use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use typed_builder::TypedBuilder;
use crate::hooks::HookMatcher;
use crate::mcp::McpServers;
use crate::permissions::CanUseToolCallback;
pub use crate::callback::MessageCallback;
#[derive(TypedBuilder)]
pub struct ClientConfig {
#[builder(setter(into))]
pub prompt: String,
#[builder(default, setter(strip_option))]
pub cli_path: Option<PathBuf>,
#[builder(default, setter(strip_option))]
pub cwd: Option<PathBuf>,
#[builder(default, setter(strip_option, into))]
pub model: Option<String>,
#[builder(default, setter(strip_option))]
pub system_prompt: Option<SystemPrompt>,
#[builder(default, setter(strip_option))]
pub max_turns: Option<u32>,
#[builder(default)]
pub allowed_tools: Vec<String>,
#[builder(default)]
pub disallowed_tools: Vec<String>,
#[builder(default)]
pub permission_mode: PermissionMode,
#[builder(default, setter(strip_option))]
pub can_use_tool: Option<CanUseToolCallback>,
#[builder(default, setter(strip_option, into))]
pub resume: Option<String>,
#[builder(default)]
pub hooks: Vec<HookMatcher>,
#[builder(default)]
pub mcp_servers: McpServers,
#[builder(default, setter(strip_option))]
pub message_callback: Option<MessageCallback>,
#[builder(default)]
pub env: HashMap<String, String>,
#[builder(default)]
pub verbose: bool,
#[builder(default, setter(strip_option))]
pub auth_method: Option<AuthMethod>,
#[builder(default)]
pub sandbox: bool,
#[builder(default)]
pub extra_args: BTreeMap<String, Option<String>>,
#[builder(default_code = "Some(Duration::from_secs(30))")]
pub connect_timeout: Option<Duration>,
#[builder(default_code = "Some(Duration::from_secs(10))")]
pub close_timeout: Option<Duration>,
#[builder(default)]
pub read_timeout: Option<Duration>,
#[builder(default_code = "Duration::from_secs(30)")]
pub default_hook_timeout: Duration,
#[builder(default_code = "Some(Duration::from_secs(5))")]
pub version_check_timeout: Option<Duration>,
#[builder(default, setter(strip_option))]
pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
}
impl ClientConfig {
pub fn to_cli_args(&self) -> Vec<String> {
let mut args = vec!["--experimental-acp".to_string()];
if let Some(model) = &self.model {
args.push("--model".to_string());
args.push(model.clone());
}
if self.sandbox {
args.push("--sandbox".to_string());
}
match self.permission_mode {
PermissionMode::Default => {}
PermissionMode::AcceptEdits => {
args.push("--approval-mode".to_string());
args.push("auto_edit".to_string());
}
PermissionMode::Plan => {
args.push("--approval-mode".to_string());
args.push("plan".to_string());
}
PermissionMode::BypassPermissions => {
args.push("--yolo".to_string());
}
}
if self.verbose {
args.push("--debug".to_string());
}
if let Some(turns) = self.max_turns {
args.push("--max-turns".to_string());
args.push(turns.to_string());
}
if let Some(sp) = &self.system_prompt {
match sp {
SystemPrompt::Text(text) => {
args.push("--system-prompt".to_string());
args.push(text.clone());
}
SystemPrompt::File(path) => {
args.push("--system-prompt-file".to_string());
args.push(path.to_string_lossy().to_string());
}
}
}
if !self.allowed_tools.is_empty() {
args.push("--allowed-tools".to_string());
args.push(self.allowed_tools.join(","));
}
if !self.disallowed_tools.is_empty() {
args.push("--disallowed-tools".to_string());
args.push(self.disallowed_tools.join(","));
}
for (key, value) in &self.extra_args {
args.push(format!("--{key}"));
if let Some(v) = value {
args.push(v.clone());
}
}
args
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthMethod {
LoginWithGoogle,
ApiKey,
VertexAi,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PermissionMode {
#[default]
Default,
AcceptEdits,
Plan,
BypassPermissions,
}
#[derive(Debug, Clone)]
pub enum SystemPrompt {
Text(String),
File(PathBuf),
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{AuthMethod, ClientConfig, PermissionMode};
#[test]
fn test_config_builder_minimal() {
let config = ClientConfig::builder().prompt("test").build();
assert_eq!(config.prompt, "test");
assert!(config.cli_path.is_none());
assert!(config.model.is_none());
assert!(config.cwd.is_none());
assert!(config.allowed_tools.is_empty());
assert!(config.disallowed_tools.is_empty());
assert!(config.hooks.is_empty());
assert!(config.mcp_servers.is_empty());
assert!(!config.sandbox);
assert!(!config.verbose);
}
#[test]
fn test_config_builder_with_model() {
let config = ClientConfig::builder()
.prompt("hello")
.model("gemini-2.5-pro")
.build();
assert_eq!(config.model.as_deref(), Some("gemini-2.5-pro"));
}
#[test]
fn test_to_cli_args_default() {
let config = ClientConfig::builder().prompt("test").build();
let args = config.to_cli_args();
assert_eq!(args, vec!["--experimental-acp"]);
}
#[test]
fn test_to_cli_args_with_model() {
let config = ClientConfig::builder()
.prompt("test")
.model("gemini-2.5-flash")
.build();
let args = config.to_cli_args();
assert!(
args.contains(&"--model".to_string()),
"expected --model flag"
);
assert!(
args.contains(&"gemini-2.5-flash".to_string()),
"expected model value"
);
let model_pos = args.iter().position(|a| a == "--model").unwrap();
assert_eq!(args[model_pos + 1], "gemini-2.5-flash");
}
#[test]
fn test_to_cli_args_accept_edits() {
let config = ClientConfig::builder()
.prompt("test")
.permission_mode(PermissionMode::AcceptEdits)
.build();
let args = config.to_cli_args();
assert!(
args.contains(&"--approval-mode".to_string()),
"expected --approval-mode"
);
assert!(
args.contains(&"auto_edit".to_string()),
"expected auto_edit value"
);
let pos = args.iter().position(|a| a == "--approval-mode").unwrap();
assert_eq!(args[pos + 1], "auto_edit");
}
#[test]
fn test_to_cli_args_plan_mode() {
let config = ClientConfig::builder()
.prompt("test")
.permission_mode(PermissionMode::Plan)
.build();
let args = config.to_cli_args();
let pos = args.iter().position(|a| a == "--approval-mode").unwrap();
assert_eq!(args[pos + 1], "plan");
}
#[test]
fn test_to_cli_args_bypass() {
let config = ClientConfig::builder()
.prompt("test")
.permission_mode(PermissionMode::BypassPermissions)
.build();
let args = config.to_cli_args();
assert!(
args.contains(&"--yolo".to_string()),
"BypassPermissions must map to --yolo"
);
assert!(
!args.contains(&"--approval-mode".to_string()),
"--approval-mode must not appear alongside --yolo"
);
}
#[test]
fn test_to_cli_args_sandbox() {
let config = ClientConfig::builder()
.prompt("test")
.sandbox(true)
.build();
let args = config.to_cli_args();
assert!(
args.contains(&"--sandbox".to_string()),
"sandbox=true must emit --sandbox"
);
}
#[test]
fn test_to_cli_args_sandbox_false_omitted() {
let config = ClientConfig::builder().prompt("test").build();
let args = config.to_cli_args();
assert!(
!args.contains(&"--sandbox".to_string()),
"--sandbox must not appear when sandbox=false"
);
}
#[test]
fn test_to_cli_args_extra() {
let mut extra = BTreeMap::new();
extra.insert("temperature".to_string(), Some("0.7".to_string()));
extra.insert("top-p".to_string(), None);
let config = ClientConfig::builder()
.prompt("test")
.extra_args(extra)
.build();
let args = config.to_cli_args();
assert!(
args.contains(&"--temperature".to_string()),
"extra key must become --<key>"
);
assert!(
args.contains(&"0.7".to_string()),
"extra value must appear as a separate arg"
);
assert!(
args.contains(&"--top-p".to_string()),
"flag-only extra arg must appear without a value"
);
}
#[test]
fn test_to_cli_args_extra_btreemap_ordering() {
let mut extra = BTreeMap::new();
extra.insert("zzz".to_string(), Some("last".to_string()));
extra.insert("aaa".to_string(), Some("first".to_string()));
let config = ClientConfig::builder()
.prompt("test")
.extra_args(extra)
.build();
let args = config.to_cli_args();
let aaa_pos = args.iter().position(|a| a == "--aaa").unwrap();
let zzz_pos = args.iter().position(|a| a == "--zzz").unwrap();
assert!(aaa_pos < zzz_pos, "--aaa must precede --zzz (BTreeMap order)");
}
#[test]
fn test_permission_mode_default() {
assert_eq!(
PermissionMode::default(),
PermissionMode::Default,
"Default must be the zero-value for PermissionMode"
);
}
#[test]
fn test_permission_mode_debug() {
let _ = format!("{:?}", PermissionMode::Default);
let _ = format!("{:?}", PermissionMode::AcceptEdits);
let _ = format!("{:?}", PermissionMode::Plan);
let _ = format!("{:?}", PermissionMode::BypassPermissions);
}
#[test]
fn test_auth_method_serde_roundtrip() {
for variant in [
AuthMethod::LoginWithGoogle,
AuthMethod::ApiKey,
AuthMethod::VertexAi,
] {
let json = serde_json::to_string(&variant).expect("serialize");
let recovered: AuthMethod = serde_json::from_str(&json).expect("deserialize");
assert_eq!(variant, recovered, "serde roundtrip failed for {json}");
}
}
#[test]
fn test_auth_method_partial_eq() {
assert_eq!(AuthMethod::ApiKey, AuthMethod::ApiKey);
assert_ne!(AuthMethod::ApiKey, AuthMethod::VertexAi);
}
#[test]
fn test_default_timeouts() {
let config = ClientConfig::builder().prompt("test").build();
assert_eq!(
config.connect_timeout,
Some(std::time::Duration::from_secs(30)),
"connect_timeout default must be 30 s"
);
assert_eq!(
config.close_timeout,
Some(std::time::Duration::from_secs(10)),
"close_timeout default must be 10 s"
);
assert_eq!(
config.default_hook_timeout,
std::time::Duration::from_secs(30),
"hook timeout default must be 30 s"
);
assert_eq!(
config.version_check_timeout,
Some(std::time::Duration::from_secs(5)),
"version_check_timeout default must be 5 s"
);
assert!(
config.read_timeout.is_none(),
"read_timeout must default to None"
);
}
#[test]
fn test_to_cli_args_verbose() {
let config = ClientConfig::builder()
.prompt("test")
.verbose(true)
.build();
let args = config.to_cli_args();
assert!(
args.contains(&"--debug".to_string()),
"verbose=true must emit --debug"
);
}
#[test]
fn test_to_cli_args_verbose_false_omitted() {
let config = ClientConfig::builder().prompt("test").build();
let args = config.to_cli_args();
assert!(
!args.contains(&"--debug".to_string()),
"--debug must not appear when verbose=false"
);
}
#[test]
fn test_to_cli_args_max_turns() {
let config = ClientConfig::builder()
.prompt("test")
.max_turns(5_u32)
.build();
let args = config.to_cli_args();
let pos = args.iter().position(|a| a == "--max-turns").expect("--max-turns missing");
assert_eq!(args[pos + 1], "5");
}
#[test]
fn test_to_cli_args_system_prompt_text() {
let config = ClientConfig::builder()
.prompt("test")
.system_prompt(crate::config::SystemPrompt::Text("You are helpful.".to_string()))
.build();
let args = config.to_cli_args();
let pos = args.iter().position(|a| a == "--system-prompt").expect("--system-prompt missing");
assert_eq!(args[pos + 1], "You are helpful.");
}
#[test]
fn test_to_cli_args_system_prompt_file() {
let config = ClientConfig::builder()
.prompt("test")
.system_prompt(crate::config::SystemPrompt::File(
std::path::PathBuf::from("/etc/prompt.txt"),
))
.build();
let args = config.to_cli_args();
let pos = args.iter().position(|a| a == "--system-prompt-file").expect("--system-prompt-file missing");
assert_eq!(args[pos + 1], "/etc/prompt.txt");
}
#[test]
fn test_to_cli_args_allowed_tools() {
let config = ClientConfig::builder()
.prompt("test")
.allowed_tools(vec!["read_file".to_string(), "write_file".to_string()])
.build();
let args = config.to_cli_args();
let pos = args.iter().position(|a| a == "--allowed-tools").expect("--allowed-tools missing");
assert_eq!(args[pos + 1], "read_file,write_file");
}
#[test]
fn test_to_cli_args_disallowed_tools() {
let config = ClientConfig::builder()
.prompt("test")
.disallowed_tools(vec!["shell".to_string()])
.build();
let args = config.to_cli_args();
let pos = args.iter().position(|a| a == "--disallowed-tools").expect("--disallowed-tools missing");
assert_eq!(args[pos + 1], "shell");
}
#[test]
fn test_to_cli_args_empty_tool_lists_omitted() {
let config = ClientConfig::builder().prompt("test").build();
let args = config.to_cli_args();
assert!(!args.contains(&"--allowed-tools".to_string()));
assert!(!args.contains(&"--disallowed-tools".to_string()));
}
#[test]
fn test_to_cli_args_always_has_acp_flag() {
let config = ClientConfig::builder()
.prompt("p")
.model("gemini-2.5-pro")
.sandbox(true)
.permission_mode(PermissionMode::BypassPermissions)
.build();
let args = config.to_cli_args();
assert_eq!(
args[0], "--experimental-acp",
"--experimental-acp must always be the first argument"
);
}
}