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
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
58
        .filter(|s| !s.is_empty())
53
3
        .collect();
54
3
    let mut s = String::new();
55
51
    for (i, line) in lines.iter().enumerate() {
56
51
        let hurl_str = parse_line(line).map_err(|message| {
57
            format!("Can not parse curl command at line {}: {message}", i + 1)
58
        })?;
59
51
        s.push_str(format!("{hurl_str}\n").as_str());
60
    }
61
3
    Ok(s)
62
}
63

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

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

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

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

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

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

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

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

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

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

            
168
GET http://localhost:8000/custom-headers
169
Fruit:Raspberry
170

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

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

            
189
"#;
190

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

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

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

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

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

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

            
265
    #[test]
266
    fn test_digest_flag() {
267
        let hurl_str = r#"GET http://localhost:8000/hello
268
[Options]
269
digest: true
270
"#;
271
        assert_eq!(
272
            parse_line("curl --digest http://localhost:8000/hello").unwrap(),
273
            hurl_str
274
        );
275
    }
276

            
277
    #[test]
278
    fn test_empty_cookie() {
279
        assert!(
280
            parse_line("curl http://localhost:8000/empty-cookie -b 'valid=pair' -b ''")
281
                .unwrap_err()
282
                .contains("empty value provided")
283
        );
284
    }
285

            
286
    #[test]
287
    fn test_single_illegal_cookie_pair() {
288
        assert!(
289
            parse_line("curl http://localhost:8000/empty-cookie -b 'valid=pair' -b 'invalid'")
290
                .unwrap_err()
291
                .contains("invalid cookie pair provided")
292
        );
293
    }
294

            
295
    #[test]
296
    fn test_multiple_illegal_cookie_pairs() {
297
        assert!(parse_line(
298
            "curl http://localhost:8000/empty-cookie -b 'name=value' -b 'valid=pair; invalid-1; invalid-2'"
299
        )
300
        .unwrap_err()
301
        .contains("invalid cookie pairs provided: [invalid-1, invalid-2]"));
302
    }
303

            
304
    #[test]
305
    fn test_post_hello() {
306
        let hurl_str = r#"POST http://localhost:8000/hello
307
Content-Type: text/plain
308
```
309
hello
310
```
311
"#;
312
        assert_eq!(
313
            parse_line(r#"curl -d $'hello'  -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#).unwrap(),
314
            hurl_str
315
        );
316
    }
317

            
318
    #[test]
319
    fn test_post_format_params() {
320
        let hurl_str = r#"POST http://localhost:3000/data
321
Content-Type: application/x-www-form-urlencoded
322
```
323
param1=value1&param2=value2
324
```
325
"#;
326
        assert_eq!(
327
            parse_line("curl http://localhost:3000/data -d 'param1=value1&param2=value2'").unwrap(),
328
            hurl_str
329
        );
330
        assert_eq!(
331
            parse_line("curl -X POST http://localhost:3000/data -H 'Content-Type: application/x-www-form-urlencoded' --data 'param1=value1&param2=value2'").unwrap(),
332
            hurl_str
333
        );
334
    }
335

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

            
349
        let hurl_str = r#"POST http://localhost:3000/data
350
Content-Type: application/json
351
```
352
{
353
  "key1": "value1",
354
  "key2": "value2"
355
}
356
```
357
"#;
358
        assert_eq!(
359
            parse_line(r#"curl -d $'{\n  "key1": "value1",\n  "key2": "value2"\n}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap(),
360
            hurl_str
361
        );
362
    }
363

            
364
    #[test]
365
    fn test_post_file() {
366
        let hurl_str = r#"POST http://example.com/
367
file, filename;
368
"#;
369
        assert_eq!(
370
            parse_line(r#"curl --data @filename http://example.com/"#).unwrap(),
371
            hurl_str
372
        );
373
    }
374

            
375
    #[test]
376
    fn test_redirect() {
377
        let hurl_str = r#"GET http://localhost:8000/redirect-absolute
378
[Options]
379
location: true
380
"#;
381
        assert_eq!(
382
            parse_line(r#"curl -L http://localhost:8000/redirect-absolute"#).unwrap(),
383
            hurl_str
384
        );
385
    }
386

            
387
    #[test]
388
    fn test_insecure() {
389
        let hurl_str = r#"GET https://localhost:8001/hello
390
[Options]
391
insecure: true
392
"#;
393
        assert_eq!(
394
            parse_line(r#"curl -k https://localhost:8001/hello"#).unwrap(),
395
            hurl_str
396
        );
397
    }
398

            
399
    #[test]
400
    fn test_max_redirects() {
401
        let hurl_str = r#"GET https://localhost:8001/hello
402
[Options]
403
max-redirs: 10
404
"#;
405
        assert_eq!(
406
            parse_line(r#"curl https://localhost:8001/hello --max-redirs 10"#).unwrap(),
407
            hurl_str
408
        );
409
    }
410

            
411
    #[test]
412
    fn test_verbose_flag() {
413
        let hurl_str = r#"GET http://localhost:8000/hello
414
[Options]
415
verbose: true
416
"#;
417
        let flags = vec!["-v", "--verbose"];
418
        for flag in flags {
419
            assert_eq!(
420
                parse_line(format!("curl {flag} http://localhost:8000/hello").as_str()).unwrap(),
421
                hurl_str
422
            );
423
        }
424
    }
425

            
426
    #[test]
427
    fn test_user_option() {
428
        let user = "test_user:test_pass";
429
        let hurl_str = format!("GET http://localhost:8000/hello\n[Options]\nuser: {user}\n");
430

            
431
        let flags = vec!["-u", "--user"];
432
        for flag in flags {
433
            assert_eq!(
434
                parse_line(&format!("curl {flag} '{user}' http://localhost:8000/hello")).unwrap(),
435
                hurl_str
436
            );
437
        }
438
    }
439

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

            
452
    #[test]
453
    fn test_negotiate_flag() {
454
        let hurl_str = r#"GET http://localhost:8000/hello
455
[Options]
456
negotiate: true
457
"#;
458
        assert_eq!(
459
            parse_line("curl --negotiate http://localhost:8000/hello").unwrap(),
460
            hurl_str
461
        );
462
    }
463

            
464
    #[test]
465
    fn test_data_raw_literal() {
466
        let hurl_str = r#"POST http://example.com/
467
Content-Type: application/x-www-form-urlencoded
468
```
469
@filename
470
```
471
"#;
472
        assert_eq!(
473
            parse_line(r#"curl --data-raw @filename http://example.com/"#).unwrap(),
474
            hurl_str
475
        );
476
    }
477

            
478
    #[test]
479
    fn test_data_raw_plain() {
480
        let hurl_str = r#"POST http://localhost:8000/hello
481
Content-Type: text/plain
482
```
483
hello
484
```
485
"#;
486
        assert_eq!(
487
            parse_line(
488
                r#"curl --data-raw 'hello' -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#
489
            )
490
            .unwrap(),
491
            hurl_str
492
        );
493
    }
494

            
495
    #[test]
496
    fn test_data_raw_json() {
497
        let hurl_str = r#"POST http://localhost:3000/data
498
Content-Type: application/json
499
```
500
{"key1":"value1", "key2":"value2"}
501
```
502
"#;
503
        assert_eq!(
504
            hurl_str,
505
            parse_line(
506
                r#"curl --data-raw '{"key1":"value1", "key2":"value2"}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#
507
            )
508
            .unwrap()
509
        );
510
    }
511
}