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
9
    pub fn new(name: &str, value: &str) -> HurlOption {
33
9
        HurlOption {
34
9
            name: name.to_string(),
35
9
            value: value.to_string(),
36
        }
37
    }
38
}
39

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

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

            
62
27
fn parse_line(s: &str) -> Result<String, String> {
63
27
    let mut command = clap::Command::new("curl")
64
27
        .arg(commands::compressed())
65
27
        .arg(commands::data())
66
27
        .arg(commands::headers())
67
27
        .arg(commands::insecure())
68
27
        .arg(commands::location())
69
27
        .arg(commands::max_redirects())
70
27
        .arg(commands::method())
71
27
        .arg(commands::retry())
72
27
        .arg(commands::url())
73
27
        .arg(commands::url_param());
74

            
75
27
    let params = args::split(s)?;
76
27
    let arg_matches = match command.try_get_matches_from_mut(params) {
77
27
        Ok(r) => r,
78
        Err(e) => return Err(e.to_string()),
79
    };
80

            
81
27
    let method = matches::method(&arg_matches);
82
27
    let url = matches::url(&arg_matches);
83
27
    let headers = matches::headers(&arg_matches);
84
27
    let options = matches::options(&arg_matches);
85
27
    let body = matches::body(&arg_matches);
86
27
    let s = format(&method, &url, headers, &options, body);
87
27
    Ok(s)
88
}
89

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

            
127
27
fn has_option(options: &[HurlOption], name: &str) -> bool {
128
33
    for option in options {
129
9
        if option.name == name {
130
3
            return true;
131
        }
132
    }
133
24
    false
134
}
135

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

            
144
#[cfg(test)]
145
mod test {
146
    use crate::curl::*;
147

            
148
    #[test]
149
    fn test_parse() {
150
        let hurl_str = r#"GET http://localhost:8000/hello
151

            
152
GET http://localhost:8000/custom-headers
153
Fruit:Raspberry
154

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

            
167
    #[test]
168
    fn test_hello() {
169
        let hurl_str = r#"GET http://localhost:8000/hello
170
"#;
171
        assert_eq!(
172
            parse_line("curl http://localhost:8000/hello").unwrap(),
173
            hurl_str
174
        );
175
    }
176

            
177
    #[test]
178
    fn test_headers() {
179
        let hurl_str = r#"GET http://localhost:8000/custom-headers
180
Fruit:Raspberry
181
Fruit: Banana
182
Test: '
183
"#;
184
        assert_eq!(
185
            parse_line("curl http://localhost:8000/custom-headers -H 'Fruit:Raspberry' -H 'Fruit: Banana' -H $'Test: \\''").unwrap(),
186
            hurl_str
187
        );
188
        assert_eq!(
189
            parse_line("curl http://localhost:8000/custom-headers   --header Fruit:Raspberry -H 'Fruit: Banana' -H $'Test: \\''  ").unwrap(),
190
            hurl_str
191
        );
192
    }
193

            
194
    #[test]
195
    fn test_empty_headers() {
196
        let hurl_str = r#"GET http://localhost:8000/empty-headers
197
Empty-Header:
198
"#;
199
        assert_eq!(
200
            parse_line("curl http://localhost:8000/empty-headers -H 'Empty-Header;'").unwrap(),
201
            hurl_str
202
        );
203
    }
204

            
205
    #[test]
206
    fn test_post_hello() {
207
        let hurl_str = r#"POST http://localhost:8000/hello
208
Content-Type: text/plain
209
```
210
hello
211
```
212
"#;
213
        assert_eq!(
214
            parse_line(r#"curl -d $'hello'  -H 'Content-Type: text/plain' -X POST http://localhost:8000/hello"#).unwrap(),
215
            hurl_str
216
        );
217
    }
218

            
219
    #[test]
220
    fn test_post_format_params() {
221
        let hurl_str = r#"POST http://localhost:3000/data
222
Content-Type: application/x-www-form-urlencoded
223
```
224
param1=value1&param2=value2
225
```
226
"#;
227
        assert_eq!(
228
            parse_line("curl http://localhost:3000/data -d 'param1=value1&param2=value2'").unwrap(),
229
            hurl_str
230
        );
231
        assert_eq!(
232
            parse_line("curl -X POST http://localhost:3000/data -H 'Content-Type: application/x-www-form-urlencoded' --data 'param1=value1&param2=value2'").unwrap(),
233
            hurl_str
234
        );
235
    }
236

            
237
    #[test]
238
    fn test_post_json() {
239
        let hurl_str = r#"POST http://localhost:3000/data
240
Content-Type: application/json
241
```
242
{"key1":"value1", "key2":"value2"}
243
```
244
"#;
245
        assert_eq!(
246
                hurl_str,
247
                parse_line(r#"curl -d '{"key1":"value1", "key2":"value2"}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap()
248
            );
249

            
250
        let hurl_str = r#"POST http://localhost:3000/data
251
Content-Type: application/json
252
```
253
{
254
  "key1": "value1",
255
  "key2": "value2"
256
}
257
```
258
"#;
259
        assert_eq!(
260
            parse_line(r#"curl -d $'{\n  "key1": "value1",\n  "key2": "value2"\n}' -H 'Content-Type: application/json' -X POST http://localhost:3000/data"#).unwrap(),
261
            hurl_str
262
        );
263
    }
264

            
265
    #[test]
266
    fn test_post_file() {
267
        let hurl_str = r#"POST http://example.com/
268
file, filename;
269
"#;
270
        assert_eq!(
271
            parse_line(r#"curl --data @filename http://example.com/"#).unwrap(),
272
            hurl_str
273
        );
274
    }
275

            
276
    #[test]
277
    fn test_redirect() {
278
        let hurl_str = r#"GET http://localhost:8000/redirect-absolute
279
[Options]
280
location: true
281
"#;
282
        assert_eq!(
283
            parse_line(r#"curl -L http://localhost:8000/redirect-absolute"#).unwrap(),
284
            hurl_str
285
        );
286
    }
287

            
288
    #[test]
289
    fn test_insecure() {
290
        let hurl_str = r#"GET https://localhost:8001/hello
291
[Options]
292
insecure: true
293
"#;
294
        assert_eq!(
295
            parse_line(r#"curl -k https://localhost:8001/hello"#).unwrap(),
296
            hurl_str
297
        );
298
    }
299

            
300
    #[test]
301
    fn test_max_redirects() {
302
        let hurl_str = r#"GET https://localhost:8001/hello
303
[Options]
304
max-redirs: 10
305
"#;
306
        assert_eq!(
307
            parse_line(r#"curl https://localhost:8001/hello --max-redirs 10"#).unwrap(),
308
            hurl_str
309
        );
310
    }
311
}