1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2025 Orange
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 *          http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 *
17
 */
18
use regex;
19
use std::fmt;
20

            
21
mod args;
22
mod commands;
23
mod matches;
24

            
25
#[derive(Clone, Debug, PartialEq, Eq)]
26
pub struct HurlOption {
27
    name: String,
28
    value: String,
29
}
30

            
31
impl HurlOption {
32
15
    pub fn new(name: &str, value: &str) -> HurlOption {
33
15
        HurlOption {
34
15
            name: name.to_string(),
35
15
            value: value.to_string(),
36
        }
37
    }
38
}
39

            
40
impl fmt::Display for HurlOption {
41
15
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42
15
        write!(f, "{}: {}", self.name, self.value)
43
    }
44
}
45

            
46
3
pub fn parse(s: &str) -> Result<String, String> {
47
3
    let cleaned_s = s.replace("\\\n", "").replace("\\\r\n", "");
48
3

            
49
3
    let lines: Vec<&str> = regex::Regex::new(r"\n|\r\n")
50
3
        .unwrap()
51
3
        .split(&cleaned_s)
52
40
        .filter(|s| !s.is_empty())
53
3
        .collect();
54
3
    let mut s = String::new();
55
33
    for (i, line) in lines.iter().enumerate() {
56
33
        let hurl_str = parse_line(line).map_err(|message| {
57
            format!("Can not parse curl command at line {}: {message}", i + 1)
58
33
        })?;
59
33
        s.push_str(format!("{hurl_str}\n").as_str());
60
    }
61
3
    Ok(s)
62
}
63

            
64
33
fn parse_line(s: &str) -> Result<String, String> {
65
33
    let mut command = clap::Command::new("curl")
66
33
        .arg(commands::compressed())
67
33
        .arg(commands::data())
68
33
        .arg(commands::headers())
69
33
        .arg(commands::insecure())
70
33
        .arg(commands::verbose())
71
33
        .arg(commands::location())
72
33
        .arg(commands::max_redirects())
73
33
        .arg(commands::method())
74
33
        .arg(commands::retry())
75
33
        .arg(commands::url())
76
33
        .arg(commands::url_param());
77

            
78
33
    let params = args::split(s)?;
79
33
    let arg_matches = match command.try_get_matches_from_mut(params) {
80
33
        Ok(r) => r,
81
        Err(e) => return Err(e.to_string()),
82
    };
83

            
84
33
    let method = matches::method(&arg_matches);
85
33
    let url = matches::url(&arg_matches);
86
33
    let headers = matches::headers(&arg_matches);
87
33
    let options = matches::options(&arg_matches);
88
33
    let body = matches::body(&arg_matches);
89
33
    let s = format(&method, &url, headers, &options, body);
90
33
    Ok(s)
91
}
92

            
93
33
fn format(
94
33
    method: &str,
95
33
    url: &str,
96
33
    headers: Vec<String>,
97
33
    options: &[HurlOption],
98
33
    body: Option<String>,
99
33
) -> String {
100
33
    let mut s = format!("{method} {url}");
101
57
    for header in headers {
102
24
        if let Some(stripped) = header.strip_suffix(";") {
103
3
            s.push_str(format!("\n{}:", stripped).as_str());
104
21
        } else {
105
21
            s.push_str(format!("\n{header}").as_str());
106
        }
107
    }
108
33
    if !options.is_empty() {
109
15
        s.push_str("\n[Options]");
110
30
        for option in options {
111
15
            s.push_str(format!("\n{option}").as_str());
112
        }
113
    }
114
33
    if let Some(body) = body {
115
6
        s.push('\n');
116
6
        s.push_str(body.as_str());
117
    }
118
33
    let asserts = additional_asserts(options);
119
33
    if !asserts.is_empty() {
120
3
        s.push_str("\nHTTP *");
121
3
        s.push_str("\n[Asserts]");
122
6
        for assert in asserts {
123
3
            s.push_str(format!("\n{assert}").as_str());
124
        }
125
    }
126
33
    s.push('\n');
127
33
    s
128
}
129

            
130
33
fn has_option(options: &[HurlOption], name: &str) -> bool {
131
45
    for option in options {
132
15
        if option.name == name {
133
3
            return true;
134
        }
135
    }
136
30
    false
137
}
138

            
139
33
fn additional_asserts(options: &[HurlOption]) -> Vec<String> {
140
33
    let mut asserts = vec![];
141
33
    if has_option(options, "retry") {
142
3
        asserts.push("status < 500".to_string());
143
    }
144
33
    asserts
145
}
146

            
147
#[cfg(test)]
148
mod test {
149
    use crate::curl::*;
150

            
151
    #[test]
152
    fn test_parse() {
153
        let hurl_str = r#"GET http://localhost:8000/hello
154

            
155
GET http://localhost:8000/custom-headers
156
Fruit:Raspberry
157

            
158
"#;
159
        assert_eq!(
160
            parse(
161
                r#"curl http://localhost:8000/hello
162
curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry'
163
"#
164
            )
165
            .unwrap(),
166
            hurl_str
167
        );
168
    }
169

            
170
    #[test]
171
    fn test_parse_with_escape() {
172
        let hurl_str = r#"GET http://localhost:8000/custom_headers
173
Fruit:Raspberry
174
Fruit:Banana
175

            
176
"#;
177

            
178
        assert_eq!(
179
            parse(
180
                r#"curl http://localhost:8000/custom_headers \
181
                -H 'Fruit:Raspberry' \
182
                -H 'Fruit:Banana'
183
"#,
184
            )
185
            .unwrap(),
186
            hurl_str
187
        );
188
    }
189

            
190
    #[test]
191
    fn test_hello() {
192
        let hurl_str = r#"GET http://localhost:8000/hello
193
"#;
194
        assert_eq!(
195
            parse_line("curl http://localhost:8000/hello").unwrap(),
196
            hurl_str
197
        );
198
    }
199

            
200
    #[test]
201
    fn test_headers() {
202
        let hurl_str = r#"GET http://localhost:8000/custom-headers
203
Fruit:Raspberry
204
Fruit: Banana
205
Test: '
206
"#;
207
        assert_eq!(
208
            parse_line("curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry' -H 'Fruit: Banana' -H $'Test: \\''").unwrap(),
209
            hurl_str
210
        );
211
        assert_eq!(
212
            parse_line("curl http://localhost:8000/custom-headers   --header Fruit:Raspberry -H 'Fruit: Banana' -H $'Test: \\''  ").unwrap(),
213
            hurl_str
214
        );
215
    }
216

            
217
    #[test]
218
    fn test_empty_headers() {
219
        let hurl_str = r#"GET http://localhost:8000/empty-headers
220
Empty-Header:
221
"#;
222
        assert_eq!(
223
            parse_line("curl http://localhost:8000/empty-headers -H 'Empty-Header;'").unwrap(),
224
            hurl_str
225
        );
226
    }
227

            
228
    #[test]
229
    fn test_illegal_header() {
230
        assert!(
231
            parse_line("curl http://localhost:8000/illegal-header -H 'Illegal-Header'")
232
                .unwrap_err()
233
                .contains("headers must be formatted as '<NAME:VALUE>' or '<NAME>;'")
234
        );
235
    }
236

            
237
    #[test]
238
    fn test_post_hello() {
239
        let hurl_str = r#"POST http://localhost:8000/hello
240
Content-Type: text/plain
241
```
242
hello
243
```
244
"#;
245
        assert_eq!(
246
            parse_line(r#"curl -d $'hello'  -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#).unwrap(),
247
            hurl_str
248
        );
249
    }
250

            
251
    #[test]
252
    fn test_post_format_params() {
253
        let hurl_str = r#"POST http://localhost:3000/data
254
Content-Type: application/x-www-form-urlencoded
255
```
256
param1=value1&param2=value2
257
```
258
"#;
259
        assert_eq!(
260
            parse_line("curl http://localhost:3000/data -d 'param1=value1&param2=value2'").unwrap(),
261
            hurl_str
262
        );
263
        assert_eq!(
264
            parse_line("curl -X POST http://localhost:3000/data -H 'Content-Type: application/x-www-form-urlencoded' --data 'param1=value1&param2=value2'").unwrap(),
265
            hurl_str
266
        );
267
    }
268

            
269
    #[test]
270
    fn test_post_json() {
271
        let hurl_str = r#"POST http://localhost:3000/data
272
Content-Type: application/json
273
```
274
{"key1":"value1", "key2":"value2"}
275
```
276
"#;
277
        assert_eq!(
278
                hurl_str,
279
                parse_line(r#"curl -d '{"key1":"value1", "key2":"value2"}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap()
280
            );
281

            
282
        let hurl_str = r#"POST http://localhost:3000/data
283
Content-Type: application/json
284
```
285
{
286
  "key1": "value1",
287
  "key2": "value2"
288
}
289
```
290
"#;
291
        assert_eq!(
292
            parse_line(r#"curl -d $'{\n  "key1": "value1",\n  "key2": "value2"\n}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap(),
293
            hurl_str
294
        );
295
    }
296

            
297
    #[test]
298
    fn test_post_file() {
299
        let hurl_str = r#"POST http://example.com/
300
file, filename;
301
"#;
302
        assert_eq!(
303
            parse_line(r#"curl --data @filename http://example.com/"#).unwrap(),
304
            hurl_str
305
        );
306
    }
307

            
308
    #[test]
309
    fn test_redirect() {
310
        let hurl_str = r#"GET http://localhost:8000/redirect-absolute
311
[Options]
312
location: true
313
"#;
314
        assert_eq!(
315
            parse_line(r#"curl -L http://localhost:8000/redirect-absolute"#).unwrap(),
316
            hurl_str
317
        );
318
    }
319

            
320
    #[test]
321
    fn test_insecure() {
322
        let hurl_str = r#"GET https://localhost:8001/hello
323
[Options]
324
insecure: true
325
"#;
326
        assert_eq!(
327
            parse_line(r#"curl -k https://localhost:8001/hello"#).unwrap(),
328
            hurl_str
329
        );
330
    }
331

            
332
    #[test]
333
    fn test_max_redirects() {
334
        let hurl_str = r#"GET https://localhost:8001/hello
335
[Options]
336
max-redirs: 10
337
"#;
338
        assert_eq!(
339
            parse_line(r#"curl https://localhost:8001/hello --max-redirs 10"#).unwrap(),
340
            hurl_str
341
        );
342
    }
343

            
344
    #[test]
345
    fn test_verbose_flag() {
346
        let hurl_str = r#"GET http://localhost:8000/hello
347
[Options]
348
verbose: true
349
"#;
350
        let flags = vec!["-v", "--verbose"];
351
        for flag in flags {
352
            assert_eq!(
353
                parse_line(format!("curl {} http://localhost:8000/hello", flag).as_str()).unwrap(),
354
                hurl_str
355
            );
356
        }
357
    }
358
}