Skip to main content

agent_first_http/
curl_compat.rs

1use crate::cli::{CliRequest, Mode};
2use crate::types::*;
3/// curl compatibility mode: parse a subset of curl command-line flags and
4/// return a `Mode::Cli(...)` equivalent to what afhttp would produce natively.
5///
6/// Supported flags: see docs/cli.md for the generated table.
7use agent_first_data::OutputFormat;
8use base64::Engine;
9use serde_json::Value;
10use std::collections::HashMap;
11
12pub fn parse_curl_args(args: &[String]) -> Mode {
13    let mut method: Option<String> = None;
14    let mut url: Option<String> = None;
15    let mut headers: Vec<(String, String)> = Vec::new();
16    // -d parts (JSON auto-detect)
17    let mut data_parts: Vec<String> = Vec::new();
18    // --data-raw (always text)
19    let mut data_raw_parts: Vec<String> = Vec::new();
20    let mut data_urlencode: Vec<UrlencodedPart> = Vec::new();
21    let mut form_parts: Vec<MultipartPart> = Vec::new();
22    let mut response_save_file: Option<String> = None;
23    let mut response_save_file_is_basename = false;
24    let mut response_save_resume = false;
25    let mut tls_insecure = false;
26    let mut tls_cacert_file: Option<String> = None;
27    let mut tls_cert_file: Option<String> = None;
28    let mut tls_key_file: Option<String> = None;
29    let mut proxy: Option<String> = None;
30    let mut retry: Option<u32> = None;
31    let mut timeout_connect_s: Option<u64> = None;
32    let mut timeout_idle_s: Option<u64> = None;
33    let mut verbose = false;
34    let mut chunked = false;
35    let mut head_mode = false;
36    let mut upload_file: Option<String> = None;
37    let mut response_redirect: Option<u32> = None;
38
39    let mut i = 0;
40    while i < args.len() {
41        let arg = &args[i];
42
43        // Positional argument: first non-flag becomes the URL
44        if !arg.starts_with('-') || arg == "-" {
45            if url.is_none() && arg != "-" {
46                url = Some(arg.clone());
47            }
48            i += 1;
49            continue;
50        }
51
52        // Long flags (--flag or --flag=value)
53        if let Some(rest) = arg.strip_prefix("--") {
54            // Split on '=' for --flag=value syntax
55            let (flag, inline_val) = match rest.find('=') {
56                Some(pos) => (&rest[..pos], Some(&rest[pos + 1..])),
57                None => (rest, None),
58            };
59
60            // Helper: get next arg or use inline value
61            macro_rules! next_val {
62                () => {
63                    if let Some(v) = inline_val {
64                        Some(v.to_string())
65                    } else if i + 1 < args.len() && !args[i + 1].starts_with('-') {
66                        i += 1;
67                        Some(args[i].clone())
68                    } else {
69                        None
70                    }
71                };
72            }
73
74            match flag {
75                "request" => {
76                    if let Some(v) = next_val!() {
77                        method = Some(v.to_uppercase());
78                    }
79                }
80                "header" => {
81                    if let Some(v) = next_val!() {
82                        push_header(&v, &mut headers);
83                    }
84                }
85                "data" | "data-ascii" => {
86                    if let Some(v) = next_val!() {
87                        data_parts.push(v);
88                    }
89                }
90                "data-raw" => {
91                    if let Some(v) = next_val!() {
92                        data_raw_parts.push(v);
93                    }
94                }
95                "data-urlencode" => {
96                    if let Some(v) = next_val!() {
97                        push_urlencode_part(&v, &mut data_urlencode);
98                    }
99                }
100                "form" => {
101                    if let Some(v) = next_val!() {
102                        push_form_part(&v, &mut form_parts);
103                    }
104                }
105                "output" => {
106                    if let Some(v) = next_val!() {
107                        response_save_file = Some(v);
108                    }
109                }
110                "remote-name" => {
111                    response_save_file_is_basename = true;
112                }
113                "location" => {
114                    if response_redirect.is_none() {
115                        response_redirect = Some(10);
116                    }
117                }
118                "max-redirs" => {
119                    if let Some(v) = next_val!() {
120                        response_redirect = v.parse().ok();
121                    }
122                }
123                "head" => {
124                    head_mode = true;
125                }
126                "insecure" => {
127                    tls_insecure = true;
128                }
129                "cacert" => {
130                    if let Some(v) = next_val!() {
131                        tls_cacert_file = Some(v);
132                    }
133                }
134                "cert" => {
135                    if let Some(v) = next_val!() {
136                        tls_cert_file = Some(v);
137                    }
138                }
139                "key" => {
140                    if let Some(v) = next_val!() {
141                        tls_key_file = Some(v);
142                    }
143                }
144                "proxy" => {
145                    if let Some(v) = next_val!() {
146                        proxy = Some(v);
147                    }
148                }
149                "retry" => {
150                    if let Some(v) = next_val!() {
151                        retry = v.parse().ok();
152                    }
153                }
154                "connect-timeout" => {
155                    if let Some(v) = next_val!() {
156                        // curl accepts float; truncate to integer seconds
157                        timeout_connect_s = v.parse::<f64>().ok().map(|f| f as u64);
158                    }
159                }
160                "max-time" => {
161                    if let Some(v) = next_val!() {
162                        timeout_idle_s = v.parse::<f64>().ok().map(|f| f as u64);
163                    }
164                }
165                "user-agent" => {
166                    if let Some(v) = next_val!() {
167                        headers.push(("User-Agent".to_string(), v));
168                    }
169                }
170                "user" => {
171                    if let Some(v) = next_val!() {
172                        push_basic_auth(&v, &mut headers);
173                    }
174                }
175                "cookie" => {
176                    if let Some(v) = next_val!() {
177                        headers.push(("Cookie".to_string(), v));
178                    }
179                }
180                "referer" => {
181                    if let Some(v) = next_val!() {
182                        headers.push(("Referer".to_string(), v));
183                    }
184                }
185                "upload-file" => {
186                    if let Some(v) = next_val!() {
187                        upload_file = Some(v);
188                    }
189                }
190                "no-buffer" => {
191                    chunked = true;
192                }
193                "verbose" => {
194                    verbose = true;
195                }
196                // Intentional no-ops: afhttp already behaves like these flags
197                "silent" | "compressed" | "fail" | "fail-with-body" | "show-error" | "globoff"
198                | "disable-eprt" | "ipv4" | "ipv6" => {}
199                "continue-at" => {
200                    if let Some(v) = inline_val {
201                        if v == "-" {
202                            response_save_resume = true;
203                        }
204                    } else if i + 1 < args.len() {
205                        i += 1;
206                        if args[i] == "-" {
207                            response_save_resume = true;
208                        }
209                    }
210                }
211                _ => {
212                    // Consume a value if the flag looks like it takes one
213                    // (heuristic: skip it so the parser stays in sync)
214                    if inline_val.is_none() {
215                        // Unknown flags: consume next arg only for known value-taking patterns
216                        // We don't consume blindly to avoid eating a URL
217                    }
218                }
219            }
220            i += 1;
221            continue;
222        }
223
224        // Short flags: arg is "-X", "-XMethod", "-vk", etc.
225        let chars = &arg[1..]; // strip leading '-'
226        if chars.is_empty() {
227            i += 1;
228            continue;
229        }
230
231        // Process short flags character by character (bundled flags like -vk)
232        let mut j = 0;
233        let char_bytes: Vec<char> = chars.chars().collect();
234        while j < char_bytes.len() {
235            let c = char_bytes[j];
236            // Check if the rest of the arg after this char provides a value
237            let rest_of_arg: String = char_bytes[j + 1..].iter().collect();
238
239            match c {
240                'X' => {
241                    if !rest_of_arg.is_empty() {
242                        method = Some(rest_of_arg.to_uppercase());
243                        j = char_bytes.len(); // consumed rest of arg
244                    } else {
245                        i += 1;
246                        if i < args.len() {
247                            method = Some(args[i].to_uppercase());
248                        }
249                        j = char_bytes.len();
250                    }
251                }
252                'H' => {
253                    if !rest_of_arg.is_empty() {
254                        push_header(&rest_of_arg, &mut headers);
255                        j = char_bytes.len();
256                    } else {
257                        i += 1;
258                        if i < args.len() {
259                            push_header(&args[i], &mut headers);
260                        }
261                        j = char_bytes.len();
262                    }
263                }
264                'd' => {
265                    if !rest_of_arg.is_empty() {
266                        data_parts.push(rest_of_arg.clone());
267                        j = char_bytes.len();
268                    } else {
269                        i += 1;
270                        if i < args.len() {
271                            data_parts.push(args[i].clone());
272                        }
273                        j = char_bytes.len();
274                    }
275                }
276                'F' => {
277                    if !rest_of_arg.is_empty() {
278                        push_form_part(&rest_of_arg, &mut form_parts);
279                        j = char_bytes.len();
280                    } else {
281                        i += 1;
282                        if i < args.len() {
283                            push_form_part(&args[i], &mut form_parts);
284                        }
285                        j = char_bytes.len();
286                    }
287                }
288                'o' => {
289                    if !rest_of_arg.is_empty() {
290                        response_save_file = Some(rest_of_arg.clone());
291                        j = char_bytes.len();
292                    } else {
293                        i += 1;
294                        if i < args.len() {
295                            response_save_file = Some(args[i].clone());
296                        }
297                        j = char_bytes.len();
298                    }
299                }
300                'O' => {
301                    response_save_file_is_basename = true;
302                    j += 1;
303                }
304                'L' => {
305                    if response_redirect.is_none() {
306                        response_redirect = Some(10);
307                    }
308                    j += 1;
309                }
310                'k' => {
311                    tls_insecure = true;
312                    j += 1;
313                }
314                'x' => {
315                    if !rest_of_arg.is_empty() {
316                        proxy = Some(rest_of_arg.clone());
317                        j = char_bytes.len();
318                    } else {
319                        i += 1;
320                        if i < args.len() {
321                            proxy = Some(args[i].clone());
322                        }
323                        j = char_bytes.len();
324                    }
325                }
326                'A' => {
327                    if !rest_of_arg.is_empty() {
328                        headers.push(("User-Agent".to_string(), rest_of_arg.clone()));
329                        j = char_bytes.len();
330                    } else {
331                        i += 1;
332                        if i < args.len() {
333                            headers.push(("User-Agent".to_string(), args[i].clone()));
334                        }
335                        j = char_bytes.len();
336                    }
337                }
338                'u' => {
339                    if !rest_of_arg.is_empty() {
340                        push_basic_auth(&rest_of_arg, &mut headers);
341                        j = char_bytes.len();
342                    } else {
343                        i += 1;
344                        if i < args.len() {
345                            push_basic_auth(&args[i], &mut headers);
346                        }
347                        j = char_bytes.len();
348                    }
349                }
350                'I' => {
351                    head_mode = true;
352                    j += 1;
353                }
354                'N' => {
355                    chunked = true;
356                    j += 1;
357                }
358                'v' => {
359                    verbose = true;
360                    j += 1;
361                }
362                's' => {
363                    // silent — no-op
364                    j += 1;
365                }
366                'b' => {
367                    if !rest_of_arg.is_empty() {
368                        headers.push(("Cookie".to_string(), rest_of_arg.clone()));
369                        j = char_bytes.len();
370                    } else {
371                        i += 1;
372                        if i < args.len() {
373                            headers.push(("Cookie".to_string(), args[i].clone()));
374                        }
375                        j = char_bytes.len();
376                    }
377                }
378                'e' => {
379                    if !rest_of_arg.is_empty() {
380                        headers.push(("Referer".to_string(), rest_of_arg.clone()));
381                        j = char_bytes.len();
382                    } else {
383                        i += 1;
384                        if i < args.len() {
385                            headers.push(("Referer".to_string(), args[i].clone()));
386                        }
387                        j = char_bytes.len();
388                    }
389                }
390                'T' => {
391                    if !rest_of_arg.is_empty() {
392                        upload_file = Some(rest_of_arg.clone());
393                        j = char_bytes.len();
394                    } else {
395                        i += 1;
396                        if i < args.len() {
397                            upload_file = Some(args[i].clone());
398                        }
399                        j = char_bytes.len();
400                    }
401                }
402                'C' => {
403                    if rest_of_arg == "-" {
404                        response_save_resume = true;
405                        j = char_bytes.len();
406                    } else {
407                        i += 1;
408                        if i < args.len() && args[i] == "-" {
409                            response_save_resume = true;
410                        }
411                        j = char_bytes.len();
412                    }
413                }
414                _ => {
415                    j += 1;
416                }
417            }
418        }
419
420        i += 1;
421    }
422
423    // Resolve -O: save to URL basename
424    if response_save_file_is_basename {
425        let basename = url
426            .as_deref()
427            .and_then(remote_name_from_url)
428            .unwrap_or_else(|| "output".to_string());
429        response_save_file = Some(basename);
430    }
431
432    // Determine method
433    let has_body_data = !data_parts.is_empty()
434        || !data_raw_parts.is_empty()
435        || !data_urlencode.is_empty()
436        || !form_parts.is_empty();
437
438    let method = if head_mode {
439        "HEAD".to_string()
440    } else if let Some(m) = method {
441        m
442    } else if upload_file.is_some() {
443        // -T defaults to PUT
444        "PUT".to_string()
445    } else if has_body_data {
446        "POST".to_string()
447    } else {
448        "GET".to_string()
449    };
450
451    // Build headers map
452    let mut headers_map: HashMap<String, Value> = HashMap::new();
453    for (k, v) in headers {
454        headers_map.insert(k, Value::String(v));
455    }
456
457    // Build body
458    let (body, body_base64, body_file, body_multipart, body_urlencoded) =
459        if let Some(path) = upload_file {
460            (None, None, Some(path), None, None)
461        } else if !form_parts.is_empty() {
462            (None, None, None, Some(form_parts), None)
463        } else if !data_urlencode.is_empty() {
464            (None, None, None, None, Some(data_urlencode))
465        } else if !data_raw_parts.is_empty() {
466            // --data-raw: always text, no JSON detection
467            let combined = data_raw_parts.join("&");
468            (Some(Value::String(combined)), None, None, None, None)
469        } else if !data_parts.is_empty() {
470            // -d: concatenate with &, then JSON auto-detect (object/array only)
471            let combined = data_parts.join("&");
472            let body_val = match serde_json::from_str::<Value>(combined.trim()) {
473                Ok(v) if v.is_object() || v.is_array() => v,
474                _ => Value::String(combined),
475            };
476            (Some(body_val), None, None, None, None)
477        } else {
478            (None, None, None, None, None)
479        };
480
481    // Build log categories from --verbose
482    const ALL_CATEGORIES: &[&str] = &["startup", "request", "progress", "retry", "redirect"];
483    let log_categories: Vec<String> = if verbose {
484        ALL_CATEGORIES.iter().map(|s| s.to_string()).collect()
485    } else {
486        vec![]
487    };
488
489    // TLS goes into per-request options (one-shot mode, like native CLI mode)
490    let tls = if tls_insecure
491        || tls_cacert_file.is_some()
492        || tls_cert_file.is_some()
493        || tls_key_file.is_some()
494    {
495        Some(TlsConfigPartial {
496            insecure: if tls_insecure { Some(true) } else { None },
497            cacert_file: tls_cacert_file,
498            cert_file: tls_cert_file,
499            key_file: tls_key_file,
500            ..TlsConfigPartial::default()
501        })
502    } else {
503        None
504    };
505
506    let config_overrides = ConfigPatch {
507        proxy,
508        timeout_connect_s,
509        ..ConfigPatch::default()
510    };
511
512    let chunked_delimiter = Value::String("\n".to_string());
513
514    let options = RequestOptions {
515        timeout_idle_s,
516        retry,
517        response_redirect,
518        response_save_file,
519        response_save_resume: if response_save_resume {
520            Some(true)
521        } else {
522            None
523        },
524        chunked,
525        chunked_delimiter,
526        tls,
527        ..RequestOptions::default()
528    };
529
530    Mode::Cli(Box::new(CliRequest {
531        method,
532        url: url.unwrap_or_default(),
533        headers: headers_map,
534        body,
535        body_base64,
536        body_file,
537        body_multipart,
538        body_urlencoded,
539        options,
540        config_overrides,
541        log_categories,
542        output_format: OutputFormat::Json,
543        dry_run: false,
544    }))
545}
546
547// ---------------------------------------------------------------------------
548// Parsing helpers
549// ---------------------------------------------------------------------------
550
551fn remote_name_from_url(raw: &str) -> Option<String> {
552    let parsed = reqwest::Url::parse(raw).ok()?;
553    parsed
554        .path_segments()
555        .and_then(|mut segments| segments.next_back())
556        .filter(|s| !s.is_empty())
557        .map(ToString::to_string)
558}
559
560fn push_header(s: &str, headers: &mut Vec<(String, String)>) {
561    match s.find(':') {
562        Some(pos) => {
563            let name = s[..pos].trim().to_string();
564            let value = s[pos + 1..].trim().to_string();
565            headers.push((name, value));
566        }
567        None => {
568            // curl treats "HeaderName;" as a header with empty value (removal signal)
569            // We ignore malformed headers silently
570        }
571    }
572}
573
574fn push_basic_auth(s: &str, headers: &mut Vec<(String, String)>) {
575    let encoded = base64::engine::general_purpose::STANDARD.encode(s.as_bytes());
576    headers.push(("Authorization".to_string(), format!("Basic {encoded}")));
577}
578
579fn push_urlencode_part(s: &str, parts: &mut Vec<UrlencodedPart>) {
580    // Supported formats: name=value, name@file (read from file, ignored here), name
581    match s.find('=') {
582        Some(pos) => parts.push(UrlencodedPart {
583            name: s[..pos].to_string(),
584            value: s[pos + 1..].to_string(),
585        }),
586        None => {
587            // name without value → empty value
588            parts.push(UrlencodedPart {
589                name: s.to_string(),
590                value: String::new(),
591            });
592        }
593    }
594}
595
596fn push_form_part(s: &str, parts: &mut Vec<MultipartPart>) {
597    match s.find('=') {
598        Some(pos) => {
599            let name = s[..pos].to_string();
600            let rest = &s[pos + 1..];
601            if let Some(file_rest) = rest.strip_prefix('@') {
602                // File part: name=@path[;filename=x][;type=mime]
603                let segments: Vec<&str> = file_rest.splitn(2, ';').collect();
604                let file = segments[0].to_string();
605                let mut filename = None;
606                let mut content_type = None;
607                if let Some(meta) = segments.get(1) {
608                    for part in meta.split(';') {
609                        if let Some(f) = part.strip_prefix("filename=") {
610                            filename = Some(f.to_string());
611                        } else if let Some(t) = part.strip_prefix("type=") {
612                            content_type = Some(t.to_string());
613                        }
614                    }
615                }
616                parts.push(MultipartPart {
617                    name,
618                    value: None,
619                    value_base64: None,
620                    file: Some(file),
621                    filename,
622                    content_type,
623                });
624            } else {
625                parts.push(MultipartPart {
626                    name,
627                    value: Some(rest.to_string()),
628                    value_base64: None,
629                    file: None,
630                    filename: None,
631                    content_type: None,
632                });
633            }
634        }
635        None => {
636            // name without = is a text part with empty value
637            parts.push(MultipartPart {
638                name: s.to_string(),
639                value: Some(String::new()),
640                value_base64: None,
641                file: None,
642                filename: None,
643                content_type: None,
644            });
645        }
646    }
647}
648
649#[cfg(test)]
650#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
651mod tests {
652    use super::*;
653
654    fn to_args(items: &[&str]) -> Vec<String> {
655        items.iter().map(|s| (*s).to_string()).collect()
656    }
657
658    fn parse_cli(items: &[&str]) -> CliRequest {
659        match parse_curl_args(&to_args(items)) {
660            Mode::Cli(req) => *req,
661            _ => panic!("expected Mode::Cli"),
662        }
663    }
664
665    #[test]
666    fn parse_curl_defaults_to_get() {
667        let req = parse_cli(&["https://example.com"]);
668        assert_eq!(req.method, "GET");
669        assert_eq!(req.url, "https://example.com");
670        assert!(req.body.is_none());
671    }
672
673    #[test]
674    fn parse_curl_short_flags_with_body_and_headers() {
675        let req = parse_cli(&[
676            "-XPOST",
677            "-H",
678            "X-Test: 1",
679            "-d",
680            "{\"x\":1}",
681            "https://example.com/api",
682        ]);
683        assert_eq!(req.method, "POST");
684        assert_eq!(req.url, "https://example.com/api");
685        assert_eq!(
686            req.headers.get("X-Test"),
687            Some(&Value::String("1".to_string()))
688        );
689        assert_eq!(req.body, Some(serde_json::json!({"x": 1})));
690    }
691
692    #[test]
693    fn parse_curl_data_raw_is_string_and_verbose_sets_logs() {
694        let req = parse_cli(&["--data-raw", "a=1", "-v", "https://example.com"]);
695        assert_eq!(req.method, "POST");
696        assert_eq!(req.body, Some(Value::String("a=1".to_string())));
697        assert!(req.log_categories.iter().any(|c| c == "startup"));
698    }
699
700    #[test]
701    fn parse_curl_upload_file_defaults_to_put() {
702        let req = parse_cli(&["-T", "/tmp/file.bin", "https://example.com/upload"]);
703        assert_eq!(req.method, "PUT");
704        assert_eq!(req.body_file.as_deref(), Some("/tmp/file.bin"));
705    }
706
707    #[test]
708    fn parse_curl_form_and_urlencoded_modes() {
709        let form = parse_cli(&[
710            "-F",
711            "name=roger",
712            "-F",
713            "file=@/tmp/a.txt;filename=x.txt;type=text/plain",
714            "https://example.com",
715        ]);
716        let parts = form.body_multipart.expect("multipart");
717        assert_eq!(parts.len(), 2);
718        assert_eq!(parts[0].name, "name");
719        assert_eq!(parts[0].value.as_deref(), Some("roger"));
720        assert_eq!(parts[1].file.as_deref(), Some("/tmp/a.txt"));
721        assert_eq!(parts[1].filename.as_deref(), Some("x.txt"));
722        assert_eq!(parts[1].content_type.as_deref(), Some("text/plain"));
723
724        let ue = parse_cli(&[
725            "--data-urlencode",
726            "a=b",
727            "--data-urlencode",
728            "empty",
729            "https://example.com",
730        ]);
731        let parts = ue.body_urlencoded.expect("urlencoded");
732        assert_eq!(parts.len(), 2);
733        assert_eq!(parts[0].name, "a");
734        assert_eq!(parts[0].value, "b");
735        assert_eq!(parts[1].name, "empty");
736        assert_eq!(parts[1].value, "");
737    }
738
739    #[test]
740    fn parse_curl_remote_name_and_resume_and_redirects() {
741        let req = parse_cli(&[
742            "-O",
743            "--continue-at",
744            "-",
745            "-L",
746            "--max-redirs",
747            "3",
748            "https://example.com/path/file.txt",
749        ]);
750        assert_eq!(req.options.response_save_file.as_deref(), Some("file.txt"));
751        assert_eq!(req.options.response_save_resume, Some(true));
752        assert_eq!(req.options.response_redirect, Some(3));
753    }
754
755    #[test]
756    fn parse_curl_maps_tls_proxy_and_timeouts() {
757        let req = parse_cli(&[
758            "--insecure",
759            "--cacert",
760            "/tmp/ca.pem",
761            "--cert",
762            "/tmp/cert.pem",
763            "--key",
764            "/tmp/key.pem",
765            "--proxy",
766            "http://127.0.0.1:8080",
767            "--connect-timeout",
768            "2.9",
769            "--max-time",
770            "7.1",
771            "--retry",
772            "5",
773            "https://example.com",
774        ]);
775        assert_eq!(
776            req.config_overrides.proxy.as_deref(),
777            Some("http://127.0.0.1:8080")
778        );
779        assert_eq!(req.config_overrides.timeout_connect_s, Some(2));
780        assert_eq!(req.options.timeout_idle_s, Some(7));
781        assert_eq!(req.options.retry, Some(5));
782        let tls = req.options.tls.expect("tls");
783        assert_eq!(tls.insecure, Some(true));
784        assert_eq!(tls.cacert_file.as_deref(), Some("/tmp/ca.pem"));
785        assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem"));
786        assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem"));
787    }
788
789    #[test]
790    fn parse_helpers_cover_edge_cases() {
791        assert_eq!(
792            remote_name_from_url("https://example.com/a/b.txt").as_deref(),
793            Some("b.txt")
794        );
795        assert!(remote_name_from_url("not-url").is_none());
796
797        let mut headers = Vec::new();
798        push_header("X-A: 1", &mut headers);
799        push_header("MalformedHeader", &mut headers);
800        assert_eq!(headers.len(), 1);
801        assert_eq!(headers[0].0, "X-A");
802        assert_eq!(headers[0].1, "1");
803
804        let mut auth_headers = Vec::new();
805        push_basic_auth("u:p", &mut auth_headers);
806        assert_eq!(auth_headers[0].0, "Authorization");
807        assert!(auth_headers[0].1.starts_with("Basic "));
808
809        let mut ue = Vec::new();
810        push_urlencode_part("a=1", &mut ue);
811        push_urlencode_part("b", &mut ue);
812        assert_eq!(ue[0].name, "a");
813        assert_eq!(ue[0].value, "1");
814        assert_eq!(ue[1].name, "b");
815        assert_eq!(ue[1].value, "");
816
817        let mut form = Vec::new();
818        push_form_part("field=value", &mut form);
819        push_form_part(
820            "up=@/tmp/x.bin;filename=x.bin;type=application/octet-stream",
821            &mut form,
822        );
823        push_form_part("empty", &mut form);
824        assert_eq!(form.len(), 3);
825        assert_eq!(form[0].value.as_deref(), Some("value"));
826        assert_eq!(form[1].file.as_deref(), Some("/tmp/x.bin"));
827        assert_eq!(form[1].filename.as_deref(), Some("x.bin"));
828        assert_eq!(
829            form[1].content_type.as_deref(),
830            Some("application/octet-stream")
831        );
832        assert_eq!(form[2].value.as_deref(), Some(""));
833    }
834
835    #[test]
836    fn parse_curl_long_equals_and_noop_flags() {
837        let req = parse_cli(&[
838            "--request=patch",
839            "--header=X-A: 1",
840            "--data=hello",
841            "--output=out.txt",
842            "--location",
843            "--max-redirs=2",
844            "--head",
845            "--insecure",
846            "--cacert=/tmp/ca.pem",
847            "--cert=/tmp/cert.pem",
848            "--key=/tmp/key.pem",
849            "--proxy=http://127.0.0.1:8080",
850            "--retry=3",
851            "--connect-timeout=2.5",
852            "--max-time=7.8",
853            "--user-agent=ua",
854            "--user=u:p",
855            "--cookie=a=1",
856            "--referer=https://ref",
857            "--upload-file=/tmp/up.bin",
858            "--no-buffer",
859            "--verbose",
860            "--silent",
861            "--compressed",
862            "--fail",
863            "--fail-with-body",
864            "--show-error",
865            "--globoff",
866            "--disable-eprt",
867            "--ipv4",
868            "--ipv6",
869            "--continue-at=-",
870            "https://example.com/p",
871        ]);
872        assert_eq!(req.method, "HEAD");
873        assert_eq!(req.url, "https://example.com/p");
874        assert_eq!(req.options.response_redirect, Some(2));
875        assert_eq!(req.options.response_save_resume, Some(true));
876        assert_eq!(req.options.timeout_idle_s, Some(7));
877        assert_eq!(req.options.retry, Some(3));
878        assert!(req.options.chunked);
879        assert_eq!(
880            req.config_overrides.proxy.as_deref(),
881            Some("http://127.0.0.1:8080")
882        );
883        assert_eq!(req.config_overrides.timeout_connect_s, Some(2));
884        assert_eq!(
885            req.headers.get("User-Agent"),
886            Some(&Value::String("ua".to_string()))
887        );
888        assert_eq!(
889            req.headers.get("Cookie"),
890            Some(&Value::String("a=1".to_string()))
891        );
892        assert_eq!(
893            req.headers.get("Referer"),
894            Some(&Value::String("https://ref".to_string()))
895        );
896        assert!(req.headers.contains_key("Authorization"));
897        assert_eq!(req.body_file.as_deref(), Some("/tmp/up.bin"));
898
899        let head = parse_cli(&["--head", "https://example.com/head"]);
900        assert_eq!(head.method, "HEAD");
901    }
902
903    #[test]
904    fn parse_curl_short_flags_separate_values_and_unknowns() {
905        let req = parse_cli(&[
906            "-vksLN",
907            "-X",
908            "PUT",
909            "-H",
910            "X-B: 2",
911            "-d",
912            "k=v",
913            "-F",
914            "f=@/tmp/a.txt",
915            "-o",
916            "res.bin",
917            "-x",
918            "http://proxy:8080",
919            "-A",
920            "ua2",
921            "-u",
922            "u2:p2",
923            "-b",
924            "c=1",
925            "-e",
926            "https://r",
927            "-T",
928            "/tmp/body.bin",
929            "-C",
930            "-",
931            "-Z", // unknown
932            "-",
933            "https://example.com/file",
934        ]);
935        assert_eq!(req.method, "PUT");
936        assert_eq!(req.options.response_save_file.as_deref(), Some("res.bin"));
937        assert_eq!(req.options.response_save_resume, Some(true));
938        assert!(req.options.chunked);
939        assert_eq!(
940            req.config_overrides.proxy.as_deref(),
941            Some("http://proxy:8080")
942        );
943        assert_eq!(req.body_file.as_deref(), Some("/tmp/body.bin"));
944        assert_eq!(
945            req.headers.get("User-Agent"),
946            Some(&Value::String("ua2".to_string()))
947        );
948        assert_eq!(
949            req.headers.get("Cookie"),
950            Some(&Value::String("c=1".to_string()))
951        );
952    }
953
954    #[test]
955    fn parse_curl_remote_name_fallback_and_missing_values() {
956        let req = parse_cli(&["-O", "https://example.com"]);
957        assert_eq!(req.options.response_save_file.as_deref(), Some("output"));
958
959        let req = parse_cli(&["https://example.com", "--request"]);
960        assert_eq!(req.method, "GET");
961        assert_eq!(req.url, "https://example.com");
962
963        let req = parse_cli(&["--request", "--header", "X-A: 1", "https://example.com"]);
964        assert_eq!(req.method, "GET");
965        assert_eq!(req.url, "https://example.com");
966        assert_eq!(
967            req.headers.get("X-A"),
968            Some(&Value::String("1".to_string()))
969        );
970    }
971}