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