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