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
27
    pub fn new(name: &str, value: &str) -> HurlOption {
33
27
        HurlOption {
34
27
            name: name.to_string(),
35
27
            value: value.to_string(),
36
        }
37
    }
38
}
39

            
40
impl fmt::Display for HurlOption {
41
27
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42
27
        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

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

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

            
82
45
    let params = args::split(s)?;
83
45
    let arg_matches = match command.try_get_matches_from_mut(params) {
84
45
        Ok(r) => r,
85
        Err(e) => return Err(e.to_string()),
86
    };
87

            
88
45
    let method = matches::method(&arg_matches);
89
45
    let url = matches::url(&arg_matches);
90
45
    let headers = matches::headers(&arg_matches);
91
45
    let cookies = matches::cookies(&arg_matches);
92
45
    let options = matches::options(&arg_matches);
93
45
    let body = matches::body(&arg_matches);
94
45
    let s = format(&method, &url, &headers, &cookies, &options, body);
95
45
    Ok(s)
96
}
97

            
98
45
fn format(
99
45
    method: &str,
100
45
    url: &str,
101
45
    headers: &[String],
102
45
    cookies: &[String],
103
45
    options: &[HurlOption],
104
45
    body: Option<String>,
105
45
) -> String {
106
45
    let mut s = format!("{method} {url}");
107
69
    for header in headers {
108
24
        if let Some(stripped) = header.strip_suffix(";") {
109
3
            s.push_str(format!("\n{stripped}:").as_str());
110
21
        } else {
111
21
            s.push_str(format!("\n{header}").as_str());
112
        }
113
    }
114

            
115
45
    if !cookies.is_empty() {
116
        s.push_str(format!("\ncookie: {}", cookies.join("; ")).as_str());
117
    }
118

            
119
45
    if !options.is_empty() {
120
27
        s.push_str("\n[Options]");
121
54
        for option in options {
122
27
            s.push_str(format!("\n{option}").as_str());
123
        }
124
    }
125
45
    if let Some(body) = body {
126
6
        s.push('\n');
127
6
        s.push_str(body.as_str());
128
    }
129
45
    let asserts = additional_asserts(options);
130
45
    if !asserts.is_empty() {
131
3
        s.push_str("\nHTTP *");
132
3
        s.push_str("\n[Asserts]");
133
6
        for assert in asserts {
134
3
            s.push_str(format!("\n{assert}").as_str());
135
        }
136
    }
137
45
    s.push('\n');
138
45
    s
139
}
140

            
141
45
fn has_option(options: &[HurlOption], name: &str) -> bool {
142
69
    for option in options {
143
27
        if option.name == name {
144
3
            return true;
145
        }
146
    }
147
42
    false
148
}
149

            
150
45
fn additional_asserts(options: &[HurlOption]) -> Vec<String> {
151
45
    let mut asserts = vec![];
152
45
    if has_option(options, "retry") {
153
3
        asserts.push("status < 500".to_string());
154
    }
155
45
    asserts
156
}
157

            
158
#[cfg(test)]
159
mod test {
160
    use crate::curl::*;
161

            
162
    #[test]
163
    fn test_parse() {
164
        let hurl_str = r#"GET http://localhost:8000/hello
165

            
166
GET http://localhost:8000/custom-headers
167
Fruit:Raspberry
168

            
169
"#;
170
        assert_eq!(
171
            parse(
172
                r#"curl http://localhost:8000/hello
173
curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry'
174
"#
175
            )
176
            .unwrap(),
177
            hurl_str
178
        );
179
    }
180

            
181
    #[test]
182
    fn test_parse_with_escape() {
183
        let hurl_str = r#"GET http://localhost:8000/custom_headers
184
Fruit:Raspberry
185
Fruit:Banana
186

            
187
"#;
188

            
189
        assert_eq!(
190
            parse(
191
                r#"curl http://localhost:8000/custom_headers \
192
                -H 'Fruit:Raspberry' \
193
                -H 'Fruit:Banana'
194
"#,
195
            )
196
            .unwrap(),
197
            hurl_str
198
        );
199
    }
200

            
201
    #[test]
202
    fn test_hello() {
203
        let hurl_str = r#"GET http://localhost:8000/hello
204
"#;
205
        assert_eq!(
206
            parse_line("curl http://localhost:8000/hello").unwrap(),
207
            hurl_str
208
        );
209
    }
210

            
211
    #[test]
212
    fn test_headers() {
213
        let hurl_str = r#"GET http://localhost:8000/custom-headers
214
Fruit:Raspberry
215
Fruit: Banana
216
Test: '
217
"#;
218
        assert_eq!(
219
            parse_line("curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry' -H 'Fruit: Banana' -H $'Test: \\''").unwrap(),
220
            hurl_str
221
        );
222
        assert_eq!(
223
            parse_line("curl http://localhost:8000/custom-headers   --header Fruit:Raspberry -H 'Fruit: Banana' -H $'Test: \\''  ").unwrap(),
224
            hurl_str
225
        );
226
    }
227

            
228
    #[test]
229
    fn test_empty_headers() {
230
        let hurl_str = r#"GET http://localhost:8000/empty-headers
231
Empty-Header:
232
"#;
233
        assert_eq!(
234
            parse_line("curl http://localhost:8000/empty-headers -H 'Empty-Header;'").unwrap(),
235
            hurl_str
236
        );
237
    }
238

            
239
    #[test]
240
    fn test_illegal_header() {
241
        assert!(
242
            parse_line("curl http://localhost:8000/illegal-header -H 'Illegal-Header'")
243
                .unwrap_err()
244
                .contains("headers must be formatted as '<NAME:VALUE>' or '<NAME>;'")
245
        );
246
    }
247

            
248
    #[test]
249
    fn test_valid_cookies() {
250
        let hurl_str = r#"GET http://localhost:8000/custom-cookies
251
cookie: name1=value1; name2=value2; name3=value3
252
"#;
253
        assert_eq!(
254
            parse_line("curl http://localhost:8000/custom-cookies -b 'name1=value1' -b 'name2=value2;name3=value3;;'").unwrap(),
255
            hurl_str
256
        );
257
        assert_eq!(
258
            parse_line("curl http://localhost:8000/custom-cookies --cookie 'name1=value1' --cookie 'name2=value2;name3=value3;;'").unwrap(),
259
            hurl_str
260
        );
261
    }
262

            
263
    #[test]
264
    fn test_empty_cookie() {
265
        assert!(
266
            parse_line("curl http://localhost:8000/empty-cookie -b 'valid=pair' -b ''")
267
                .unwrap_err()
268
                .contains("empty value provided")
269
        );
270
    }
271

            
272
    #[test]
273
    fn test_single_illegal_cookie_pair() {
274
        assert!(
275
            parse_line("curl http://localhost:8000/empty-cookie -b 'valid=pair' -b 'invalid'")
276
                .unwrap_err()
277
                .contains("invalid cookie pair provided")
278
        );
279
    }
280

            
281
    #[test]
282
    fn test_multiple_illegal_cookie_pairs() {
283
        assert!(parse_line(
284
            "curl http://localhost:8000/empty-cookie -b 'name=value' -b 'valid=pair; invalid-1; invalid-2'"
285
        )
286
        .unwrap_err()
287
        .contains("invalid cookie pairs provided: [invalid-1, invalid-2]"));
288
    }
289

            
290
    #[test]
291
    fn test_post_hello() {
292
        let hurl_str = r#"POST http://localhost:8000/hello
293
Content-Type: text/plain
294
```
295
hello
296
```
297
"#;
298
        assert_eq!(
299
            parse_line(r#"curl -d $'hello'  -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#).unwrap(),
300
            hurl_str
301
        );
302
    }
303

            
304
    #[test]
305
    fn test_post_format_params() {
306
        let hurl_str = r#"POST http://localhost:3000/data
307
Content-Type: application/x-www-form-urlencoded
308
```
309
param1=value1&param2=value2
310
```
311
"#;
312
        assert_eq!(
313
            parse_line("curl http://localhost:3000/data -d 'param1=value1&param2=value2'").unwrap(),
314
            hurl_str
315
        );
316
        assert_eq!(
317
            parse_line("curl -X POST http://localhost:3000/data -H 'Content-Type: application/x-www-form-urlencoded' --data 'param1=value1&param2=value2'").unwrap(),
318
            hurl_str
319
        );
320
    }
321

            
322
    #[test]
323
    fn test_post_json() {
324
        let hurl_str = r#"POST http://localhost:3000/data
325
Content-Type: application/json
326
```
327
{"key1":"value1", "key2":"value2"}
328
```
329
"#;
330
        assert_eq!(
331
                hurl_str,
332
                parse_line(r#"curl -d '{"key1":"value1", "key2":"value2"}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap()
333
            );
334

            
335
        let hurl_str = r#"POST http://localhost:3000/data
336
Content-Type: application/json
337
```
338
{
339
  "key1": "value1",
340
  "key2": "value2"
341
}
342
```
343
"#;
344
        assert_eq!(
345
            parse_line(r#"curl -d $'{\n  "key1": "value1",\n  "key2": "value2"\n}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap(),
346
            hurl_str
347
        );
348
    }
349

            
350
    #[test]
351
    fn test_post_file() {
352
        let hurl_str = r#"POST http://example.com/
353
file, filename;
354
"#;
355
        assert_eq!(
356
            parse_line(r#"curl --data @filename http://example.com/"#).unwrap(),
357
            hurl_str
358
        );
359
    }
360

            
361
    #[test]
362
    fn test_redirect() {
363
        let hurl_str = r#"GET http://localhost:8000/redirect-absolute
364
[Options]
365
location: true
366
"#;
367
        assert_eq!(
368
            parse_line(r#"curl -L http://localhost:8000/redirect-absolute"#).unwrap(),
369
            hurl_str
370
        );
371
    }
372

            
373
    #[test]
374
    fn test_insecure() {
375
        let hurl_str = r#"GET https://localhost:8001/hello
376
[Options]
377
insecure: true
378
"#;
379
        assert_eq!(
380
            parse_line(r#"curl -k https://localhost:8001/hello"#).unwrap(),
381
            hurl_str
382
        );
383
    }
384

            
385
    #[test]
386
    fn test_max_redirects() {
387
        let hurl_str = r#"GET https://localhost:8001/hello
388
[Options]
389
max-redirs: 10
390
"#;
391
        assert_eq!(
392
            parse_line(r#"curl https://localhost:8001/hello --max-redirs 10"#).unwrap(),
393
            hurl_str
394
        );
395
    }
396

            
397
    #[test]
398
    fn test_verbose_flag() {
399
        let hurl_str = r#"GET http://localhost:8000/hello
400
[Options]
401
verbose: true
402
"#;
403
        let flags = vec!["-v", "--verbose"];
404
        for flag in flags {
405
            assert_eq!(
406
                parse_line(format!("curl {flag} http://localhost:8000/hello").as_str()).unwrap(),
407
                hurl_str
408
            );
409
        }
410
    }
411

            
412
    #[test]
413
    fn test_user_option() {
414
        let user = "test_user:test_pass";
415
        let hurl_str = format!("GET http://localhost:8000/hello\n[Options]\nuser: {user}\n");
416

            
417
        let flags = vec!["-u", "--user"];
418
        for flag in flags {
419
            assert_eq!(
420
                parse_line(&format!("curl {flag} '{user}' http://localhost:8000/hello")).unwrap(),
421
                hurl_str
422
            );
423
        }
424
    }
425

            
426
    #[test]
427
    fn test_ntlm_flag() {
428
        let hurl_str = r#"GET http://localhost:8000/hello
429
[Options]
430
ntlm: true
431
"#;
432
        assert_eq!(
433
            parse_line("curl --ntlm http://localhost:8000/hello").unwrap(),
434
            hurl_str
435
        );
436
    }
437

            
438
    #[test]
439
    fn test_negotiate_flag() {
440
        let hurl_str = r#"GET http://localhost:8000/hello
441
[Options]
442
negotiate: true
443
"#;
444
        assert_eq!(
445
            parse_line("curl --negotiate http://localhost:8000/hello").unwrap(),
446
            hurl_str
447
        );
448
    }
449
}