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

            
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
3

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

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

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

            
93
33
fn format(
94
33
    method: &str,
95
33
    url: &str,
96
33
    headers: Vec<String>,
97
33
    options: &[HurlOption],
98
33
    body: Option<String>,
99
33
) -> String {
100
33
    let mut s = format!("{method} {url}");
101
57
    for header in headers {
102
24
        if let Some(stripped) = header.strip_suffix(";") {
103
3
            s.push_str(format!("\n{}:", stripped).as_str());
104
21
        } else {
105
21
            s.push_str(format!("\n{header}").as_str());
106
        }
107
    }
108
33
    if !options.is_empty() {
109
15
        s.push_str("\n[Options]");
110
30
        for option in options {
111
15
            s.push_str(format!("\n{option}").as_str());
112
        }
113
    }
114
33
    if let Some(body) = body {
115
6
        s.push('\n');
116
6
        s.push_str(body.as_str());
117
    }
118
33
    let asserts = additional_asserts(options);
119
33
    if !asserts.is_empty() {
120
3
        s.push_str("\nHTTP *");
121
3
        s.push_str("\n[Asserts]");
122
6
        for assert in asserts {
123
3
            s.push_str(format!("\n{assert}").as_str());
124
        }
125
    }
126
33
    s.push('\n');
127
33
    s
128
}
129

            
130
33
fn has_option(options: &[HurlOption], name: &str) -> bool {
131
45
    for option in options {
132
15
        if option.name == name {
133
3
            return true;
134
        }
135
    }
136
30
    false
137
}
138

            
139
33
fn additional_asserts(options: &[HurlOption]) -> Vec<String> {
140
33
    let mut asserts = vec![];
141
33
    if has_option(options, "retry") {
142
3
        asserts.push("status < 500".to_string());
143
    }
144
33
    asserts
145
}
146

            
147
#[cfg(test)]
148
mod test {
149
    use crate::curl::*;
150

            
151
    #[test]
152
    fn test_parse() {
153
        let hurl_str = r#"GET http://localhost:8000/hello
154

            
155
GET http://localhost:8000/custom-headers
156
Fruit:Raspberry
157

            
158
"#;
159
        assert_eq!(
160
            parse(
161
                r#"curl http://localhost:8000/hello
162
curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry'
163
"#
164
            )
165
            .unwrap(),
166
            hurl_str
167
        );
168
    }
169

            
170
    #[test]
171
    fn test_parse_with_escape() {
172
        let hurl_str = r#"GET http://localhost:8000/custom_headers
173
Fruit:Raspberry
174
Fruit:Banana
175

            
176
"#;
177

            
178
        assert_eq!(
179
            parse(
180
                r#"curl http://localhost:8000/custom_headers \
181
                -H 'Fruit:Raspberry' \
182
                -H 'Fruit:Banana'
183
"#,
184
            )
185
            .unwrap(),
186
            hurl_str
187
        );
188
    }
189

            
190
    #[test]
191
    fn test_hello() {
192
        let hurl_str = r#"GET http://localhost:8000/hello
193
"#;
194
        assert_eq!(
195
            parse_line("curl http://localhost:8000/hello").unwrap(),
196
            hurl_str
197
        );
198
    }
199

            
200
    #[test]
201
    fn test_headers() {
202
        let hurl_str = r#"GET http://localhost:8000/custom-headers
203
Fruit:Raspberry
204
Fruit: Banana
205
Test: '
206
"#;
207
        assert_eq!(
208
            parse_line("curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry' -H 'Fruit: Banana' -H $'Test: \\''").unwrap(),
209
            hurl_str
210
        );
211
        assert_eq!(
212
            parse_line("curl http://localhost:8000/custom-headers   --header Fruit:Raspberry -H 'Fruit: Banana' -H $'Test: \\''  ").unwrap(),
213
            hurl_str
214
        );
215
    }
216

            
217
    #[test]
218
    fn test_empty_headers() {
219
        let hurl_str = r#"GET http://localhost:8000/empty-headers
220
Empty-Header:
221
"#;
222
        assert_eq!(
223
            parse_line("curl http://localhost:8000/empty-headers -H 'Empty-Header;'").unwrap(),
224
            hurl_str
225
        );
226
    }
227

            
228
    #[test]
229
    fn test_post_hello() {
230
        let hurl_str = r#"POST http://localhost:8000/hello
231
Content-Type: text/plain
232
```
233
hello
234
```
235
"#;
236
        assert_eq!(
237
            parse_line(r#"curl -d $'hello'  -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#).unwrap(),
238
            hurl_str
239
        );
240
    }
241

            
242
    #[test]
243
    fn test_post_format_params() {
244
        let hurl_str = r#"POST http://localhost:3000/data
245
Content-Type: application/x-www-form-urlencoded
246
```
247
param1=value1&param2=value2
248
```
249
"#;
250
        assert_eq!(
251
            parse_line("curl http://localhost:3000/data -d 'param1=value1&param2=value2'").unwrap(),
252
            hurl_str
253
        );
254
        assert_eq!(
255
            parse_line("curl -X POST http://localhost:3000/data -H 'Content-Type: application/x-www-form-urlencoded' --data 'param1=value1&param2=value2'").unwrap(),
256
            hurl_str
257
        );
258
    }
259

            
260
    #[test]
261
    fn test_post_json() {
262
        let hurl_str = r#"POST http://localhost:3000/data
263
Content-Type: application/json
264
```
265
{"key1":"value1", "key2":"value2"}
266
```
267
"#;
268
        assert_eq!(
269
                hurl_str,
270
                parse_line(r#"curl -d '{"key1":"value1", "key2":"value2"}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap()
271
            );
272

            
273
        let hurl_str = r#"POST http://localhost:3000/data
274
Content-Type: application/json
275
```
276
{
277
  "key1": "value1",
278
  "key2": "value2"
279
}
280
```
281
"#;
282
        assert_eq!(
283
            parse_line(r#"curl -d $'{\n  "key1": "value1",\n  "key2": "value2"\n}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap(),
284
            hurl_str
285
        );
286
    }
287

            
288
    #[test]
289
    fn test_post_file() {
290
        let hurl_str = r#"POST http://example.com/
291
file, filename;
292
"#;
293
        assert_eq!(
294
            parse_line(r#"curl --data @filename http://example.com/"#).unwrap(),
295
            hurl_str
296
        );
297
    }
298

            
299
    #[test]
300
    fn test_redirect() {
301
        let hurl_str = r#"GET http://localhost:8000/redirect-absolute
302
[Options]
303
location: true
304
"#;
305
        assert_eq!(
306
            parse_line(r#"curl -L http://localhost:8000/redirect-absolute"#).unwrap(),
307
            hurl_str
308
        );
309
    }
310

            
311
    #[test]
312
    fn test_insecure() {
313
        let hurl_str = r#"GET https://localhost:8001/hello
314
[Options]
315
insecure: true
316
"#;
317
        assert_eq!(
318
            parse_line(r#"curl -k https://localhost:8001/hello"#).unwrap(),
319
            hurl_str
320
        );
321
    }
322

            
323
    #[test]
324
    fn test_max_redirects() {
325
        let hurl_str = r#"GET https://localhost:8001/hello
326
[Options]
327
max-redirs: 10
328
"#;
329
        assert_eq!(
330
            parse_line(r#"curl https://localhost:8001/hello --max-redirs 10"#).unwrap(),
331
            hurl_str
332
        );
333
    }
334

            
335
    #[test]
336
    fn test_verbose_flag() {
337
        let hurl_str = r#"GET http://localhost:8000/hello
338
[Options]
339
verbose: true
340
"#;
341
        let flags = vec!["-v", "--verbose"];
342
        for flag in flags {
343
            assert_eq!(
344
                parse_line(format!("curl {} http://localhost:8000/hello", flag).as_str()).unwrap(),
345
                hurl_str
346
            );
347
        }
348
    }
349
}