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#[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 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 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 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 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 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 let (body, body_base64, body_file, body_multipart, body_urlencoded) = parse_body_flags(&cli);
421
422 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 let tls = build_tls_partial(&cli);
436
437 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 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 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 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
518pub 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 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(&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 Output::Response { .. } => Some(RedactionPolicy::RedactionTraceOnly),
568 Output::ChunkData { .. } => Some(RedactionPolicy::RedactionNone),
570 _ => None,
572 }
573}
574
575fn 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
592fn 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) } 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 if let Some(path) = b.strip_prefix('@') {
651 return (None, None, Some(path.to_string()), None, None);
652 }
653 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 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 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 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
775fn 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}