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
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::digest())
69
45
        .arg(commands::headers())
70
45
        .arg(commands::cookies())
71
45
        .arg(commands::insecure())
72
45
        .arg(commands::verbose())
73
45
        .arg(commands::negotiate())
74
45
        .arg(commands::ntlm())
75
45
        .arg(commands::location())
76
45
        .arg(commands::max_redirects())
77
45
        .arg(commands::method())
78
45
        .arg(commands::retry())
79
45
        .arg(commands::user())
80
45
        .arg(commands::url())
81
45
        .arg(commands::url_param());
82

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

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

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

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

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

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

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

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

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

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

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

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

            
188
"#;
189

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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