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

            
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
        })?;
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::cookies())
70
33
        .arg(commands::insecure())
71
33
        .arg(commands::verbose())
72
33
        .arg(commands::location())
73
33
        .arg(commands::max_redirects())
74
33
        .arg(commands::method())
75
33
        .arg(commands::retry())
76
33
        .arg(commands::url())
77
33
        .arg(commands::url_param());
78

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

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

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

            
112
33
    if !cookies.is_empty() {
113
        s.push_str(format!("\ncookie: {}", cookies.join("; ")).as_str());
114
    }
115

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

            
138
33
fn has_option(options: &[HurlOption], name: &str) -> bool {
139
45
    for option in options {
140
15
        if option.name == name {
141
3
            return true;
142
        }
143
    }
144
30
    false
145
}
146

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

            
155
#[cfg(test)]
156
mod test {
157
    use crate::curl::*;
158

            
159
    #[test]
160
    fn test_parse() {
161
        let hurl_str = r#"GET http://localhost:8000/hello
162

            
163
GET http://localhost:8000/custom-headers
164
Fruit:Raspberry
165

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

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

            
184
"#;
185

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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