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, 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    let cli = Cli::try_parse().unwrap_or_else(|e| {
320        if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
321            e.exit();
322        }
323        emit_cli_usage_error_and_exit(e.to_string(), None);
324    });
325    let output_format = match cli_parse_output(&cli.output) {
326        Ok(f) => f,
327        Err(e) => emit_cli_usage_error_and_exit(e, None),
328    };
329
330    match cli.mode {
331        CliMode::Pipe => {
332            // Build config overrides from CLI flags so --mode pipe --log startup,retry --proxy ...
333            // all take effect at launch time.
334            const ALL_CATEGORIES: &[&str] =
335                &["startup", "request", "progress", "retry", "redirect"];
336            let log_categories: Vec<String> = if cli.verbose {
337                cli_parse_log_filters(ALL_CATEGORIES)
338            } else if let Some(ref log_str) = cli.log {
339                let entries: Vec<&str> = log_str.split(',').collect();
340                cli_parse_log_filters(&entries)
341            } else {
342                vec![]
343            };
344            let has_log_flag = cli.verbose || cli.log.is_some();
345            let tls = build_tls_partial(&cli);
346            let pipe_config = ConfigPatch {
347                response_save_dir: cli.response_save_dir.clone(),
348                response_save_above_bytes: cli.response_save_above_bytes,
349                request_concurrency_limit: cli.request_concurrency_limit,
350                timeout_connect_s: cli.timeout_connect_s,
351                retry_base_delay_ms: cli.retry_base_delay_ms,
352                proxy: cli.proxy.clone(),
353                tls,
354                log: if has_log_flag {
355                    Some(log_categories)
356                } else {
357                    None
358                },
359                ..ConfigPatch::default()
360            };
361            return Mode::Pipe(Box::new(PipeInit {
362                config: pipe_config,
363                output_format,
364            }));
365        }
366        CliMode::Curl => {
367            let curl_args = strip_mode_flag(&raw[1..]);
368            return crate::curl_compat::parse_curl_args(&curl_args);
369        }
370        CliMode::Cli => {}
371    }
372
373    let method = match cli.method {
374        Some(ref m) => m.to_uppercase(),
375        None => {
376            // No method in cli mode: show help and exit 2
377            let mut cmd = <Cli as clap::CommandFactory>::command();
378            let _ = cmd.print_help();
379            let _ = writeln!(std::io::stdout());
380            std::process::exit(2);
381        }
382    };
383
384    let url = match cli.url {
385        Some(ref u) => u.clone(),
386        None => {
387            emit_cli_usage_error_and_exit(
388                "URL is required after method",
389                Some("usage: afhttp METHOD URL [flags]"),
390            );
391        }
392    };
393
394    // Parse headers
395    let mut headers = HashMap::new();
396    for h in &cli.header {
397        let (name, value) = parse_header_flag(h);
398        headers.insert(name, value);
399    }
400
401    // Parse body
402    let (body, body_base64, body_file, body_multipart, body_urlencoded) = parse_body_flags(&cli);
403
404    // Build chunked options
405    let mut chunked = cli.chunked;
406    let chunked_delimiter = if cli.chunked_delimiter_raw {
407        chunked = true;
408        Value::Null
409    } else if let Some(ref d) = cli.chunked_delimiter {
410        chunked = true;
411        Value::String(unescape_delimiter(d))
412    } else {
413        Value::String("\n".to_string())
414    };
415
416    // Build TLS partial (borrows cli)
417    let tls = build_tls_partial(&cli);
418
419    // Parse log categories from --verbose or --log
420    const ALL_CATEGORIES: &[&str] = &["startup", "request", "progress", "retry", "redirect"];
421    let log_categories: Vec<String> = if cli.verbose {
422        cli_parse_log_filters(ALL_CATEGORIES)
423    } else if let Some(ref log_str) = cli.log {
424        let entries: Vec<&str> = log_str.split(',').collect();
425        cli_parse_log_filters(&entries)
426    } else {
427        vec![]
428    };
429
430    // Build config overrides — non-startup log categories flow through here
431    let has_log_flag = cli.verbose || cli.log.is_some();
432    let config_overrides = ConfigPatch {
433        response_save_dir: cli.response_save_dir.clone(),
434        response_save_above_bytes: cli.response_save_above_bytes,
435        request_concurrency_limit: cli.request_concurrency_limit,
436        timeout_connect_s: cli.timeout_connect_s,
437        retry_base_delay_ms: cli.retry_base_delay_ms,
438        proxy: cli.proxy.clone(),
439        log: if has_log_flag {
440            Some(
441                log_categories
442                    .iter()
443                    .filter(|c| *c != "startup")
444                    .cloned()
445                    .collect(),
446            )
447        } else {
448            None
449        },
450        ..ConfigPatch::default()
451    };
452
453    // Parse retry_on_status from comma-separated string
454    let retry_on_status = cli.retry_on_status.as_deref().map(|s| {
455        s.split(',')
456            .filter_map(|c| c.trim().parse::<u16>().ok())
457            .collect()
458    });
459
460    // Build request options (consumes remaining cli fields)
461    let options = RequestOptions {
462        timeout_idle_s: cli.timeout_idle_s,
463        retry: cli.retry,
464        response_redirect: cli.response_redirect,
465        response_parse_json: cli.response_parse_json,
466        response_decompress: cli.response_decompress,
467        response_save_resume: if cli.response_save_resume {
468            Some(true)
469        } else {
470            None
471        },
472        chunked,
473        chunked_delimiter,
474        response_save_file: cli.response_save_file,
475        progress_bytes: cli.progress_bytes,
476        progress_ms: cli.progress_ms,
477        retry_on_status,
478        response_max_bytes: cli.response_max_bytes,
479        upgrade: cli.upgrade,
480        tls,
481    };
482
483    Mode::Cli(Box::new(CliRequest {
484        method,
485        url,
486        headers,
487        body,
488        body_base64,
489        body_file,
490        body_multipart,
491        body_urlencoded,
492        options,
493        config_overrides,
494        log_categories,
495        output_format,
496        dry_run: cli.dry_run,
497    }))
498}
499
500// ---------------------------------------------------------------------------
501// CLI output writer (strips id/tag from Output)
502// ---------------------------------------------------------------------------
503
504pub fn write_cli_output(output: &Output, format: OutputFormat) {
505    let mut value = match serde_json::to_value(output) {
506        Ok(v) => v,
507        Err(_) => {
508            let fallback = r#"{"code":"error","error_code":"internal_error","error":"output serialization failed","retryable":false,"trace":{"duration_ms":0}}"#;
509            let stdout = std::io::stdout();
510            let mut out = stdout.lock();
511            let _ = out.write_all(fallback.as_bytes());
512            let _ = out.write_all(b"\n");
513            let _ = out.flush();
514            return;
515        }
516    };
517
518    // Strip id and tag fields for CLI output
519    if let Some(obj) = value.as_object_mut() {
520        obj.remove("id");
521        obj.remove("tag");
522    }
523
524    let formatted = if matches!(format, OutputFormat::Json) {
525        match json_redaction_policy_for_output(output) {
526            Some(policy) => agent_first_data::output_json_with(&value, policy),
527            None => agent_first_data::output_json(&value),
528        }
529    } else {
530        // Protect server body fields from Agent-First Data suffix processing.
531        // Non-string body (parsed JSON objects) is converted to a JSON string
532        // so formatters treat them as opaque data.
533        protect_server_body(&mut value);
534        cli_output(&value, format)
535    };
536
537    let stdout = std::io::stdout();
538    let mut out = stdout.lock();
539    let _ = out.write_all(formatted.as_bytes());
540    if !formatted.ends_with('\n') {
541        let _ = out.write_all(b"\n");
542    }
543    let _ = out.flush();
544}
545
546fn json_redaction_policy_for_output(output: &Output) -> Option<RedactionPolicy> {
547    match output {
548        // Keep server payload raw in response body; only trace metadata is redacted.
549        Output::Response { .. } => Some(RedactionPolicy::RedactionTraceOnly),
550        // Stream chunks are opaque server data.
551        Output::ChunkData { .. } => Some(RedactionPolicy::RedactionNone),
552        // Other events keep existing safe default.
553        _ => None,
554    }
555}
556
557/// Protect server-originated body fields from Agent-First Data suffix processing.
558/// Converts non-string body/data values to JSON string representation
559/// so yaml/plain formatters treat them as opaque strings.
560fn protect_server_body(value: &mut Value) {
561    if let Some(obj) = value.as_object_mut() {
562        for key in &["body", "data"] {
563            if let Some(v) = obj.get(*key).cloned() {
564                if !v.is_null() && !v.is_string() {
565                    if let Ok(json_str) = serde_json::to_string(&v) {
566                        obj.insert((*key).to_string(), Value::String(json_str));
567                    }
568                }
569            }
570        }
571    }
572}
573
574// ---------------------------------------------------------------------------
575// Parsing helpers
576// ---------------------------------------------------------------------------
577
578fn parse_header_flag(s: &str) -> (String, Value) {
579    let colon_pos = match s.find(':') {
580        Some(p) => p,
581        None => {
582            emit_cli_usage_error_and_exit(
583                format!("invalid header '{s}'"),
584                Some("expected format: Name: Value"),
585            );
586        }
587    };
588    let name = s[..colon_pos].trim().to_string();
589    let value = s[colon_pos + 1..].trim();
590    if value.is_empty() {
591        (name, Value::Null) // null removes default
592    } else {
593        (name, Value::String(value.to_string()))
594    }
595}
596
597#[allow(clippy::type_complexity)]
598fn parse_body_flags(
599    cli: &Cli,
600) -> (
601    Option<Value>,
602    Option<String>,
603    Option<String>,
604    Option<Vec<MultipartPart>>,
605    Option<Vec<UrlencodedPart>>,
606) {
607    let has_body = cli.body.is_some();
608    let has_base64 = cli.body_base64.is_some();
609    let has_file = cli.body_file.is_some();
610    let has_multipart = !cli.body_multipart.is_empty();
611    let has_urlencoded = !cli.body_urlencoded.is_empty();
612
613    let count = [
614        has_body,
615        has_base64,
616        has_file,
617        has_multipart,
618        has_urlencoded,
619    ]
620    .iter()
621    .filter(|&&b| b)
622    .count();
623    if count > 1 {
624        emit_cli_usage_error_and_exit(
625            "--body, --body-base64, --body-file, --body-multipart, and --body-urlencoded are mutually exclusive",
626            Some("use only one body flag per request"),
627        );
628    }
629
630    if let Some(ref b) = cli.body {
631        // @path -> body_file
632        if let Some(path) = b.strip_prefix('@') {
633            return (None, None, Some(path.to_string()), None, None);
634        }
635        // JSON auto-detect: full parse, object or array only — numbers/booleans/null are ambiguous
636        let trimmed = b.trim();
637        if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
638            if v.is_object() || v.is_array() {
639                return (Some(v), None, None, None, None);
640            }
641        }
642        // Plain text
643        return (Some(Value::String(b.clone())), None, None, None, None);
644    }
645
646    if let Some(ref b64) = cli.body_base64 {
647        return (None, Some(b64.clone()), None, None, None);
648    }
649
650    if let Some(ref path) = cli.body_file {
651        return (None, None, Some(path.clone()), None, None);
652    }
653
654    if !cli.body_multipart.is_empty() {
655        let parts: Vec<MultipartPart> = cli
656            .body_multipart
657            .iter()
658            .map(|s| parse_form_flag(s))
659            .collect();
660        return (None, None, None, Some(parts), None);
661    }
662
663    if !cli.body_urlencoded.is_empty() {
664        let parts: Vec<UrlencodedPart> = cli
665            .body_urlencoded
666            .iter()
667            .map(|s| parse_urlencoded_flag(s))
668            .collect();
669        return (None, None, None, None, Some(parts));
670    }
671
672    (None, None, None, None, None)
673}
674
675fn parse_form_flag(s: &str) -> MultipartPart {
676    let eq_pos = match s.find('=') {
677        Some(p) => p,
678        None => {
679            emit_cli_usage_error_and_exit(
680                format!("invalid --body-multipart '{s}'"),
681                Some("expected format: name=value or name=@filepath"),
682            );
683        }
684    };
685    let name = s[..eq_pos].to_string();
686    let rest = &s[eq_pos + 1..];
687
688    if let Some(file_rest) = rest.strip_prefix('@') {
689        // File part: name=@path[;filename=x][;type=mime]
690        let parts: Vec<&str> = file_rest.split(';').collect();
691        let file = parts[0].to_string();
692        let mut filename = None;
693        let mut content_type = None;
694        for p in &parts[1..] {
695            if let Some(f) = p.strip_prefix("filename=") {
696                filename = Some(f.to_string());
697            } else if let Some(t) = p.strip_prefix("type=") {
698                content_type = Some(t.to_string());
699            }
700        }
701        MultipartPart {
702            name,
703            value: None,
704            value_base64: None,
705            file: Some(file),
706            filename,
707            content_type,
708        }
709    } else {
710        // Text part
711        MultipartPart {
712            name,
713            value: Some(rest.to_string()),
714            value_base64: None,
715            file: None,
716            filename: None,
717            content_type: None,
718        }
719    }
720}
721
722fn parse_urlencoded_flag(s: &str) -> UrlencodedPart {
723    match s.find('=') {
724        Some(pos) => UrlencodedPart {
725            name: s[..pos].to_string(),
726            value: s[pos + 1..].to_string(),
727        },
728        None => {
729            emit_cli_usage_error_and_exit(
730                format!("invalid --body-urlencoded '{s}'"),
731                Some("expected format: name=value"),
732            );
733        }
734    }
735}
736
737fn build_tls_partial(cli: &Cli) -> Option<TlsConfigPartial> {
738    if cli.tls_insecure
739        || cli.tls_cacert_file.is_some()
740        || cli.tls_cert_file.is_some()
741        || cli.tls_key_file.is_some()
742    {
743        Some(TlsConfigPartial {
744            insecure: if cli.tls_insecure { Some(true) } else { None },
745            cacert_pem: None,
746            cacert_file: cli.tls_cacert_file.clone(),
747            cert_pem: None,
748            cert_file: cli.tls_cert_file.clone(),
749            key_pem_secret: None,
750            key_file: cli.tls_key_file.clone(),
751        })
752    } else {
753        None
754    }
755}
756
757/// Unescape common delimiter literals: \\n -> \n
758fn unescape_delimiter(s: &str) -> String {
759    s.replace("\\n", "\n")
760}
761
762#[cfg(test)]
763#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
764mod tests {
765    use super::*;
766
767    fn empty_cli() -> Cli {
768        Cli {
769            method: None,
770            url: None,
771            header: vec![],
772            body: None,
773            body_base64: None,
774            body_file: None,
775            body_multipart: vec![],
776            body_urlencoded: vec![],
777            response_save_dir: None,
778            response_save_above_bytes: None,
779            request_concurrency_limit: None,
780            timeout_connect_s: None,
781            timeout_idle_s: None,
782            retry: None,
783            retry_base_delay_ms: None,
784            retry_on_status: None,
785            response_redirect: None,
786            response_parse_json: None,
787            response_decompress: None,
788            response_save_file: None,
789            response_save_resume: false,
790            response_max_bytes: None,
791            chunked: false,
792            chunked_delimiter: None,
793            chunked_delimiter_raw: false,
794            progress_ms: None,
795            progress_bytes: None,
796            tls_insecure: false,
797            tls_cacert_file: None,
798            tls_cert_file: None,
799            tls_key_file: None,
800            proxy: None,
801            upgrade: None,
802            output: "json".to_string(),
803            log: None,
804            verbose: false,
805            dry_run: false,
806            mode: CliMode::Cli,
807        }
808    }
809
810    #[test]
811    fn parse_header_flag_normal_and_remove_default() {
812        let (name, value) = parse_header_flag("X-Test: abc");
813        assert_eq!(name, "X-Test");
814        assert_eq!(value, Value::String("abc".to_string()));
815
816        let (name, value) = parse_header_flag("X-Remove:   ");
817        assert_eq!(name, "X-Remove");
818        assert_eq!(value, Value::Null);
819    }
820
821    #[test]
822    fn parse_body_flags_object_array_string_and_files() {
823        let mut cli = empty_cli();
824        cli.body = Some("{\"a\":1}".to_string());
825        let (body, b64, file, mp, ue) = parse_body_flags(&cli);
826        assert_eq!(body, Some(serde_json::json!({"a":1})));
827        assert!(b64.is_none() && file.is_none() && mp.is_none() && ue.is_none());
828
829        let mut cli = empty_cli();
830        cli.body = Some("[1,2]".to_string());
831        let (body, _, _, _, _) = parse_body_flags(&cli);
832        assert_eq!(body, Some(serde_json::json!([1, 2])));
833
834        let mut cli = empty_cli();
835        cli.body = Some("hello".to_string());
836        let (body, _, _, _, _) = parse_body_flags(&cli);
837        assert_eq!(body, Some(Value::String("hello".to_string())));
838
839        let mut cli = empty_cli();
840        cli.body = Some("@/tmp/body.txt".to_string());
841        let (_, _, file, _, _) = parse_body_flags(&cli);
842        assert_eq!(file.as_deref(), Some("/tmp/body.txt"));
843
844        let mut cli = empty_cli();
845        cli.body_base64 = Some("aGVsbG8=".to_string());
846        let (_, b64, _, _, _) = parse_body_flags(&cli);
847        assert_eq!(b64.as_deref(), Some("aGVsbG8="));
848
849        let mut cli = empty_cli();
850        cli.body_file = Some("/tmp/f.bin".to_string());
851        let (_, _, file, _, _) = parse_body_flags(&cli);
852        assert_eq!(file.as_deref(), Some("/tmp/f.bin"));
853    }
854
855    #[test]
856    fn parse_body_flags_multipart_and_urlencoded() {
857        let mut cli = empty_cli();
858        cli.body_multipart = vec![
859            "name=roger".to_string(),
860            "upload=@/tmp/a.txt;filename=x.txt;type=text/plain".to_string(),
861        ];
862        let (_, _, _, mp, _) = parse_body_flags(&cli);
863        let parts = mp.expect("multipart");
864        assert_eq!(parts.len(), 2);
865        assert_eq!(parts[0].name, "name");
866        assert_eq!(parts[0].value.as_deref(), Some("roger"));
867        assert_eq!(parts[1].file.as_deref(), Some("/tmp/a.txt"));
868        assert_eq!(parts[1].filename.as_deref(), Some("x.txt"));
869        assert_eq!(parts[1].content_type.as_deref(), Some("text/plain"));
870
871        let mut cli = empty_cli();
872        cli.body_urlencoded = vec!["a=1".to_string(), "b=".to_string()];
873        let (_, _, _, _, ue) = parse_body_flags(&cli);
874        let parts = ue.expect("urlencoded");
875        assert_eq!(parts.len(), 2);
876        assert_eq!(parts[0].name, "a");
877        assert_eq!(parts[0].value, "1");
878        assert_eq!(parts[1].name, "b");
879        assert_eq!(parts[1].value, "");
880    }
881
882    #[test]
883    fn parse_form_and_urlencoded_flags() {
884        let p = parse_form_flag("n=v");
885        assert_eq!(p.name, "n");
886        assert_eq!(p.value.as_deref(), Some("v"));
887        assert!(p.file.is_none());
888
889        let p = parse_form_flag("f=@/tmp/a.bin;filename=b.bin;type=application/octet-stream");
890        assert_eq!(p.file.as_deref(), Some("/tmp/a.bin"));
891        assert_eq!(p.filename.as_deref(), Some("b.bin"));
892        assert_eq!(p.content_type.as_deref(), Some("application/octet-stream"));
893
894        let p = parse_urlencoded_flag("x=1");
895        assert_eq!(p.name, "x");
896        assert_eq!(p.value, "1");
897    }
898
899    #[test]
900    fn build_tls_partial_and_unescape_delimiter() {
901        let mut cli = empty_cli();
902        assert!(build_tls_partial(&cli).is_none());
903
904        cli.tls_insecure = true;
905        cli.tls_cacert_file = Some("/tmp/ca.pem".to_string());
906        cli.tls_cert_file = Some("/tmp/cert.pem".to_string());
907        cli.tls_key_file = Some("/tmp/key.pem".to_string());
908        let tls = build_tls_partial(&cli).expect("tls");
909        assert_eq!(tls.insecure, Some(true));
910        assert_eq!(tls.cacert_file.as_deref(), Some("/tmp/ca.pem"));
911        assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem"));
912        assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem"));
913
914        assert_eq!(unescape_delimiter("\\n\\n"), "\n\n");
915    }
916
917    #[test]
918    fn protect_server_body_stringifies_non_string() {
919        let mut value = serde_json::json!({
920            "body": {"a": 1},
921            "data": [1,2],
922            "other": true
923        });
924        protect_server_body(&mut value);
925        assert_eq!(
926            value.get("body"),
927            Some(&Value::String("{\"a\":1}".to_string()))
928        );
929        assert_eq!(value.get("data"), Some(&Value::String("[1,2]".to_string())));
930        assert_eq!(value.get("other"), Some(&Value::Bool(true)));
931    }
932
933    #[test]
934    fn json_redaction_policy_for_response_and_log() {
935        let resp = Output::Response {
936            id: "1".to_string(),
937            tag: None,
938            status: 200,
939            headers: HashMap::new(),
940            body: Some(serde_json::json!({"api_key_secret":"sk-live-123"})),
941            body_base64: None,
942            body_file: None,
943            body_parse_failed: false,
944            trace: Trace::error_only(1),
945        };
946        assert_eq!(
947            json_redaction_policy_for_output(&resp),
948            Some(RedactionPolicy::RedactionTraceOnly)
949        );
950
951        let log = Output::Log {
952            event: "startup".to_string(),
953            fields: HashMap::from([(
954                "api_key_secret".to_string(),
955                Value::String("sk-live-123".to_string()),
956            )]),
957        };
958        assert_eq!(json_redaction_policy_for_output(&log), None);
959    }
960
961    #[test]
962    fn curl_mode_helpers() {
963        let raw = vec![
964            "afhttp".to_string(),
965            "--mode".to_string(),
966            "curl".to_string(),
967        ];
968        assert!(raw_mode_is_curl(&raw));
969        assert_eq!(strip_mode_flag(&raw[1..]), Vec::<String>::new());
970
971        let raw = vec![
972            "afhttp".to_string(),
973            "--mode=curl".to_string(),
974            "-X".to_string(),
975            "GET".to_string(),
976            "https://example.com".to_string(),
977        ];
978        assert!(raw_mode_is_curl(&raw));
979        assert_eq!(
980            strip_mode_flag(&raw[1..]),
981            vec![
982                "-X".to_string(),
983                "GET".to_string(),
984                "https://example.com".to_string()
985            ]
986        );
987
988        let raw = vec![
989            "afhttp".to_string(),
990            "--mode".to_string(),
991            "pipe".to_string(),
992        ];
993        assert!(!raw_mode_is_curl(&raw));
994    }
995}