Skip to main content

claude_cli_sdk/
config.rs

1//! Client configuration — `ClientConfig` with typed builder pattern.
2//!
3//! [`ClientConfig`] carries every option needed to spawn and control a Claude
4//! Code CLI session. It uses [`typed_builder`] so that required fields must be
5//! supplied at compile time while optional fields have sensible defaults.
6//!
7//! # Example
8//!
9//! ```rust
10//! use claude_cli_sdk::config::{ClientConfig, PermissionMode};
11//!
12//! let config = ClientConfig::builder()
13//!     .prompt("List files in /tmp")
14//!     .build();
15//! ```
16
17use std::collections::{BTreeMap, HashMap};
18use std::path::PathBuf;
19use std::sync::Arc;
20use std::time::Duration;
21
22use serde::{Deserialize, Serialize};
23use typed_builder::TypedBuilder;
24
25use tokio_util::sync::CancellationToken;
26
27use crate::callback::MessageCallback;
28use crate::hooks::HookMatcher;
29use crate::mcp::McpServers;
30use crate::permissions::CanUseToolCallback;
31
32// ── ClientConfig ─────────────────────────────────────────────────────────────
33
34/// Configuration for a Claude Code SDK client session.
35///
36/// Use [`ClientConfig::builder()`] to construct.
37#[derive(TypedBuilder)]
38pub struct ClientConfig {
39    // ── Required ─────────────────────────────────────────────────────────
40    /// The prompt text to send to Claude.
41    #[builder(setter(into))]
42    pub prompt: String,
43
44    // ── Session ──────────────────────────────────────────────────────────
45    /// Path to the Claude CLI binary. If `None`, auto-discovered via
46    /// [`find_cli()`](crate::discovery::find_cli).
47    #[builder(default, setter(strip_option))]
48    pub cli_path: Option<PathBuf>,
49
50    /// Working directory for the Claude process.
51    #[builder(default, setter(strip_option))]
52    pub cwd: Option<PathBuf>,
53
54    /// Model to use (e.g., `"claude-sonnet-4-5"`).
55    #[builder(default, setter(strip_option, into))]
56    pub model: Option<String>,
57
58    /// Fallback model if the primary is unavailable.
59    #[builder(default, setter(strip_option, into))]
60    pub fallback_model: Option<String>,
61
62    /// System prompt configuration.
63    #[builder(default, setter(strip_option))]
64    pub system_prompt: Option<SystemPrompt>,
65
66    // ── Limits ───────────────────────────────────────────────────────────
67    /// Maximum number of agentic turns before stopping.
68    #[builder(default, setter(strip_option))]
69    pub max_turns: Option<u32>,
70
71    /// Maximum USD budget for the session.
72    #[builder(default, setter(strip_option))]
73    pub max_budget_usd: Option<f64>,
74
75    /// Maximum thinking tokens per turn.
76    #[builder(default, setter(strip_option))]
77    pub max_thinking_tokens: Option<u32>,
78
79    // ── Tools ────────────────────────────────────────────────────────────
80    /// Explicitly allowed tool names.
81    #[builder(default)]
82    pub allowed_tools: Vec<String>,
83
84    /// Explicitly disallowed tool names.
85    #[builder(default)]
86    pub disallowed_tools: Vec<String>,
87
88    // ── Permissions ──────────────────────────────────────────────────────
89    /// Permission mode for the session.
90    #[builder(default)]
91    pub permission_mode: PermissionMode,
92
93    /// Callback invoked when the CLI requests tool use permission.
94    #[builder(default, setter(strip_option))]
95    pub can_use_tool: Option<CanUseToolCallback>,
96
97    // ── Session management ───────────────────────────────────────────────
98    /// Resume an existing session by ID.
99    #[builder(default, setter(strip_option, into))]
100    pub resume: Option<String>,
101
102    // ── Hooks ────────────────────────────────────────────────────────────
103    /// Lifecycle hooks to register for the session.
104    #[builder(default)]
105    pub hooks: Vec<HookMatcher>,
106
107    // ── MCP ──────────────────────────────────────────────────────────────
108    /// External MCP server configurations.
109    #[builder(default)]
110    pub mcp_servers: McpServers,
111
112    // ── Callback ─────────────────────────────────────────────────────────
113    /// Optional message callback for observe/filter.
114    #[builder(default, setter(strip_option))]
115    pub message_callback: Option<MessageCallback>,
116
117    // ── Environment ──────────────────────────────────────────────────────
118    /// Extra environment variables to pass to the CLI process.
119    #[builder(default)]
120    pub env: HashMap<String, String>,
121
122    /// Enable verbose (debug) output from the CLI.
123    #[builder(default)]
124    pub verbose: bool,
125
126    // ── Output ───────────────────────────────────────────────────────────
127    /// Output format. Defaults to `"stream-json"` for SDK use.
128    ///
129    /// `"stream-json"` enables realtime streaming — the CLI outputs NDJSON
130    /// lines as events happen. This is required for the SDK's init handshake
131    /// and multi-turn conversations.
132    ///
133    /// Other options: `"json"` (single result blob), `"text"` (human-readable).
134    #[builder(default_code = r#""stream-json".into()"#, setter(into))]
135    pub output_format: String,
136
137    // ── Extra CLI args ────────────────────────────────────────────────────
138    /// Arbitrary extra CLI flags to pass through to the Claude process.
139    ///
140    /// Keys are flag names (without the `--` prefix). Values are optional:
141    /// - `Some("value")` produces `--key value`
142    /// - `None` produces `--key` (boolean-style flag)
143    ///
144    /// Uses [`BTreeMap`] to guarantee deterministic CLI arg ordering across
145    /// invocations (important for reproducible test snapshots).
146    ///
147    /// # Example
148    ///
149    /// ```rust
150    /// use std::collections::BTreeMap;
151    /// use claude_cli_sdk::ClientConfig;
152    ///
153    /// let config = ClientConfig::builder()
154    ///     .prompt("hello")
155    ///     .extra_args(BTreeMap::from([
156    ///         ("replay-user-messages".into(), None),
157    ///         ("context-window".into(), Some("200000".into())),
158    ///     ]))
159    ///     .build();
160    /// ```
161    #[builder(default)]
162    pub extra_args: BTreeMap<String, Option<String>>,
163
164    // ── Timeouts ──────────────────────────────────────────────────────────
165    /// Deadline for process spawn + init message. `None` = wait forever.
166    ///
167    /// Default: `Some(30s)`.
168    #[builder(default_code = "Some(Duration::from_secs(30))")]
169    pub connect_timeout: Option<Duration>,
170
171    /// Deadline for `child.wait()` during close. On expiry the process is
172    /// killed. `None` = wait forever.
173    ///
174    /// Default: `Some(10s)`.
175    #[builder(default_code = "Some(Duration::from_secs(10))")]
176    pub close_timeout: Option<Duration>,
177
178    /// If `true`, close stdin immediately after spawning the CLI process.
179    ///
180    /// This is required when using `--print` mode (the default) because
181    /// the CLI expects stdin EOF before processing the prompt with
182    /// `--output-format stream-json`.
183    ///
184    /// Default: `true`.
185    #[builder(default_code = "true")]
186    pub end_input_on_connect: bool,
187
188    /// Per-message recv deadline. `None` = wait forever (default).
189    ///
190    /// This is for detecting hung/zombie processes, not for limiting turn
191    /// time — Claude turns can legitimately take minutes.
192    #[builder(default)]
193    pub read_timeout: Option<Duration>,
194
195    /// Fallback timeout for hook callbacks when [`HookMatcher::timeout`] is
196    /// `None`.
197    ///
198    /// Default: 30 seconds.
199    #[builder(default_code = "Duration::from_secs(30)")]
200    pub default_hook_timeout: Duration,
201
202    /// Deadline for the `--version` check in
203    /// [`check_cli_version()`](crate::discovery::check_cli_version).
204    /// `None` = wait forever.
205    ///
206    /// Default: `Some(5s)`.
207    #[builder(default_code = "Some(Duration::from_secs(5))")]
208    pub version_check_timeout: Option<Duration>,
209
210    /// Deadline for control requests (e.g., `set_model`, `set_permission_mode`).
211    /// If the CLI does not respond within this duration, the request fails with
212    /// [`Error::Timeout`](crate::Error::Timeout).
213    ///
214    /// Default: `30s`.
215    #[builder(default_code = "Duration::from_secs(30)")]
216    pub control_request_timeout: Duration,
217
218    // ── Cancellation ─────────────────────────────────────────────────────
219    /// Optional cancellation token for cooperative cancellation.
220    ///
221    /// When cancelled, in-flight streams yield [`Error::Cancelled`](crate::Error::Cancelled)
222    /// and the background reader task shuts down cleanly.
223    #[builder(default, setter(strip_option))]
224    pub cancellation_token: Option<CancellationToken>,
225
226    // ── Stderr ───────────────────────────────────────────────────────────
227    /// Optional callback for CLI stderr output.
228    #[builder(default, setter(strip_option))]
229    pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
230}
231
232impl std::fmt::Debug for ClientConfig {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.debug_struct("ClientConfig")
235            .field("prompt", &self.prompt)
236            .field("cli_path", &self.cli_path)
237            .field("cwd", &self.cwd)
238            .field("model", &self.model)
239            .field("permission_mode", &self.permission_mode)
240            .field("max_turns", &self.max_turns)
241            .field("max_budget_usd", &self.max_budget_usd)
242            .field("verbose", &self.verbose)
243            .field("output_format", &self.output_format)
244            .field("connect_timeout", &self.connect_timeout)
245            .field("close_timeout", &self.close_timeout)
246            .field("read_timeout", &self.read_timeout)
247            .field("default_hook_timeout", &self.default_hook_timeout)
248            .field("version_check_timeout", &self.version_check_timeout)
249            .field("control_request_timeout", &self.control_request_timeout)
250            .finish_non_exhaustive()
251    }
252}
253
254// ── PermissionMode ───────────────────────────────────────────────────────────
255
256/// Permission mode controlling how tool use requests are handled.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum PermissionMode {
260    /// Default: prompt the user for each tool use.
261    #[default]
262    Default,
263    /// Automatically accept file edits (still prompt for other tools).
264    AcceptEdits,
265    /// Plan-only mode: suggest changes but don't execute.
266    Plan,
267    /// Bypass all permission prompts (dangerous — use in CI only).
268    BypassPermissions,
269}
270
271impl PermissionMode {
272    /// Convert to the CLI flag value.
273    #[must_use]
274    pub fn as_cli_flag(&self) -> &'static str {
275        match self {
276            Self::Default => "default",
277            Self::AcceptEdits => "acceptEdits",
278            Self::Plan => "plan",
279            Self::BypassPermissions => "bypassPermissions",
280        }
281    }
282}
283
284// ── SystemPrompt ─────────────────────────────────────────────────────────────
285
286/// System prompt configuration for a Claude session.
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(tag = "type", rename_all = "snake_case")]
289pub enum SystemPrompt {
290    /// A raw text system prompt.
291    Text {
292        /// The system prompt text.
293        text: String,
294    },
295    /// A named preset system prompt.
296    Preset {
297        /// The preset kind (e.g., `"custom"`).
298        kind: String,
299        /// The preset name.
300        preset: String,
301        /// Additional text to append after the preset.
302        #[serde(default, skip_serializing_if = "Option::is_none")]
303        append: Option<String>,
304    },
305}
306
307impl SystemPrompt {
308    /// Create a text system prompt.
309    #[must_use]
310    pub fn text(s: impl Into<String>) -> Self {
311        Self::Text { text: s.into() }
312    }
313
314    /// Create a preset system prompt.
315    #[must_use]
316    pub fn preset(kind: impl Into<String>, preset: impl Into<String>) -> Self {
317        Self::Preset {
318            kind: kind.into(),
319            preset: preset.into(),
320            append: None,
321        }
322    }
323}
324
325// ── CLI arg generation ───────────────────────────────────────────────────────
326
327impl ClientConfig {
328    /// Validate the configuration, returning an error for invalid settings.
329    ///
330    /// Checks:
331    /// - If `cwd` is set, it must exist and be a directory.
332    ///
333    /// This is called automatically by [`Client::new()`](crate::Client::new).
334    pub fn validate(&self) -> crate::errors::Result<()> {
335        if let Some(ref cwd) = self.cwd {
336            if !cwd.exists() {
337                return Err(crate::errors::Error::Config(format!(
338                    "working directory does not exist: {}",
339                    cwd.display()
340                )));
341            }
342            if !cwd.is_dir() {
343                return Err(crate::errors::Error::Config(format!(
344                    "working directory is not a directory: {}",
345                    cwd.display()
346                )));
347            }
348        }
349        Ok(())
350    }
351
352    /// Build the CLI argument list for spawning the Claude process.
353    ///
354    /// This does NOT include the binary path itself — just the arguments.
355    #[must_use]
356    pub fn to_cli_args(&self) -> Vec<String> {
357        let mut args = vec![
358            "--output-format".into(),
359            self.output_format.clone(),
360            "--print".into(),
361            self.prompt.clone(),
362        ];
363
364        // stream-json output requires --verbose and benefits from
365        // --input-format stream-json for bidirectional streaming.
366        if self.output_format == "stream-json" && !self.verbose {
367            args.push("--verbose".into());
368        }
369
370        if let Some(model) = &self.model {
371            args.push("--model".into());
372            args.push(model.clone());
373        }
374
375        if let Some(fallback) = &self.fallback_model {
376            args.push("--fallback-model".into());
377            args.push(fallback.clone());
378        }
379
380        if let Some(turns) = self.max_turns {
381            args.push("--max-turns".into());
382            args.push(turns.to_string());
383        }
384
385        if let Some(budget) = self.max_budget_usd {
386            args.push("--max-budget-usd".into());
387            args.push(budget.to_string());
388        }
389
390        if let Some(thinking) = self.max_thinking_tokens {
391            args.push("--max-thinking-tokens".into());
392            args.push(thinking.to_string());
393        }
394
395        if self.permission_mode != PermissionMode::Default {
396            args.push("--permission-mode".into());
397            args.push(self.permission_mode.as_cli_flag().into());
398        }
399
400        if let Some(resume) = &self.resume {
401            args.push("--resume".into());
402            args.push(resume.clone());
403        }
404
405        if self.verbose {
406            args.push("--verbose".into());
407        }
408
409        for tool in &self.allowed_tools {
410            args.push("--allowedTools".into());
411            args.push(tool.clone());
412        }
413
414        for tool in &self.disallowed_tools {
415            args.push("--disallowedTools".into());
416            args.push(tool.clone());
417        }
418
419        if !self.mcp_servers.is_empty() {
420            let json = serde_json::to_string(&self.mcp_servers)
421                .expect("McpServers serialization is infallible");
422            args.push("--mcp-servers".into());
423            args.push(json);
424        }
425
426        if let Some(prompt) = &self.system_prompt {
427            match prompt {
428                SystemPrompt::Text { text } => {
429                    args.push("--system-prompt".into());
430                    args.push(text.clone());
431                }
432                SystemPrompt::Preset { preset, append, .. } => {
433                    args.push("--system-prompt-preset".into());
434                    args.push(preset.clone());
435                    if let Some(append_text) = append {
436                        args.push("--append-system-prompt".into());
437                        args.push(append_text.clone());
438                    }
439                }
440            }
441        }
442
443        // Extra args — appended last so they can override anything above.
444        for (key, value) in &self.extra_args {
445            args.push(format!("--{key}"));
446            if let Some(v) = value {
447                args.push(v.clone());
448            }
449        }
450
451        args
452    }
453
454    /// Build the environment variable map for the CLI process.
455    ///
456    /// Merges SDK defaults with `self.env` (user-supplied env takes precedence)
457    /// and any SDK-internal env vars.
458    ///
459    /// SDK defaults:
460    /// - `CLAUDE_CODE_SDK_ORIGINATOR=claude_cli_sdk_rs` — telemetry originator
461    /// - `CI=true` — headless mode (suppress interactive prompts)
462    /// - `TERM=dumb` — prevent ANSI escape sequences in output
463    #[must_use]
464    pub fn to_env(&self) -> HashMap<String, String> {
465        let mut env = HashMap::new();
466
467        // SDK defaults (overridable by self.env)
468        env.insert(
469            "CLAUDE_CODE_SDK_ORIGINATOR".into(),
470            "claude_cli_sdk_rs".into(),
471        );
472        env.insert("CI".into(), "true".into());
473        env.insert("TERM".into(), "dumb".into());
474
475        // User-supplied env overrides defaults
476        env.extend(self.env.clone());
477
478        // Control protocol (always set if needed, cannot be overridden)
479        if self.can_use_tool.is_some() || !self.hooks.is_empty() {
480            env.insert("CLAUDE_CODE_SDK_CONTROL_PORT".into(), "stdin".into());
481        }
482
483        env
484    }
485}
486
487// ── Tests ────────────────────────────────────────────────────────────────────
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn builder_minimal() {
495        let config = ClientConfig::builder().prompt("hello").build();
496        assert_eq!(config.prompt, "hello");
497        assert_eq!(config.output_format, "stream-json");
498        assert_eq!(config.permission_mode, PermissionMode::Default);
499    }
500
501    #[test]
502    fn builder_full() {
503        let config = ClientConfig::builder()
504            .prompt("test prompt")
505            .model("claude-opus-4-5")
506            .max_turns(5_u32)
507            .max_budget_usd(1.0_f64)
508            .permission_mode(PermissionMode::AcceptEdits)
509            .verbose(true)
510            .build();
511
512        assert_eq!(config.model.as_deref(), Some("claude-opus-4-5"));
513        assert_eq!(config.max_turns, Some(5));
514        assert_eq!(config.max_budget_usd, Some(1.0));
515        assert_eq!(config.permission_mode, PermissionMode::AcceptEdits);
516        assert!(config.verbose);
517    }
518
519    #[test]
520    fn to_cli_args_minimal() {
521        let config = ClientConfig::builder().prompt("hello").build();
522        let args = config.to_cli_args();
523        assert!(args.contains(&"--output-format".into()));
524        assert!(args.contains(&"stream-json".into()));
525        assert!(args.contains(&"--print".into()));
526        assert!(args.contains(&"hello".into()));
527    }
528
529    #[test]
530    fn to_cli_args_with_model_and_turns() {
531        let config = ClientConfig::builder()
532            .prompt("test")
533            .model("claude-sonnet-4-5")
534            .max_turns(10_u32)
535            .build();
536        let args = config.to_cli_args();
537        assert!(args.contains(&"--model".into()));
538        assert!(args.contains(&"claude-sonnet-4-5".into()));
539        assert!(args.contains(&"--max-turns".into()));
540        assert!(args.contains(&"10".into()));
541    }
542
543    #[test]
544    fn to_cli_args_with_permission_mode() {
545        let config = ClientConfig::builder()
546            .prompt("test")
547            .permission_mode(PermissionMode::BypassPermissions)
548            .build();
549        let args = config.to_cli_args();
550        assert!(args.contains(&"--permission-mode".into()));
551        assert!(args.contains(&"bypassPermissions".into()));
552    }
553
554    #[test]
555    fn to_cli_args_default_permission_mode_not_included() {
556        let config = ClientConfig::builder().prompt("test").build();
557        let args = config.to_cli_args();
558        assert!(!args.contains(&"--permission-mode".into()));
559    }
560
561    #[test]
562    fn to_cli_args_with_system_prompt_text() {
563        let config = ClientConfig::builder()
564            .prompt("test")
565            .system_prompt(SystemPrompt::text("You are a helpful assistant"))
566            .build();
567        let args = config.to_cli_args();
568        assert!(args.contains(&"--system-prompt".into()));
569        assert!(args.contains(&"You are a helpful assistant".into()));
570    }
571
572    #[test]
573    fn to_cli_args_with_mcp_servers() {
574        use crate::mcp::McpServerConfig;
575
576        let mut servers = McpServers::new();
577        servers.insert(
578            "fs".into(),
579            McpServerConfig::new("npx").with_args(["-y", "mcp-fs"]),
580        );
581
582        let config = ClientConfig::builder()
583            .prompt("test")
584            .mcp_servers(servers)
585            .build();
586        let args = config.to_cli_args();
587        assert!(args.contains(&"--mcp-servers".into()));
588    }
589
590    #[test]
591    fn to_env_without_callbacks() {
592        let config = ClientConfig::builder().prompt("test").build();
593        let env = config.to_env();
594        assert!(!env.contains_key("CLAUDE_CODE_SDK_CONTROL_PORT"));
595    }
596
597    #[test]
598    fn to_env_includes_originator_and_headless_defaults() {
599        let config = ClientConfig::builder().prompt("test").build();
600        let env = config.to_env();
601        assert_eq!(
602            env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
603            Some(&"claude_cli_sdk_rs".into())
604        );
605        assert_eq!(env.get("CI"), Some(&"true".into()));
606        assert_eq!(env.get("TERM"), Some(&"dumb".into()));
607    }
608
609    #[test]
610    fn to_env_user_env_overrides_defaults() {
611        let config = ClientConfig::builder()
612            .prompt("test")
613            .env(HashMap::from([
614                ("CI".into(), "false".into()),
615                ("TERM".into(), "xterm-256color".into()),
616            ]))
617            .build();
618        let env = config.to_env();
619        // User-supplied values should override SDK defaults.
620        assert_eq!(env.get("CI"), Some(&"false".into()));
621        assert_eq!(env.get("TERM"), Some(&"xterm-256color".into()));
622        // Originator should still be present (not overridden).
623        assert_eq!(
624            env.get("CLAUDE_CODE_SDK_ORIGINATOR"),
625            Some(&"claude_cli_sdk_rs".into())
626        );
627    }
628
629    #[test]
630    fn to_env_with_hooks_enables_control_port() {
631        use crate::hooks::{HookCallback, HookEvent, HookMatcher, HookOutput};
632        let cb: HookCallback = Arc::new(|_, _, _| Box::pin(async { HookOutput::allow() }));
633        let config = ClientConfig::builder()
634            .prompt("test")
635            .hooks(vec![HookMatcher::new(HookEvent::PreToolUse, cb)])
636            .build();
637        let env = config.to_env();
638        assert_eq!(
639            env.get("CLAUDE_CODE_SDK_CONTROL_PORT"),
640            Some(&"stdin".into())
641        );
642    }
643
644    #[test]
645    fn permission_mode_serde_round_trip() {
646        let modes = [
647            PermissionMode::Default,
648            PermissionMode::AcceptEdits,
649            PermissionMode::Plan,
650            PermissionMode::BypassPermissions,
651        ];
652        for mode in modes {
653            let json = serde_json::to_string(&mode).unwrap();
654            let decoded: PermissionMode = serde_json::from_str(&json).unwrap();
655            assert_eq!(mode, decoded);
656        }
657    }
658
659    #[test]
660    fn system_prompt_text_round_trip() {
661        let sp = SystemPrompt::text("You are helpful");
662        let json = serde_json::to_string(&sp).unwrap();
663        let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
664        assert_eq!(sp, decoded);
665    }
666
667    #[test]
668    fn system_prompt_preset_round_trip() {
669        let sp = SystemPrompt::Preset {
670            kind: "custom".into(),
671            preset: "coding".into(),
672            append: Some("Also be concise.".into()),
673        };
674        let json = serde_json::to_string(&sp).unwrap();
675        let decoded: SystemPrompt = serde_json::from_str(&json).unwrap();
676        assert_eq!(sp, decoded);
677    }
678
679    #[test]
680    fn debug_does_not_panic() {
681        let config = ClientConfig::builder().prompt("test").build();
682        let _ = format!("{config:?}");
683    }
684
685    #[test]
686    fn to_cli_args_with_allowed_tools() {
687        let config = ClientConfig::builder()
688            .prompt("test")
689            .allowed_tools(vec!["bash".into(), "read_file".into()])
690            .build();
691        let args = config.to_cli_args();
692        let allowed_count = args.iter().filter(|a| *a == "--allowedTools").count();
693        assert_eq!(allowed_count, 2);
694    }
695
696    #[test]
697    fn to_cli_args_with_extra_args_boolean_flag() {
698        let config = ClientConfig::builder()
699            .prompt("test")
700            .extra_args(BTreeMap::from([("replay-user-messages".into(), None)]))
701            .build();
702        let args = config.to_cli_args();
703        assert!(args.contains(&"--replay-user-messages".into()));
704    }
705
706    #[test]
707    fn to_cli_args_with_extra_args_valued_flag() {
708        let config = ClientConfig::builder()
709            .prompt("test")
710            .extra_args(BTreeMap::from([(
711                "context-window".into(),
712                Some("200000".into()),
713            )]))
714            .build();
715        let args = config.to_cli_args();
716        let idx = args.iter().position(|a| a == "--context-window").unwrap();
717        assert_eq!(args[idx + 1], "200000");
718    }
719
720    #[test]
721    fn builder_timeout_defaults() {
722        let config = ClientConfig::builder().prompt("test").build();
723        assert_eq!(config.connect_timeout, Some(Duration::from_secs(30)));
724        assert_eq!(config.close_timeout, Some(Duration::from_secs(10)));
725        assert_eq!(config.read_timeout, None);
726        assert_eq!(config.default_hook_timeout, Duration::from_secs(30));
727        assert_eq!(config.version_check_timeout, Some(Duration::from_secs(5)));
728    }
729
730    #[test]
731    fn builder_custom_timeouts() {
732        let config = ClientConfig::builder()
733            .prompt("test")
734            .connect_timeout(Some(Duration::from_secs(60)))
735            .close_timeout(Some(Duration::from_secs(20)))
736            .read_timeout(Some(Duration::from_secs(120)))
737            .default_hook_timeout(Duration::from_secs(10))
738            .version_check_timeout(Some(Duration::from_secs(15)))
739            .build();
740        assert_eq!(config.connect_timeout, Some(Duration::from_secs(60)));
741        assert_eq!(config.close_timeout, Some(Duration::from_secs(20)));
742        assert_eq!(config.read_timeout, Some(Duration::from_secs(120)));
743        assert_eq!(config.default_hook_timeout, Duration::from_secs(10));
744        assert_eq!(config.version_check_timeout, Some(Duration::from_secs(15)));
745    }
746
747    #[test]
748    fn builder_disable_connect_timeout() {
749        let config = ClientConfig::builder()
750            .prompt("test")
751            .connect_timeout(None::<Duration>)
752            .build();
753        assert_eq!(config.connect_timeout, None);
754    }
755
756    #[test]
757    fn builder_cancellation_token() {
758        let token = CancellationToken::new();
759        let config = ClientConfig::builder()
760            .prompt("test")
761            .cancellation_token(token.clone())
762            .build();
763        assert!(config.cancellation_token.is_some());
764    }
765
766    #[test]
767    fn builder_cancellation_token_default_is_none() {
768        let config = ClientConfig::builder().prompt("test").build();
769        assert!(config.cancellation_token.is_none());
770    }
771
772    #[test]
773    fn to_cli_args_with_resume() {
774        let config = ClientConfig::builder()
775            .prompt("test")
776            .resume("session-123")
777            .build();
778        let args = config.to_cli_args();
779        assert!(args.contains(&"--resume".into()));
780        assert!(args.contains(&"session-123".into()));
781    }
782}