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#[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 pub method: Option<String>,
66
67 pub url: Option<String>,
69
70 #[arg(long = "header", help_heading = "Request")]
73 pub header: Vec<String>,
74
75 #[arg(long = "body", help_heading = "Request")]
77 pub body: Option<String>,
78
79 #[arg(long = "body-base64", help_heading = "Request")]
81 pub body_base64: Option<String>,
82
83 #[arg(long = "body-file", help_heading = "Request")]
85 pub body_file: Option<String>,
86
87 #[arg(long = "body-multipart", help_heading = "Request")]
89 pub body_multipart: Vec<String>,
90
91 #[arg(long = "body-urlencoded", help_heading = "Request")]
93 pub body_urlencoded: Vec<String>,
94
95 #[arg(long = "response-save-dir", help_heading = "Config")]
98 pub response_save_dir: Option<String>,
99
100 #[arg(long = "response-save-above-bytes", help_heading = "Config")]
102 pub response_save_above_bytes: Option<u64>,
103
104 #[arg(long = "request-concurrency-limit", help_heading = "Config")]
106 pub request_concurrency_limit: Option<u64>,
107
108 #[arg(long = "timeout-connect-s", help_heading = "Config")]
110 pub timeout_connect_s: Option<u64>,
111
112 #[arg(long = "timeout-idle-s", help_heading = "Config")]
114 pub timeout_idle_s: Option<u64>,
115
116 #[arg(long, help_heading = "Config")]
118 pub retry: Option<u32>,
119
120 #[arg(long = "retry-base-delay-ms", help_heading = "Config")]
122 pub retry_base_delay_ms: Option<u64>,
123
124 #[arg(long = "retry-on-status", help_heading = "Config")]
126 pub retry_on_status: Option<String>,
127
128 #[arg(long = "response-redirect", help_heading = "Config")]
130 pub response_redirect: Option<u32>,
131
132 #[arg(long = "response-parse-json", help_heading = "Config")]
134 pub response_parse_json: Option<bool>,
135
136 #[arg(long = "response-decompress", help_heading = "Config")]
138 pub response_decompress: Option<bool>,
139
140 #[arg(long = "response-save-file", help_heading = "Config")]
142 pub response_save_file: Option<String>,
143
144 #[arg(long = "response-save-resume", help_heading = "Config")]
146 pub response_save_resume: bool,
147
148 #[arg(long = "response-max-bytes", help_heading = "Config")]
150 pub response_max_bytes: Option<u64>,
151
152 #[arg(long, help_heading = "Config")]
154 pub chunked: bool,
155
156 #[arg(long = "chunked-delimiter", help_heading = "Config")]
158 pub chunked_delimiter: Option<String>,
159
160 #[arg(long = "chunked-delimiter-raw", help_heading = "Config")]
162 pub chunked_delimiter_raw: bool,
163
164 #[arg(long = "progress-ms", help_heading = "Config")]
166 pub progress_ms: Option<u64>,
167
168 #[arg(long = "progress-bytes", help_heading = "Config")]
170 pub progress_bytes: Option<u64>,
171
172 #[arg(long = "tls-insecure", help_heading = "TLS")]
175 pub tls_insecure: bool,
176
177 #[arg(long = "tls-cacert-file", help_heading = "TLS")]
179 pub tls_cacert_file: Option<String>,
180
181 #[arg(long = "tls-cert-file", help_heading = "TLS")]
183 pub tls_cert_file: Option<String>,
184
185 #[arg(long = "tls-key-file", help_heading = "TLS")]
187 pub tls_key_file: Option<String>,
188
189 #[arg(long, help_heading = "Other")]
192 pub proxy: Option<String>,
193
194 #[arg(long, help_heading = "Other")]
196 pub upgrade: Option<String>,
197
198 #[arg(long, default_value = "json", help_heading = "Output")]
201 pub output: String,
202
203 #[arg(long, help_heading = "Output")]
205 pub log: Option<String>,
206
207 #[arg(long, help_heading = "Output")]
209 pub verbose: bool,
210
211 #[arg(long, help_heading = "Output")]
213 pub dry_run: bool,
214
215 #[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
228pub 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 pub log_categories: Vec<String>,
245 pub output_format: OutputFormat,
247 pub dry_run: bool,
249}
250
251pub 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
306pub fn parse_args() -> Mode {
311 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 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 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 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 let (body, body_base64, body_file, body_multipart, body_urlencoded) = parse_body_flags(&cli);
403
404 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 let tls = build_tls_partial(&cli);
418
419 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 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 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 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
500pub 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 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(&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 Output::Response { .. } => Some(RedactionPolicy::RedactionTraceOnly),
550 Output::ChunkData { .. } => Some(RedactionPolicy::RedactionNone),
552 _ => None,
554 }
555}
556
557fn 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
574fn 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) } 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 if let Some(path) = b.strip_prefix('@') {
633 return (None, None, Some(path.to_string()), None, None);
634 }
635 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 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 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 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
757fn 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}