Skip to main content

agent_first_http/
cli.rs

1use crate::config::VERSION;
2use crate::types::*;
3use agent_first_data::{
4    build_cli_error, cli_output, cli_parse_log_filters, cli_parse_output, OutputFormat,
5    RedactionPolicy,
6};
7use clap::{error::ErrorKind, CommandFactory, Parser, ValueEnum};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::io::Write;
11
12// ---------------------------------------------------------------------------
13// Clap argument definition
14// ---------------------------------------------------------------------------
15
16#[doc = r#"Agent-First HTTP — persistent HTTP client for AI agents.
17
18### Modes
19
20- `--mode cli` (default): one request, one structured response, then exit
21- `--mode pipe`: long-lived JSONL stdin/stdout session for agents
22- `--mode curl`: parse a focused subset of curl flags, then execute through the same runtime
23
24### Output and Exit Codes
25
26- default output is one JSON object on stdout
27- `--output yaml` and `--output plain` only reformat the envelope; server response bodies are not rewritten
28- exit code `0`: HTTP response received
29- exit code `1`: transport/runtime error
30- exit code `2`: invalid arguments
31
32### Request Body Rules
33
34- `--body` with a JSON object or array auto-sets `Content-Type: application/json`
35- string bodies are sent as raw bytes; set `--header "Content-Type: ..."` yourself when needed
36- `--body`, `--body-base64`, `--body-file`, `--body-multipart`, and `--body-urlencoded` are mutually exclusive
37
38### Streaming and Files
39
40- `--chunked` emits `chunk_start`, repeated `chunk_data`, then `chunk_end`
41- use `--chunked-delimiter '\n\n'` for SSE and `--chunked-delimiter-raw` for binary frames
42- `--response-save-file` writes the body to disk; `--response-save-resume` resumes partial downloads
43- progress logs are opt-in via `--log progress`
44
45### Examples
46
47```text
48afhttp GET https://api.example.com/users
49afhttp POST https://api.example.com/users --body '{"name":"Alice"}'
50afhttp POST https://api.openai.com/v1/files \
51  --header "Authorization: Bearer sk-xxx" \
52  --body-multipart purpose=assistants \
53  --body-multipart file=@/tmp/data.jsonl;filename=data.jsonl;type=application/jsonl
54afhttp GET https://api.example.com/stream --chunked-delimiter '\n\n'
55afhttp GET https://example.com/large.tar.gz \
56  --response-save-file /tmp/large.tar.gz \
57  --log progress
58afhttp --mode pipe
59```
60"#]
61#[derive(Parser)]
62#[command(name = "afhttp", version = VERSION, verbatim_doc_comment)]
63pub struct Cli {
64    /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
65    pub method: Option<String>,
66
67    /// URL to request
68    pub url: Option<String>,
69
70    // -- Request flags --
71    /// Request header (repeatable). Format: "Name: Value". Empty value removes default.
72    #[arg(long = "header", help_heading = "Request")]
73    pub header: Vec<String>,
74
75    /// Request body. Valid JSON object/array auto-detected and sets Content-Type: application/json. @path reads from file.
76    #[arg(long = "body", help_heading = "Request")]
77    pub body: Option<String>,
78
79    /// Base64-encoded binary request body
80    #[arg(long = "body-base64", help_heading = "Request")]
81    pub body_base64: Option<String>,
82
83    /// Read request body from file
84    #[arg(long = "body-file", help_heading = "Request")]
85    pub body_file: Option<String>,
86
87    /// Multipart form part (repeatable). Format: name=value or name=@path[;filename=x][;type=mime]
88    #[arg(long = "body-multipart", help_heading = "Request")]
89    pub body_multipart: Vec<String>,
90
91    /// URL-encoded form field (repeatable). Format: name=value. Sets Content-Type: application/x-www-form-urlencoded.
92    #[arg(long = "body-urlencoded", help_heading = "Request")]
93    pub body_urlencoded: Vec<String>,
94
95    // -- Config flags --
96    /// Directory for auto-saved large response bodies
97    #[arg(long = "response-save-dir", help_heading = "Config")]
98    pub response_save_dir: Option<String>,
99
100    /// Auto-save response body to response-save-dir when larger than this (default: 10485760)
101    #[arg(long = "response-save-above-bytes", help_heading = "Config")]
102    pub response_save_above_bytes: Option<u64>,
103
104    /// Max concurrent in-flight requests (0 = unlimited)
105    #[arg(long = "request-concurrency-limit", help_heading = "Config")]
106    pub request_concurrency_limit: Option<u64>,
107
108    /// TCP+TLS handshake timeout in seconds (default: 10)
109    #[arg(long = "timeout-connect-s", help_heading = "Config")]
110    pub timeout_connect_s: Option<u64>,
111
112    /// No-data timeout in seconds (default: 30)
113    #[arg(long = "timeout-idle-s", help_heading = "Config")]
114    pub timeout_idle_s: Option<u64>,
115
116    /// Retry count (default: 0, no retry)
117    #[arg(long, help_heading = "Config")]
118    pub retry: Option<u32>,
119
120    /// Base delay for first retry in ms (default: 100). Subsequent: base * 2^(attempt-1)
121    #[arg(long = "retry-base-delay-ms", help_heading = "Config")]
122    pub retry_base_delay_ms: Option<u64>,
123
124    /// Comma-separated status codes to retry (e.g. 429,503)
125    #[arg(long = "retry-on-status", help_heading = "Config")]
126    pub retry_on_status: Option<String>,
127
128    /// Redirect limit (default: 10, 0=disable)
129    #[arg(long = "response-redirect", help_heading = "Config")]
130    pub response_redirect: Option<u32>,
131
132    /// Parse JSON response body (default: true)
133    #[arg(long = "response-parse-json", help_heading = "Config")]
134    pub response_parse_json: Option<bool>,
135
136    /// Auto-decompress response (default: true)
137    #[arg(long = "response-decompress", help_heading = "Config")]
138    pub response_decompress: Option<bool>,
139
140    /// Save response body to file
141    #[arg(long = "response-save-file", help_heading = "Config")]
142    pub response_save_file: Option<String>,
143
144    /// Resume download if response-save-file exists
145    #[arg(long = "response-save-resume", help_heading = "Config")]
146    pub response_save_resume: bool,
147
148    /// Hard limit on response body size in bytes
149    #[arg(long = "response-max-bytes", help_heading = "Config")]
150    pub response_max_bytes: Option<u64>,
151
152    /// Stream response in chunks
153    #[arg(long, help_heading = "Config")]
154    pub chunked: bool,
155
156    /// Chunk delimiter (default: \n). Use \n\n for SSE. Implies --chunked
157    #[arg(long = "chunked-delimiter", help_heading = "Config")]
158    pub chunked_delimiter: Option<String>,
159
160    /// Raw binary chunks (null delimiter). Implies --chunked
161    #[arg(long = "chunked-delimiter-raw", help_heading = "Config")]
162    pub chunked_delimiter_raw: bool,
163
164    /// Time-based progress interval in ms (default: 10000, 0=disable). Works with --progress-bytes
165    #[arg(long = "progress-ms", help_heading = "Config")]
166    pub progress_ms: Option<u64>,
167
168    /// Byte-based progress interval (default: 0=disable). Works with --progress-ms
169    #[arg(long = "progress-bytes", help_heading = "Config")]
170    pub progress_bytes: Option<u64>,
171
172    // -- TLS flags --
173    /// Skip certificate verification
174    #[arg(long = "tls-insecure", help_heading = "TLS")]
175    pub tls_insecure: bool,
176
177    /// CA certificate file path
178    #[arg(long = "tls-cacert-file", help_heading = "TLS")]
179    pub tls_cacert_file: Option<String>,
180
181    /// Client certificate file path
182    #[arg(long = "tls-cert-file", help_heading = "TLS")]
183    pub tls_cert_file: Option<String>,
184
185    /// Client private key file path
186    #[arg(long = "tls-key-file", help_heading = "TLS")]
187    pub tls_key_file: Option<String>,
188
189    // -- Other --
190    /// Proxy URL
191    #[arg(long, help_heading = "Other")]
192    pub proxy: Option<String>,
193
194    /// Protocol upgrade (e.g. "websocket")
195    #[arg(long, help_heading = "Other")]
196    pub upgrade: Option<String>,
197
198    // -- Output flags --
199    /// Output format: json (default), yaml (human-readable), plain (logfmt)
200    #[arg(long, default_value = "json", help_heading = "Output")]
201    pub output: String,
202
203    /// Log categories (comma-separated). Categories: startup, request, progress, retry, redirect
204    #[arg(long, help_heading = "Output")]
205    pub log: Option<String>,
206
207    /// Enable all log categories (equivalent to --log startup,request,progress,retry,redirect)
208    #[arg(long, help_heading = "Output")]
209    pub verbose: bool,
210
211    /// Preview the request without executing it
212    #[arg(long, help_heading = "Output")]
213    pub dry_run: bool,
214
215    // -- Mode --
216    /// Runtime mode: cli (default), pipe, or curl
217    #[arg(long, value_enum, default_value = "cli", help_heading = "Mode")]
218    pub mode: CliMode,
219}
220
221#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
222pub enum CliMode {
223    Cli,
224    Pipe,
225    Curl,
226}
227
228// ---------------------------------------------------------------------------
229// Parsed CLI request
230// ---------------------------------------------------------------------------
231
232pub struct CliRequest {
233    pub method: String,
234    pub url: String,
235    pub headers: HashMap<String, Value>,
236    pub body: Option<Value>,
237    pub body_base64: Option<String>,
238    pub body_file: Option<String>,
239    pub body_multipart: Option<Vec<MultipartPart>>,
240    pub body_urlencoded: Option<Vec<UrlencodedPart>>,
241    pub options: RequestOptions,
242    pub config_overrides: ConfigPatch,
243    /// Log categories enabled via --log or --verbose. Includes "startup" if requested.
244    pub log_categories: Vec<String>,
245    /// Output format for CLI output
246    pub output_format: OutputFormat,
247    /// Preview the request without executing it
248    pub dry_run: bool,
249}
250
251// ---------------------------------------------------------------------------
252// Mode enum
253// ---------------------------------------------------------------------------
254
255pub enum Mode {
256    Cli(Box<CliRequest>),
257    Pipe(Box<PipeInit>),
258}
259
260pub struct PipeInit {
261    pub config: ConfigPatch,
262    pub output_format: OutputFormat,
263}
264
265fn emit_cli_usage_error_and_exit(message: impl AsRef<str>, hint: Option<&str>) -> ! {
266    let json = cli_output(&build_cli_error(message.as_ref(), hint), OutputFormat::Json);
267    let _ = writeln!(std::io::stdout(), "{json}");
268    std::process::exit(2);
269}
270
271fn raw_mode_is_curl(raw: &[String]) -> bool {
272    let mut i = 1;
273    while i < raw.len() {
274        if raw[i] == "--mode" {
275            return raw.get(i + 1).map(String::as_str) == Some("curl");
276        }
277        if let Some(v) = raw[i].strip_prefix("--mode=") {
278            return v == "curl";
279        }
280        i += 1;
281    }
282    false
283}
284
285fn strip_mode_flag(args: &[String]) -> Vec<String> {
286    let mut out = Vec::with_capacity(args.len());
287    let mut i = 0;
288    while i < args.len() {
289        if args[i] == "--mode" {
290            i += 1;
291            if i < args.len() {
292                i += 1;
293            }
294            continue;
295        }
296        if args[i].starts_with("--mode=") {
297            i += 1;
298            continue;
299        }
300        out.push(args[i].clone());
301        i += 1;
302    }
303    out
304}
305
306// ---------------------------------------------------------------------------
307// Parse args into Mode
308// ---------------------------------------------------------------------------
309
310pub fn parse_args() -> Mode {
311    // Curl mode must be handled before Clap parsing so curl-style flags
312    // (e.g. -k, -I, --insecure) do not fail clap validation.
313    let raw: Vec<String> = std::env::args().collect();
314    if raw_mode_is_curl(&raw) {
315        let curl_args = strip_mode_flag(&raw[1..]);
316        return crate::curl_compat::parse_curl_args(&curl_args);
317    }
318
319    // --help: recursive plain-text help (all subcommands expanded)
320    if raw.iter().any(|a| a == "--help" || a == "-h") {
321        let _ = Write::write_all(
322            &mut std::io::stdout(),
323            agent_first_data::cli_render_help(&Cli::command(), &[]).as_bytes(),
324        );
325        std::process::exit(0);
326    }
327    // --help-markdown: Markdown for doc generation
328    if raw.iter().any(|a| a == "--help-markdown") {
329        let _ = Write::write_all(
330            &mut std::io::stdout(),
331            agent_first_data::cli_render_help_markdown(&Cli::command(), &[]).as_bytes(),
332        );
333        std::process::exit(0);
334    }
335
336    let cli = Cli::try_parse().unwrap_or_else(|e| {
337        if matches!(e.kind(), ErrorKind::DisplayVersion) {
338            e.exit();
339        }
340        emit_cli_usage_error_and_exit(e.to_string(), None);
341    });
342    let output_format = match cli_parse_output(&cli.output) {
343        Ok(f) => f,
344        Err(e) => emit_cli_usage_error_and_exit(e, None),
345    };
346
347    match cli.mode {
348        CliMode::Pipe => {
349            // Build config overrides from CLI flags so --mode pipe --log startup,retry --proxy ...
350            // all take effect at launch time.
351            const ALL_CATEGORIES: &[&str] =
352                &["startup", "request", "progress", "retry", "redirect"];
353            let log_categories: Vec<String> = if cli.verbose {
354                cli_parse_log_filters(ALL_CATEGORIES)
355            } else if let Some(ref log_str) = cli.log {
356                let entries: Vec<&str> = log_str.split(',').collect();
357                cli_parse_log_filters(&entries)
358            } else {
359                vec![]
360            };
361            let has_log_flag = cli.verbose || cli.log.is_some();
362            let tls = build_tls_partial(&cli);
363            let pipe_config = ConfigPatch {
364                response_save_dir: cli.response_save_dir.clone(),
365                response_save_above_bytes: cli.response_save_above_bytes,
366                request_concurrency_limit: cli.request_concurrency_limit,
367                timeout_connect_s: cli.timeout_connect_s,
368                retry_base_delay_ms: cli.retry_base_delay_ms,
369                proxy: cli.proxy.clone(),
370                tls,
371                log: if has_log_flag {
372                    Some(log_categories)
373                } else {
374                    None
375                },
376                ..ConfigPatch::default()
377            };
378            return Mode::Pipe(Box::new(PipeInit {
379                config: pipe_config,
380                output_format,
381            }));
382        }
383        CliMode::Curl => {
384            let curl_args = strip_mode_flag(&raw[1..]);
385            return crate::curl_compat::parse_curl_args(&curl_args);
386        }
387        CliMode::Cli => {}
388    }
389
390    let method = match cli.method {
391        Some(ref m) => m.to_uppercase(),
392        None => {
393            // No method in cli mode: show help and exit 2
394            let _ = Write::write_all(
395                &mut std::io::stdout(),
396                agent_first_data::cli_render_help(&Cli::command(), &[]).as_bytes(),
397            );
398            std::process::exit(2);
399        }
400    };
401
402    let url = match cli.url {
403        Some(ref u) => u.clone(),
404        None => {
405            emit_cli_usage_error_and_exit(
406                "URL is required after method",
407                Some("usage: afhttp METHOD URL [flags]"),
408            );
409        }
410    };
411
412    // Parse headers
413    let mut headers = HashMap::new();
414    for h in &cli.header {
415        let (name, value) = parse_header_flag(h);
416        headers.insert(name, value);
417    }
418
419    // Parse body
420    let (body, body_base64, body_file, body_multipart, body_urlencoded) = parse_body_flags(&cli);
421
422    // Build chunked options
423    let mut chunked = cli.chunked;
424    let chunked_delimiter = if cli.chunked_delimiter_raw {
425        chunked = true;
426        Value::Null
427    } else if let Some(ref d) = cli.chunked_delimiter {
428        chunked = true;
429        Value::String(unescape_delimiter(d))
430    } else {
431        Value::String("\n".to_string())
432    };
433
434    // Build TLS partial (borrows cli)
435    let tls = build_tls_partial(&cli);
436
437    // Parse log categories from --verbose or --log
438    const ALL_CATEGORIES: &[&str] = &["startup", "request", "progress", "retry", "redirect"];
439    let log_categories: Vec<String> = if cli.verbose {
440        cli_parse_log_filters(ALL_CATEGORIES)
441    } else if let Some(ref log_str) = cli.log {
442        let entries: Vec<&str> = log_str.split(',').collect();
443        cli_parse_log_filters(&entries)
444    } else {
445        vec![]
446    };
447
448    // Build config overrides — non-startup log categories flow through here
449    let has_log_flag = cli.verbose || cli.log.is_some();
450    let config_overrides = ConfigPatch {
451        response_save_dir: cli.response_save_dir.clone(),
452        response_save_above_bytes: cli.response_save_above_bytes,
453        request_concurrency_limit: cli.request_concurrency_limit,
454        timeout_connect_s: cli.timeout_connect_s,
455        retry_base_delay_ms: cli.retry_base_delay_ms,
456        proxy: cli.proxy.clone(),
457        log: if has_log_flag {
458            Some(
459                log_categories
460                    .iter()
461                    .filter(|c| *c != "startup")
462                    .cloned()
463                    .collect(),
464            )
465        } else {
466            None
467        },
468        ..ConfigPatch::default()
469    };
470
471    // Parse retry_on_status from comma-separated string
472    let retry_on_status = cli.retry_on_status.as_deref().map(|s| {
473        s.split(',')
474            .filter_map(|c| c.trim().parse::<u16>().ok())
475            .collect()
476    });
477
478    // Build request options (consumes remaining cli fields)
479    let options = RequestOptions {
480        timeout_idle_s: cli.timeout_idle_s,
481        retry: cli.retry,
482        response_redirect: cli.response_redirect,
483        response_parse_json: cli.response_parse_json,
484        response_decompress: cli.response_decompress,
485        response_save_resume: if cli.response_save_resume {
486            Some(true)
487        } else {
488            None
489        },
490        chunked,
491        chunked_delimiter,
492        response_save_file: cli.response_save_file,
493        progress_bytes: cli.progress_bytes,
494        progress_ms: cli.progress_ms,
495        retry_on_status,
496        response_max_bytes: cli.response_max_bytes,
497        upgrade: cli.upgrade,
498        tls,
499    };
500
501    Mode::Cli(Box::new(CliRequest {
502        method,
503        url,
504        headers,
505        body,
506        body_base64,
507        body_file,
508        body_multipart,
509        body_urlencoded,
510        options,
511        config_overrides,
512        log_categories,
513        output_format,
514        dry_run: cli.dry_run,
515    }))
516}
517
518// ---------------------------------------------------------------------------
519// CLI output writer (strips id/tag from Output)
520// ---------------------------------------------------------------------------
521
522pub fn write_cli_output(output: &Output, format: OutputFormat) {
523    let mut value = match serde_json::to_value(output) {
524        Ok(v) => v,
525        Err(_) => {
526            let fallback = r#"{"code":"error","error_code":"internal_error","error":"output serialization failed","retryable":false,"trace":{"duration_ms":0}}"#;
527            let stdout = std::io::stdout();
528            let mut out = stdout.lock();
529            let _ = out.write_all(fallback.as_bytes());
530            let _ = out.write_all(b"\n");
531            let _ = out.flush();
532            return;
533        }
534    };
535
536    // Strip id and tag fields for CLI output
537    if let Some(obj) = value.as_object_mut() {
538        obj.remove("id");
539        obj.remove("tag");
540    }
541
542    let formatted = if matches!(format, OutputFormat::Json) {
543        match json_redaction_policy_for_output(output) {
544            Some(policy) => agent_first_data::output_json_with(&value, policy),
545            None => agent_first_data::output_json(&value),
546        }
547    } else {
548        // Protect server body fields from Agent-First Data suffix processing.
549        // Non-string body (parsed JSON objects) is converted to a JSON string
550        // so formatters treat them as opaque data.
551        protect_server_body(&mut value);
552        cli_output(&value, format)
553    };
554
555    let stdout = std::io::stdout();
556    let mut out = stdout.lock();
557    let _ = out.write_all(formatted.as_bytes());
558    if !formatted.ends_with('\n') {
559        let _ = out.write_all(b"\n");
560    }
561    let _ = out.flush();
562}
563
564fn json_redaction_policy_for_output(output: &Output) -> Option<RedactionPolicy> {
565    match output {
566        // Keep server payload raw in response body; only trace metadata is redacted.
567        Output::Response { .. } => Some(RedactionPolicy::RedactionTraceOnly),
568        // Stream chunks are opaque server data.
569        Output::ChunkData { .. } => Some(RedactionPolicy::RedactionNone),
570        // Other events keep existing safe default.
571        _ => None,
572    }
573}
574
575/// Protect server-originated body fields from Agent-First Data suffix processing.
576/// Converts non-string body/data values to JSON string representation
577/// so yaml/plain formatters treat them as opaque strings.
578fn protect_server_body(value: &mut Value) {
579    if let Some(obj) = value.as_object_mut() {
580        for key in &["body", "data"] {
581            if let Some(v) = obj.get(*key).cloned() {
582                if !v.is_null() && !v.is_string() {
583                    if let Ok(json_str) = serde_json::to_string(&v) {
584                        obj.insert((*key).to_string(), Value::String(json_str));
585                    }
586                }
587            }
588        }
589    }
590}
591
592// ---------------------------------------------------------------------------
593// Parsing helpers
594// ---------------------------------------------------------------------------
595
596fn parse_header_flag(s: &str) -> (String, Value) {
597    let colon_pos = match s.find(':') {
598        Some(p) => p,
599        None => {
600            emit_cli_usage_error_and_exit(
601                format!("invalid header '{s}'"),
602                Some("expected format: Name: Value"),
603            );
604        }
605    };
606    let name = s[..colon_pos].trim().to_string();
607    let value = s[colon_pos + 1..].trim();
608    if value.is_empty() {
609        (name, Value::Null) // null removes default
610    } else {
611        (name, Value::String(value.to_string()))
612    }
613}
614
615#[allow(clippy::type_complexity)]
616fn parse_body_flags(
617    cli: &Cli,
618) -> (
619    Option<Value>,
620    Option<String>,
621    Option<String>,
622    Option<Vec<MultipartPart>>,
623    Option<Vec<UrlencodedPart>>,
624) {
625    let has_body = cli.body.is_some();
626    let has_base64 = cli.body_base64.is_some();
627    let has_file = cli.body_file.is_some();
628    let has_multipart = !cli.body_multipart.is_empty();
629    let has_urlencoded = !cli.body_urlencoded.is_empty();
630
631    let count = [
632        has_body,
633        has_base64,
634        has_file,
635        has_multipart,
636        has_urlencoded,
637    ]
638    .iter()
639    .filter(|&&b| b)
640    .count();
641    if count > 1 {
642        emit_cli_usage_error_and_exit(
643            "--body, --body-base64, --body-file, --body-multipart, and --body-urlencoded are mutually exclusive",
644            Some("use only one body flag per request"),
645        );
646    }
647
648    if let Some(ref b) = cli.body {
649        // @path -> body_file
650        if let Some(path) = b.strip_prefix('@') {
651            return (None, None, Some(path.to_string()), None, None);
652        }
653        // JSON auto-detect: full parse, object or array only — numbers/booleans/null are ambiguous
654        let trimmed = b.trim();
655        if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
656            if v.is_object() || v.is_array() {
657                return (Some(v), None, None, None, None);
658            }
659        }
660        // Plain text
661        return (Some(Value::String(b.clone())), None, None, None, None);
662    }
663
664    if let Some(ref b64) = cli.body_base64 {
665        return (None, Some(b64.clone()), None, None, None);
666    }
667
668    if let Some(ref path) = cli.body_file {
669        return (None, None, Some(path.clone()), None, None);
670    }
671
672    if !cli.body_multipart.is_empty() {
673        let parts: Vec<MultipartPart> = cli
674            .body_multipart
675            .iter()
676            .map(|s| parse_form_flag(s))
677            .collect();
678        return (None, None, None, Some(parts), None);
679    }
680
681    if !cli.body_urlencoded.is_empty() {
682        let parts: Vec<UrlencodedPart> = cli
683            .body_urlencoded
684            .iter()
685            .map(|s| parse_urlencoded_flag(s))
686            .collect();
687        return (None, None, None, None, Some(parts));
688    }
689
690    (None, None, None, None, None)
691}
692
693fn parse_form_flag(s: &str) -> MultipartPart {
694    let eq_pos = match s.find('=') {
695        Some(p) => p,
696        None => {
697            emit_cli_usage_error_and_exit(
698                format!("invalid --body-multipart '{s}'"),
699                Some("expected format: name=value or name=@filepath"),
700            );
701        }
702    };
703    let name = s[..eq_pos].to_string();
704    let rest = &s[eq_pos + 1..];
705
706    if let Some(file_rest) = rest.strip_prefix('@') {
707        // File part: name=@path[;filename=x][;type=mime]
708        let parts: Vec<&str> = file_rest.split(';').collect();
709        let file = parts[0].to_string();
710        let mut filename = None;
711        let mut content_type = None;
712        for p in &parts[1..] {
713            if let Some(f) = p.strip_prefix("filename=") {
714                filename = Some(f.to_string());
715            } else if let Some(t) = p.strip_prefix("type=") {
716                content_type = Some(t.to_string());
717            }
718        }
719        MultipartPart {
720            name,
721            value: None,
722            value_base64: None,
723            file: Some(file),
724            filename,
725            content_type,
726        }
727    } else {
728        // Text part
729        MultipartPart {
730            name,
731            value: Some(rest.to_string()),
732            value_base64: None,
733            file: None,
734            filename: None,
735            content_type: None,
736        }
737    }
738}
739
740fn parse_urlencoded_flag(s: &str) -> UrlencodedPart {
741    match s.find('=') {
742        Some(pos) => UrlencodedPart {
743            name: s[..pos].to_string(),
744            value: s[pos + 1..].to_string(),
745        },
746        None => {
747            emit_cli_usage_error_and_exit(
748                format!("invalid --body-urlencoded '{s}'"),
749                Some("expected format: name=value"),
750            );
751        }
752    }
753}
754
755fn build_tls_partial(cli: &Cli) -> Option<TlsConfigPartial> {
756    if cli.tls_insecure
757        || cli.tls_cacert_file.is_some()
758        || cli.tls_cert_file.is_some()
759        || cli.tls_key_file.is_some()
760    {
761        Some(TlsConfigPartial {
762            insecure: if cli.tls_insecure { Some(true) } else { None },
763            cacert_pem: None,
764            cacert_file: cli.tls_cacert_file.clone(),
765            cert_pem: None,
766            cert_file: cli.tls_cert_file.clone(),
767            key_pem_secret: None,
768            key_file: cli.tls_key_file.clone(),
769        })
770    } else {
771        None
772    }
773}
774
775/// Unescape common delimiter literals: \\n -> \n
776fn unescape_delimiter(s: &str) -> String {
777    s.replace("\\n", "\n")
778}
779
780#[cfg(test)]
781#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
782mod tests {
783    use super::*;
784
785    fn empty_cli() -> Cli {
786        Cli {
787            method: None,
788            url: None,
789            header: vec![],
790            body: None,
791            body_base64: None,
792            body_file: None,
793            body_multipart: vec![],
794            body_urlencoded: vec![],
795            response_save_dir: None,
796            response_save_above_bytes: None,
797            request_concurrency_limit: None,
798            timeout_connect_s: None,
799            timeout_idle_s: None,
800            retry: None,
801            retry_base_delay_ms: None,
802            retry_on_status: None,
803            response_redirect: None,
804            response_parse_json: None,
805            response_decompress: None,
806            response_save_file: None,
807            response_save_resume: false,
808            response_max_bytes: None,
809            chunked: false,
810            chunked_delimiter: None,
811            chunked_delimiter_raw: false,
812            progress_ms: None,
813            progress_bytes: None,
814            tls_insecure: false,
815            tls_cacert_file: None,
816            tls_cert_file: None,
817            tls_key_file: None,
818            proxy: None,
819            upgrade: None,
820            output: "json".to_string(),
821            log: None,
822            verbose: false,
823            dry_run: false,
824            mode: CliMode::Cli,
825        }
826    }
827
828    #[test]
829    fn parse_header_flag_normal_and_remove_default() {
830        let (name, value) = parse_header_flag("X-Test: abc");
831        assert_eq!(name, "X-Test");
832        assert_eq!(value, Value::String("abc".to_string()));
833
834        let (name, value) = parse_header_flag("X-Remove:   ");
835        assert_eq!(name, "X-Remove");
836        assert_eq!(value, Value::Null);
837    }
838
839    #[test]
840    fn parse_body_flags_object_array_string_and_files() {
841        let mut cli = empty_cli();
842        cli.body = Some("{\"a\":1}".to_string());
843        let (body, b64, file, mp, ue) = parse_body_flags(&cli);
844        assert_eq!(body, Some(serde_json::json!({"a":1})));
845        assert!(b64.is_none() && file.is_none() && mp.is_none() && ue.is_none());
846
847        let mut cli = empty_cli();
848        cli.body = Some("[1,2]".to_string());
849        let (body, _, _, _, _) = parse_body_flags(&cli);
850        assert_eq!(body, Some(serde_json::json!([1, 2])));
851
852        let mut cli = empty_cli();
853        cli.body = Some("hello".to_string());
854        let (body, _, _, _, _) = parse_body_flags(&cli);
855        assert_eq!(body, Some(Value::String("hello".to_string())));
856
857        let mut cli = empty_cli();
858        cli.body = Some("@/tmp/body.txt".to_string());
859        let (_, _, file, _, _) = parse_body_flags(&cli);
860        assert_eq!(file.as_deref(), Some("/tmp/body.txt"));
861
862        let mut cli = empty_cli();
863        cli.body_base64 = Some("aGVsbG8=".to_string());
864        let (_, b64, _, _, _) = parse_body_flags(&cli);
865        assert_eq!(b64.as_deref(), Some("aGVsbG8="));
866
867        let mut cli = empty_cli();
868        cli.body_file = Some("/tmp/f.bin".to_string());
869        let (_, _, file, _, _) = parse_body_flags(&cli);
870        assert_eq!(file.as_deref(), Some("/tmp/f.bin"));
871    }
872
873    #[test]
874    fn parse_body_flags_multipart_and_urlencoded() {
875        let mut cli = empty_cli();
876        cli.body_multipart = vec![
877            "name=roger".to_string(),
878            "upload=@/tmp/a.txt;filename=x.txt;type=text/plain".to_string(),
879        ];
880        let (_, _, _, mp, _) = parse_body_flags(&cli);
881        let parts = mp.expect("multipart");
882        assert_eq!(parts.len(), 2);
883        assert_eq!(parts[0].name, "name");
884        assert_eq!(parts[0].value.as_deref(), Some("roger"));
885        assert_eq!(parts[1].file.as_deref(), Some("/tmp/a.txt"));
886        assert_eq!(parts[1].filename.as_deref(), Some("x.txt"));
887        assert_eq!(parts[1].content_type.as_deref(), Some("text/plain"));
888
889        let mut cli = empty_cli();
890        cli.body_urlencoded = vec!["a=1".to_string(), "b=".to_string()];
891        let (_, _, _, _, ue) = parse_body_flags(&cli);
892        let parts = ue.expect("urlencoded");
893        assert_eq!(parts.len(), 2);
894        assert_eq!(parts[0].name, "a");
895        assert_eq!(parts[0].value, "1");
896        assert_eq!(parts[1].name, "b");
897        assert_eq!(parts[1].value, "");
898    }
899
900    #[test]
901    fn parse_form_and_urlencoded_flags() {
902        let p = parse_form_flag("n=v");
903        assert_eq!(p.name, "n");
904        assert_eq!(p.value.as_deref(), Some("v"));
905        assert!(p.file.is_none());
906
907        let p = parse_form_flag("f=@/tmp/a.bin;filename=b.bin;type=application/octet-stream");
908        assert_eq!(p.file.as_deref(), Some("/tmp/a.bin"));
909        assert_eq!(p.filename.as_deref(), Some("b.bin"));
910        assert_eq!(p.content_type.as_deref(), Some("application/octet-stream"));
911
912        let p = parse_urlencoded_flag("x=1");
913        assert_eq!(p.name, "x");
914        assert_eq!(p.value, "1");
915    }
916
917    #[test]
918    fn build_tls_partial_and_unescape_delimiter() {
919        let mut cli = empty_cli();
920        assert!(build_tls_partial(&cli).is_none());
921
922        cli.tls_insecure = true;
923        cli.tls_cacert_file = Some("/tmp/ca.pem".to_string());
924        cli.tls_cert_file = Some("/tmp/cert.pem".to_string());
925        cli.tls_key_file = Some("/tmp/key.pem".to_string());
926        let tls = build_tls_partial(&cli).expect("tls");
927        assert_eq!(tls.insecure, Some(true));
928        assert_eq!(tls.cacert_file.as_deref(), Some("/tmp/ca.pem"));
929        assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem"));
930        assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem"));
931
932        assert_eq!(unescape_delimiter("\\n\\n"), "\n\n");
933    }
934
935    #[test]
936    fn protect_server_body_stringifies_non_string() {
937        let mut value = serde_json::json!({
938            "body": {"a": 1},
939            "data": [1,2],
940            "other": true
941        });
942        protect_server_body(&mut value);
943        assert_eq!(
944            value.get("body"),
945            Some(&Value::String("{\"a\":1}".to_string()))
946        );
947        assert_eq!(value.get("data"), Some(&Value::String("[1,2]".to_string())));
948        assert_eq!(value.get("other"), Some(&Value::Bool(true)));
949    }
950
951    #[test]
952    fn json_redaction_policy_for_response_and_log() {
953        let resp = Output::Response {
954            id: "1".to_string(),
955            tag: None,
956            status: 200,
957            headers: HashMap::new(),
958            body: Some(serde_json::json!({"api_key_secret":"sk-live-123"})),
959            body_base64: None,
960            body_file: None,
961            body_parse_failed: false,
962            trace: Trace::error_only(1),
963        };
964        assert_eq!(
965            json_redaction_policy_for_output(&resp),
966            Some(RedactionPolicy::RedactionTraceOnly)
967        );
968
969        let log = Output::Log {
970            event: "startup".to_string(),
971            fields: HashMap::from([(
972                "api_key_secret".to_string(),
973                Value::String("sk-live-123".to_string()),
974            )]),
975        };
976        assert_eq!(json_redaction_policy_for_output(&log), None);
977    }
978
979    #[test]
980    fn curl_mode_helpers() {
981        let raw = vec![
982            "afhttp".to_string(),
983            "--mode".to_string(),
984            "curl".to_string(),
985        ];
986        assert!(raw_mode_is_curl(&raw));
987        assert_eq!(strip_mode_flag(&raw[1..]), Vec::<String>::new());
988
989        let raw = vec![
990            "afhttp".to_string(),
991            "--mode=curl".to_string(),
992            "-X".to_string(),
993            "GET".to_string(),
994            "https://example.com".to_string(),
995        ];
996        assert!(raw_mode_is_curl(&raw));
997        assert_eq!(
998            strip_mode_flag(&raw[1..]),
999            vec![
1000                "-X".to_string(),
1001                "GET".to_string(),
1002                "https://example.com".to_string()
1003            ]
1004        );
1005
1006        let raw = vec![
1007            "afhttp".to_string(),
1008            "--mode".to_string(),
1009            "pipe".to_string(),
1010        ];
1011        assert!(!raw_mode_is_curl(&raw));
1012    }
1013}