1use std::collections::{BTreeMap, HashMap};
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27use typed_builder::TypedBuilder;
28
29use crate::hooks::HookMatcher;
30use crate::mcp::McpServers;
31use crate::permissions::CanUseToolCallback;
32
33pub use crate::callback::MessageCallback;
36
37#[derive(TypedBuilder)]
58pub struct ClientConfig {
59 #[builder(setter(into))]
63 pub prompt: String,
64
65 #[builder(default, setter(strip_option))]
70 pub cli_path: Option<PathBuf>,
71
72 #[builder(default, setter(strip_option))]
75 pub cwd: Option<PathBuf>,
76
77 #[builder(default, setter(strip_option, into))]
81 pub model: Option<String>,
82
83 #[builder(default, setter(strip_option))]
87 pub system_prompt: Option<SystemPrompt>,
88
89 #[builder(default, setter(strip_option))]
91 pub max_turns: Option<u32>,
92
93 #[builder(default)]
98 pub allowed_tools: Vec<String>,
99
100 #[builder(default)]
102 pub disallowed_tools: Vec<String>,
103
104 #[builder(default)]
108 pub permission_mode: PermissionMode,
109
110 #[builder(default, setter(strip_option))]
113 pub can_use_tool: Option<CanUseToolCallback>,
114
115 #[builder(default, setter(strip_option, into))]
119 pub resume: Option<String>,
120
121 #[builder(default)]
125 pub hooks: Vec<HookMatcher>,
126
127 #[builder(default)]
131 pub mcp_servers: McpServers,
132
133 #[builder(default, setter(strip_option))]
138 pub message_callback: Option<MessageCallback>,
139
140 #[builder(default)]
144 pub env: HashMap<String, String>,
145
146 #[builder(default)]
148 pub verbose: bool,
149
150 #[builder(default, setter(strip_option))]
165 pub auth_method: Option<AuthMethod>,
166
167 #[builder(default)]
169 pub sandbox: bool,
170
171 #[builder(default)]
174 pub extra_args: BTreeMap<String, Option<String>>,
175
176 #[builder(default_code = "Some(Duration::from_secs(30))")]
180 pub connect_timeout: Option<Duration>,
181
182 #[builder(default_code = "Some(Duration::from_secs(10))")]
184 pub close_timeout: Option<Duration>,
185
186 #[builder(default)]
191 pub read_timeout: Option<Duration>,
192
193 #[builder(default_code = "Duration::from_secs(30)")]
195 pub default_hook_timeout: Duration,
196
197 #[builder(default_code = "Some(Duration::from_secs(5))")]
202 pub version_check_timeout: Option<Duration>,
203
204 #[builder(default, setter(strip_option))]
209 pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
210}
211
212impl ClientConfig {
213 pub fn to_cli_args(&self) -> Vec<String> {
232 let mut args = vec!["--experimental-acp".to_string()];
233
234 if let Some(model) = &self.model {
235 args.push("--model".to_string());
236 args.push(model.clone());
237 }
238
239 if self.sandbox {
240 args.push("--sandbox".to_string());
241 }
242
243 match self.permission_mode {
244 PermissionMode::Default => {}
245 PermissionMode::AcceptEdits => {
246 args.push("--approval-mode".to_string());
247 args.push("auto_edit".to_string());
248 }
249 PermissionMode::Plan => {
250 args.push("--approval-mode".to_string());
251 args.push("plan".to_string());
252 }
253 PermissionMode::BypassPermissions => {
254 args.push("--yolo".to_string());
255 }
256 }
257
258 if self.verbose {
259 args.push("--debug".to_string());
260 }
261
262 if let Some(turns) = self.max_turns {
263 args.push("--max-turns".to_string());
264 args.push(turns.to_string());
265 }
266
267 if let Some(sp) = &self.system_prompt {
268 match sp {
269 SystemPrompt::Text(text) => {
270 args.push("--system-prompt".to_string());
271 args.push(text.clone());
272 }
273 SystemPrompt::File(path) => {
274 args.push("--system-prompt-file".to_string());
275 args.push(path.to_string_lossy().to_string());
276 }
277 }
278 }
279
280 if !self.allowed_tools.is_empty() {
281 args.push("--allowed-tools".to_string());
282 args.push(self.allowed_tools.join(","));
283 }
284
285 if !self.disallowed_tools.is_empty() {
286 args.push("--disallowed-tools".to_string());
287 args.push(self.disallowed_tools.join(","));
288 }
289
290 for (key, value) in &self.extra_args {
291 args.push(format!("--{key}"));
292 if let Some(v) = value {
293 args.push(v.clone());
294 }
295 }
296
297 args
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub enum AuthMethod {
310 LoginWithGoogle,
312 ApiKey,
314 VertexAi,
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
327pub enum PermissionMode {
328 #[default]
330 Default,
331 AcceptEdits,
333 Plan,
335 BypassPermissions,
337}
338
339#[derive(Debug, Clone)]
347pub enum SystemPrompt {
348 Text(String),
350 File(PathBuf),
352}
353
354#[cfg(test)]
357mod tests {
358 use std::collections::BTreeMap;
359
360 use super::{AuthMethod, ClientConfig, PermissionMode};
361
362 #[test]
365 fn test_config_builder_minimal() {
366 let config = ClientConfig::builder().prompt("test").build();
370 assert_eq!(config.prompt, "test");
371 assert!(config.cli_path.is_none());
372 assert!(config.model.is_none());
373 assert!(config.cwd.is_none());
374 assert!(config.allowed_tools.is_empty());
375 assert!(config.disallowed_tools.is_empty());
376 assert!(config.hooks.is_empty());
377 assert!(config.mcp_servers.is_empty());
378 assert!(!config.sandbox);
379 assert!(!config.verbose);
380 }
381
382 #[test]
383 fn test_config_builder_with_model() {
384 let config = ClientConfig::builder()
385 .prompt("hello")
386 .model("gemini-2.5-pro")
387 .build();
388
389 assert_eq!(config.model.as_deref(), Some("gemini-2.5-pro"));
390 }
391
392 #[test]
395 fn test_to_cli_args_default() {
396 let config = ClientConfig::builder().prompt("test").build();
398 let args = config.to_cli_args();
399 assert_eq!(args, vec!["--experimental-acp"]);
400 }
401
402 #[test]
403 fn test_to_cli_args_with_model() {
404 let config = ClientConfig::builder()
405 .prompt("test")
406 .model("gemini-2.5-flash")
407 .build();
408 let args = config.to_cli_args();
409 assert!(
410 args.contains(&"--model".to_string()),
411 "expected --model flag"
412 );
413 assert!(
414 args.contains(&"gemini-2.5-flash".to_string()),
415 "expected model value"
416 );
417 let model_pos = args.iter().position(|a| a == "--model").unwrap();
419 assert_eq!(args[model_pos + 1], "gemini-2.5-flash");
420 }
421
422 #[test]
423 fn test_to_cli_args_accept_edits() {
424 let config = ClientConfig::builder()
425 .prompt("test")
426 .permission_mode(PermissionMode::AcceptEdits)
427 .build();
428 let args = config.to_cli_args();
429 assert!(
430 args.contains(&"--approval-mode".to_string()),
431 "expected --approval-mode"
432 );
433 assert!(
434 args.contains(&"auto_edit".to_string()),
435 "expected auto_edit value"
436 );
437 let pos = args.iter().position(|a| a == "--approval-mode").unwrap();
438 assert_eq!(args[pos + 1], "auto_edit");
439 }
440
441 #[test]
442 fn test_to_cli_args_plan_mode() {
443 let config = ClientConfig::builder()
444 .prompt("test")
445 .permission_mode(PermissionMode::Plan)
446 .build();
447 let args = config.to_cli_args();
448 let pos = args.iter().position(|a| a == "--approval-mode").unwrap();
449 assert_eq!(args[pos + 1], "plan");
450 }
451
452 #[test]
453 fn test_to_cli_args_bypass() {
454 let config = ClientConfig::builder()
455 .prompt("test")
456 .permission_mode(PermissionMode::BypassPermissions)
457 .build();
458 let args = config.to_cli_args();
459 assert!(
460 args.contains(&"--yolo".to_string()),
461 "BypassPermissions must map to --yolo"
462 );
463 assert!(
466 !args.contains(&"--approval-mode".to_string()),
467 "--approval-mode must not appear alongside --yolo"
468 );
469 }
470
471 #[test]
472 fn test_to_cli_args_sandbox() {
473 let config = ClientConfig::builder()
474 .prompt("test")
475 .sandbox(true)
476 .build();
477 let args = config.to_cli_args();
478 assert!(
479 args.contains(&"--sandbox".to_string()),
480 "sandbox=true must emit --sandbox"
481 );
482 }
483
484 #[test]
485 fn test_to_cli_args_sandbox_false_omitted() {
486 let config = ClientConfig::builder().prompt("test").build();
488 let args = config.to_cli_args();
489 assert!(
490 !args.contains(&"--sandbox".to_string()),
491 "--sandbox must not appear when sandbox=false"
492 );
493 }
494
495 #[test]
496 fn test_to_cli_args_extra() {
497 let mut extra = BTreeMap::new();
498 extra.insert("temperature".to_string(), Some("0.7".to_string()));
499 extra.insert("top-p".to_string(), None);
500
501 let config = ClientConfig::builder()
502 .prompt("test")
503 .extra_args(extra)
504 .build();
505 let args = config.to_cli_args();
506
507 assert!(
508 args.contains(&"--temperature".to_string()),
509 "extra key must become --<key>"
510 );
511 assert!(
512 args.contains(&"0.7".to_string()),
513 "extra value must appear as a separate arg"
514 );
515 assert!(
516 args.contains(&"--top-p".to_string()),
517 "flag-only extra arg must appear without a value"
518 );
519 }
520
521 #[test]
522 fn test_to_cli_args_extra_btreemap_ordering() {
523 let mut extra = BTreeMap::new();
525 extra.insert("zzz".to_string(), Some("last".to_string()));
526 extra.insert("aaa".to_string(), Some("first".to_string()));
527
528 let config = ClientConfig::builder()
529 .prompt("test")
530 .extra_args(extra)
531 .build();
532 let args = config.to_cli_args();
533
534 let aaa_pos = args.iter().position(|a| a == "--aaa").unwrap();
536 let zzz_pos = args.iter().position(|a| a == "--zzz").unwrap();
537 assert!(aaa_pos < zzz_pos, "--aaa must precede --zzz (BTreeMap order)");
538 }
539
540 #[test]
543 fn test_permission_mode_default() {
544 assert_eq!(
545 PermissionMode::default(),
546 PermissionMode::Default,
547 "Default must be the zero-value for PermissionMode"
548 );
549 }
550
551 #[test]
552 fn test_permission_mode_debug() {
553 let _ = format!("{:?}", PermissionMode::Default);
555 let _ = format!("{:?}", PermissionMode::AcceptEdits);
556 let _ = format!("{:?}", PermissionMode::Plan);
557 let _ = format!("{:?}", PermissionMode::BypassPermissions);
558 }
559
560 #[test]
563 fn test_auth_method_serde_roundtrip() {
564 for variant in [
565 AuthMethod::LoginWithGoogle,
566 AuthMethod::ApiKey,
567 AuthMethod::VertexAi,
568 ] {
569 let json = serde_json::to_string(&variant).expect("serialize");
570 let recovered: AuthMethod = serde_json::from_str(&json).expect("deserialize");
571 assert_eq!(variant, recovered, "serde roundtrip failed for {json}");
572 }
573 }
574
575 #[test]
576 fn test_auth_method_partial_eq() {
577 assert_eq!(AuthMethod::ApiKey, AuthMethod::ApiKey);
578 assert_ne!(AuthMethod::ApiKey, AuthMethod::VertexAi);
579 }
580
581 #[test]
584 fn test_default_timeouts() {
585 let config = ClientConfig::builder().prompt("test").build();
586 assert_eq!(
587 config.connect_timeout,
588 Some(std::time::Duration::from_secs(30)),
589 "connect_timeout default must be 30 s"
590 );
591 assert_eq!(
592 config.close_timeout,
593 Some(std::time::Duration::from_secs(10)),
594 "close_timeout default must be 10 s"
595 );
596 assert_eq!(
597 config.default_hook_timeout,
598 std::time::Duration::from_secs(30),
599 "hook timeout default must be 30 s"
600 );
601 assert_eq!(
602 config.version_check_timeout,
603 Some(std::time::Duration::from_secs(5)),
604 "version_check_timeout default must be 5 s"
605 );
606 assert!(
607 config.read_timeout.is_none(),
608 "read_timeout must default to None"
609 );
610 }
611
612 #[test]
615 fn test_to_cli_args_verbose() {
616 let config = ClientConfig::builder()
617 .prompt("test")
618 .verbose(true)
619 .build();
620 let args = config.to_cli_args();
621 assert!(
622 args.contains(&"--debug".to_string()),
623 "verbose=true must emit --debug"
624 );
625 }
626
627 #[test]
628 fn test_to_cli_args_verbose_false_omitted() {
629 let config = ClientConfig::builder().prompt("test").build();
630 let args = config.to_cli_args();
631 assert!(
632 !args.contains(&"--debug".to_string()),
633 "--debug must not appear when verbose=false"
634 );
635 }
636
637 #[test]
638 fn test_to_cli_args_max_turns() {
639 let config = ClientConfig::builder()
640 .prompt("test")
641 .max_turns(5_u32)
642 .build();
643 let args = config.to_cli_args();
644 let pos = args.iter().position(|a| a == "--max-turns").expect("--max-turns missing");
645 assert_eq!(args[pos + 1], "5");
646 }
647
648 #[test]
649 fn test_to_cli_args_system_prompt_text() {
650 let config = ClientConfig::builder()
651 .prompt("test")
652 .system_prompt(crate::config::SystemPrompt::Text("You are helpful.".to_string()))
653 .build();
654 let args = config.to_cli_args();
655 let pos = args.iter().position(|a| a == "--system-prompt").expect("--system-prompt missing");
656 assert_eq!(args[pos + 1], "You are helpful.");
657 }
658
659 #[test]
660 fn test_to_cli_args_system_prompt_file() {
661 let config = ClientConfig::builder()
662 .prompt("test")
663 .system_prompt(crate::config::SystemPrompt::File(
664 std::path::PathBuf::from("/etc/prompt.txt"),
665 ))
666 .build();
667 let args = config.to_cli_args();
668 let pos = args.iter().position(|a| a == "--system-prompt-file").expect("--system-prompt-file missing");
669 assert_eq!(args[pos + 1], "/etc/prompt.txt");
670 }
671
672 #[test]
673 fn test_to_cli_args_allowed_tools() {
674 let config = ClientConfig::builder()
675 .prompt("test")
676 .allowed_tools(vec!["read_file".to_string(), "write_file".to_string()])
677 .build();
678 let args = config.to_cli_args();
679 let pos = args.iter().position(|a| a == "--allowed-tools").expect("--allowed-tools missing");
680 assert_eq!(args[pos + 1], "read_file,write_file");
681 }
682
683 #[test]
684 fn test_to_cli_args_disallowed_tools() {
685 let config = ClientConfig::builder()
686 .prompt("test")
687 .disallowed_tools(vec!["shell".to_string()])
688 .build();
689 let args = config.to_cli_args();
690 let pos = args.iter().position(|a| a == "--disallowed-tools").expect("--disallowed-tools missing");
691 assert_eq!(args[pos + 1], "shell");
692 }
693
694 #[test]
695 fn test_to_cli_args_empty_tool_lists_omitted() {
696 let config = ClientConfig::builder().prompt("test").build();
698 let args = config.to_cli_args();
699 assert!(!args.contains(&"--allowed-tools".to_string()));
700 assert!(!args.contains(&"--disallowed-tools".to_string()));
701 }
702
703 #[test]
706 fn test_to_cli_args_always_has_acp_flag() {
707 let config = ClientConfig::builder()
709 .prompt("p")
710 .model("gemini-2.5-pro")
711 .sandbox(true)
712 .permission_mode(PermissionMode::BypassPermissions)
713 .build();
714 let args = config.to_cli_args();
715 assert_eq!(
716 args[0], "--experimental-acp",
717 "--experimental-acp must always be the first argument"
718 );
719 }
720}