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