1
/*
2
 * Hurl (https://hurl.dev)
3
 * Copyright (C) 2026 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
18
    pub fn new(name: &str, value: &str) -> HurlOption {
33
18
        HurlOption {
34
18
            name: name.to_string(),
35
18
            value: value.to_string(),
36
        }
37
    }
38
}
39

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

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

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

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

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

            
90
36
    let method = matches::method(&arg_matches);
91
36
    let url = matches::url(&arg_matches)?;
92
34
    let headers = matches::headers(&arg_matches);
93
34
    let cookies = matches::cookies(&arg_matches);
94
34
    let options = matches::options(&arg_matches);
95
34
    let body = matches::body(&arg_matches);
96
34
    let s = format(&method, &url, &headers, &cookies, &options, body);
97
34
    Ok(s)
98
}
99

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

            
117
34
    if !cookies.is_empty() {
118
        s.push_str(format!("\ncookie: {}", cookies.join("; ")).as_str());
119
    }
120

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

            
143
34
fn has_option(options: &[HurlOption], name: &str) -> bool {
144
34
    for option in options {
145
18
        if option.name == name {
146
2
            return true;
147
        }
148
    }
149
32
    false
150
}
151

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

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

            
164
    #[test]
165
    fn test_missing_url() {
166
        for input in ["curl", "curl -X POST", "curl -H 'Accept: application/json'"] {
167
            assert_eq!(
168
                parse(input).unwrap_err(),
169
                "Can not parse curl command at line 1: URL is missing"
170
            );
171
        }
172
    }
173

            
174
    #[test]
175
    fn test_url_forms() {
176
        for input in [
177
            "curl https://example.com",
178
            "curl --url https://example.com",
179
            "curl example.com",
180
        ] {
181
            assert_eq!(parse_line(input).unwrap(), "GET https://example.com\n");
182
        }
183
    }
184

            
185
    #[test]
186
    fn test_parse() {
187
        let hurl_str = r#"GET http://localhost:8000/hello
188

            
189
GET http://localhost:8000/custom-headers
190
Fruit:Raspberry
191

            
192
"#;
193
        assert_eq!(
194
            parse(
195
                r#"curl http://localhost:8000/hello
196
curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry'
197
"#
198
            )
199
            .unwrap(),
200
            hurl_str
201
        );
202
    }
203

            
204
    #[test]
205
    fn test_parse_with_escape() {
206
        let hurl_str = r#"GET http://localhost:8000/custom_headers
207
Fruit:Raspberry
208
Fruit:Banana
209

            
210
"#;
211

            
212
        assert_eq!(
213
            parse(
214
                r#"curl http://localhost:8000/custom_headers \
215
                -H 'Fruit:Raspberry' \
216
                -H 'Fruit:Banana'
217
"#,
218
            )
219
            .unwrap(),
220
            hurl_str
221
        );
222
    }
223

            
224
    #[test]
225
    fn test_hello() {
226
        let hurl_str = r#"GET http://localhost:8000/hello
227
"#;
228
        assert_eq!(
229
            parse_line("curl http://localhost:8000/hello").unwrap(),
230
            hurl_str
231
        );
232
    }
233

            
234
    #[test]
235
    fn test_headers() {
236
        let hurl_str = r#"GET http://localhost:8000/custom-headers
237
Fruit:Raspberry
238
Fruit: Banana
239
Test: '
240
"#;
241
        assert_eq!(
242
            parse_line("curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry' -H 'Fruit: Banana' -H $'Test: \\''").unwrap(),
243
            hurl_str
244
        );
245
        assert_eq!(
246
            parse_line("curl http://localhost:8000/custom-headers   --header Fruit:Raspberry -H 'Fruit: Banana' -H $'Test: \\''  ").unwrap(),
247
            hurl_str
248
        );
249
    }
250

            
251
    #[test]
252
    fn test_empty_headers() {
253
        let hurl_str = r#"GET http://localhost:8000/empty-headers
254
Empty-Header:
255
"#;
256
        assert_eq!(
257
            parse_line("curl http://localhost:8000/empty-headers -H 'Empty-Header;'").unwrap(),
258
            hurl_str
259
        );
260
    }
261

            
262
    #[test]
263
    fn test_illegal_header() {
264
        assert!(
265
            parse_line("curl http://localhost:8000/illegal-header -H 'Illegal-Header'")
266
                .unwrap_err()
267
                .contains("headers must be formatted as '<NAME:VALUE>' or '<NAME>;'")
268
        );
269
    }
270

            
271
    #[test]
272
    fn test_valid_cookies() {
273
        let hurl_str = r#"GET http://localhost:8000/custom-cookies
274
cookie: name1=value1; name2=value2; name3=value3
275
"#;
276
        assert_eq!(
277
            parse_line("curl http://localhost:8000/custom-cookies -b 'name1=value1' -b 'name2=value2;name3=value3;;'").unwrap(),
278
            hurl_str
279
        );
280
        assert_eq!(
281
            parse_line("curl http://localhost:8000/custom-cookies --cookie 'name1=value1' --cookie 'name2=value2;name3=value3;;'").unwrap(),
282
            hurl_str
283
        );
284
    }
285

            
286
    #[test]
287
    fn test_digest_flag() {
288
        let hurl_str = r#"GET http://localhost:8000/hello
289
[Options]
290
digest: true
291
"#;
292
        assert_eq!(
293
            parse_line("curl --digest http://localhost:8000/hello").unwrap(),
294
            hurl_str
295
        );
296
    }
297

            
298
    #[test]
299
    fn test_empty_cookie() {
300
        assert!(
301
            parse_line("curl http://localhost:8000/empty-cookie -b 'valid=pair' -b ''")
302
                .unwrap_err()
303
                .contains("empty value provided")
304
        );
305
    }
306

            
307
    #[test]
308
    fn test_single_illegal_cookie_pair() {
309
        assert!(
310
            parse_line("curl http://localhost:8000/empty-cookie -b 'valid=pair' -b 'invalid'")
311
                .unwrap_err()
312
                .contains("invalid cookie pair provided")
313
        );
314
    }
315

            
316
    #[test]
317
    fn test_multiple_illegal_cookie_pairs() {
318
        assert!(parse_line(
319
            "curl http://localhost:8000/empty-cookie -b 'name=value' -b 'valid=pair; invalid-1; invalid-2'"
320
        )
321
        .unwrap_err()
322
        .contains("invalid cookie pairs provided: [invalid-1, invalid-2]"));
323
    }
324

            
325
    #[test]
326
    fn test_post_hello() {
327
        let hurl_str = r#"POST http://localhost:8000/hello
328
Content-Type: text/plain
329
```
330
hello
331
```
332
"#;
333
        assert_eq!(
334
            parse_line(r#"curl -d $'hello'  -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#).unwrap(),
335
            hurl_str
336
        );
337
    }
338

            
339
    #[test]
340
    fn test_post_format_params() {
341
        let hurl_str = r#"POST http://localhost:3000/data
342
Content-Type: application/x-www-form-urlencoded
343
```
344
param1=value1&param2=value2
345
```
346
"#;
347
        assert_eq!(
348
            parse_line("curl http://localhost:3000/data -d 'param1=value1&param2=value2'").unwrap(),
349
            hurl_str
350
        );
351
        assert_eq!(
352
            parse_line("curl -X POST http://localhost:3000/data -H 'Content-Type: application/x-www-form-urlencoded' --data 'param1=value1&param2=value2'").unwrap(),
353
            hurl_str
354
        );
355
    }
356

            
357
    #[test]
358
    fn test_post_json() {
359
        let hurl_str = r#"POST http://localhost:3000/data
360
Content-Type: application/json
361
```
362
{"key1":"value1", "key2":"value2"}
363
```
364
"#;
365
        assert_eq!(
366
                hurl_str,
367
                parse_line(r#"curl -d '{"key1":"value1", "key2":"value2"}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap()
368
            );
369

            
370
        let hurl_str = r#"POST http://localhost:3000/data
371
Content-Type: application/json
372
```
373
{
374
  "key1": "value1",
375
  "key2": "value2"
376
}
377
```
378
"#;
379
        assert_eq!(
380
            parse_line(r#"curl -d $'{\n  "key1": "value1",\n  "key2": "value2"\n}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap(),
381
            hurl_str
382
        );
383
    }
384

            
385
    #[test]
386
    fn test_post_file() {
387
        let hurl_str = r#"POST http://example.com/
388
file, filename;
389
"#;
390
        assert_eq!(
391
            parse_line(r#"curl --data @filename http://example.com/"#).unwrap(),
392
            hurl_str
393
        );
394
    }
395

            
396
    #[test]
397
    fn test_redirect() {
398
        let hurl_str = r#"GET http://localhost:8000/redirect-absolute
399
[Options]
400
location: true
401
"#;
402
        assert_eq!(
403
            parse_line(r#"curl -L http://localhost:8000/redirect-absolute"#).unwrap(),
404
            hurl_str
405
        );
406
    }
407

            
408
    #[test]
409
    fn test_insecure() {
410
        let hurl_str = r#"GET https://localhost:8001/hello
411
[Options]
412
insecure: true
413
"#;
414
        assert_eq!(
415
            parse_line(r#"curl -k https://localhost:8001/hello"#).unwrap(),
416
            hurl_str
417
        );
418
    }
419

            
420
    #[test]
421
    fn test_max_redirects() {
422
        let hurl_str = r#"GET https://localhost:8001/hello
423
[Options]
424
max-redirs: 10
425
"#;
426
        assert_eq!(
427
            parse_line(r#"curl https://localhost:8001/hello --max-redirs 10"#).unwrap(),
428
            hurl_str
429
        );
430
    }
431

            
432
    #[test]
433
    fn test_verbose_flag() {
434
        let hurl_str = r#"GET http://localhost:8000/hello
435
[Options]
436
verbose: true
437
"#;
438
        let flags = vec!["-v", "--verbose"];
439
        for flag in flags {
440
            assert_eq!(
441
                parse_line(format!("curl {flag} http://localhost:8000/hello").as_str()).unwrap(),
442
                hurl_str
443
            );
444
        }
445
    }
446

            
447
    #[test]
448
    fn test_user_option() {
449
        let user = "test_user:test_pass";
450
        let hurl_str = format!("GET http://localhost:8000/hello\n[Options]\nuser: {user}\n");
451

            
452
        let flags = vec!["-u", "--user"];
453
        for flag in flags {
454
            assert_eq!(
455
                parse_line(&format!("curl {flag} '{user}' http://localhost:8000/hello")).unwrap(),
456
                hurl_str
457
            );
458
        }
459
    }
460

            
461
    #[test]
462
    fn test_ntlm_flag() {
463
        let hurl_str = r#"GET http://localhost:8000/hello
464
[Options]
465
ntlm: true
466
"#;
467
        assert_eq!(
468
            parse_line("curl --ntlm http://localhost:8000/hello").unwrap(),
469
            hurl_str
470
        );
471
    }
472

            
473
    #[test]
474
    fn test_negotiate_flag() {
475
        let hurl_str = r#"GET http://localhost:8000/hello
476
[Options]
477
negotiate: true
478
"#;
479
        assert_eq!(
480
            parse_line("curl --negotiate http://localhost:8000/hello").unwrap(),
481
            hurl_str
482
        );
483
    }
484

            
485
    #[test]
486
    fn test_data_raw_literal() {
487
        let hurl_str = r#"POST http://example.com/
488
Content-Type: application/x-www-form-urlencoded
489
```
490
@filename
491
```
492
"#;
493
        assert_eq!(
494
            parse_line(r#"curl --data-raw @filename http://example.com/"#).unwrap(),
495
            hurl_str
496
        );
497
    }
498

            
499
    #[test]
500
    fn test_data_raw_plain() {
501
        let hurl_str = r#"POST http://localhost:8000/hello
502
Content-Type: text/plain
503
```
504
hello
505
```
506
"#;
507
        assert_eq!(
508
            parse_line(
509
                r#"curl --data-raw 'hello' -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#
510
            )
511
            .unwrap(),
512
            hurl_str
513
        );
514
    }
515

            
516
    #[test]
517
    fn test_data_raw_json() {
518
        let hurl_str = r#"POST http://localhost:3000/data
519
Content-Type: application/json
520
```
521
{"key1":"value1", "key2":"value2"}
522
```
523
"#;
524
        assert_eq!(
525
            hurl_str,
526
            parse_line(
527
                r#"curl --data-raw '{"key1":"value1", "key2":"value2"}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#
528
            )
529
            .unwrap()
530
        );
531
    }
532
}