Skip to main content

claude_code/
config.rs

1use std::time::Duration;
2
3/// Conditional tracing macro for warnings.
4macro_rules! trace_warn {
5    ($($arg:tt)*) => {
6        #[cfg(feature = "tracing")]
7        tracing::warn!($($arg)*);
8    };
9}
10
11/// Default CLI command name.
12const DEFAULT_CLI_PATH: &str = "claude";
13
14/// Preset defines the base set of CLI flags injected before
15/// builder attributes and `extra_args`.
16///
17/// # Examples
18///
19/// ```
20/// use claude_code::Preset;
21///
22/// // Reusable custom preset
23/// let my_preset = Preset::Custom(vec![
24///     "--print".into(),
25///     "--no-session-persistence".into(),
26/// ]);
27/// ```
28#[derive(Debug, Clone, Default, PartialEq)]
29#[non_exhaustive]
30pub enum Preset {
31    /// All context-minimization defaults (current behavior).
32    ///
33    /// Injects: `--print`, `--no-session-persistence`, `--strict-mcp-config`,
34    /// `--disable-slash-commands`, `--setting-sources ""`, `--mcp-config '{}'`,
35    /// `--tools ""`, `--system-prompt ""`.
36    #[default]
37    Normal,
38
39    /// Only flags required for the library's parsing to work.
40    ///
41    /// Injects: `--print`. Format flags (`--output-format`, `--verbose`)
42    /// are added by `to_args()` / `to_stream_args()` regardless of preset.
43    Minimal,
44
45    /// No auto-injected flags. User has full control via builder attributes
46    /// and `extra_args`.
47    Bare,
48
49    /// User-defined base args. These are injected before builder attributes
50    /// and `extra_args`.
51    Custom(Vec<String>),
52}
53
54/// Configuration options for Claude CLI execution.
55#[derive(Debug, Clone, Default)]
56#[non_exhaustive]
57pub struct ClaudeConfig {
58    /// Preset that determines the base set of auto-injected CLI flags.
59    /// Defaults to [`Preset::Normal`].
60    pub preset: Preset,
61    /// Path to the `claude` CLI binary. Defaults to `"claude"` (resolved via `PATH`).
62    ///
63    /// Use this to specify an absolute path when the binary is not on `PATH`,
64    /// or to select a specific version of the CLI.
65    ///
66    /// # Security
67    ///
68    /// No validation is performed on this value. `tokio::process::Command::new()`
69    /// invokes `execvp` directly without a shell, so shell injection is not possible.
70    pub cli_path: Option<String>,
71    /// Model to use (`--model`).
72    pub model: Option<String>,
73    /// System prompt (`--system-prompt`). Defaults to empty string when `None`.
74    pub system_prompt: Option<String>,
75    /// Append to the default system prompt (`--append-system-prompt`).
76    pub append_system_prompt: Option<String>,
77    /// Maximum number of turns (`--max-turns`).
78    pub max_turns: Option<u32>,
79    /// Timeout duration. No timeout when `None`. Library-only; not a CLI flag.
80    pub timeout: Option<Duration>,
81    /// Idle timeout for streams. If no event arrives within this duration,
82    /// the stream yields [`ClaudeError::Timeout`](crate::ClaudeError::Timeout)
83    /// and terminates. Library-only; not a CLI flag.
84    pub stream_idle_timeout: Option<Duration>,
85    /// Fallback model when default is overloaded (`--fallback-model`).
86    pub fallback_model: Option<String>,
87    /// Effort level (`--effort`). Use [`effort`] constants for known values.
88    pub effort: Option<String>,
89    /// Maximum dollar amount for API calls (`--max-budget-usd`).
90    pub max_budget_usd: Option<f64>,
91    /// Tools to allow (`--allowedTools`).
92    pub allowed_tools: Vec<String>,
93    /// Tools to deny (`--disallowedTools`).
94    pub disallowed_tools: Vec<String>,
95    /// Built-in tool set override (`--tools`). Defaults to `""` (none) when `None`.
96    pub tools: Option<String>,
97    /// MCP server configs (`--mcp-config`). Defaults to `{"mcpServers":{}}` when empty.
98    pub mcp_config: Vec<String>,
99    /// Setting sources to load (`--setting-sources`). Defaults to `""` (none) when `None`.
100    pub setting_sources: Option<String>,
101    /// Path to settings file or JSON string (`--settings`).
102    pub settings: Option<String>,
103    /// JSON Schema for structured output (`--json-schema`).
104    pub json_schema: Option<String>,
105    /// Include partial message chunks in stream output (`--include-partial-messages`).
106    pub include_partial_messages: Option<bool>,
107    /// Include hook events in stream output (`--include-hook-events`).
108    pub include_hook_events: Option<bool>,
109    /// Permission mode (`--permission-mode`). Use [`permission_mode`] constants for known values.
110    pub permission_mode: Option<String>,
111    /// Bypass all permission checks (`--dangerously-skip-permissions`).
112    pub dangerously_skip_permissions: Option<bool>,
113    /// Additional directories for tool access (`--add-dir`).
114    pub add_dir: Vec<String>,
115    /// File resources to download at startup (`--file`).
116    pub file: Vec<String>,
117    /// Resume a conversation by session ID (`--resume`).
118    pub resume: Option<String>,
119    /// Use a specific session ID (`--session-id`).
120    pub session_id: Option<String>,
121    /// Minimal mode (`--bare`).
122    pub bare: Option<bool>,
123    /// Disable session persistence (`--no-session-persistence`). Enabled by default.
124    pub no_session_persistence: Option<bool>,
125    /// Disable all skills (`--disable-slash-commands`). Enabled by default.
126    pub disable_slash_commands: Option<bool>,
127    /// Only use MCP servers from `--mcp-config` (`--strict-mcp-config`). Enabled by default.
128    pub strict_mcp_config: Option<bool>,
129    /// Arbitrary CLI arguments for forward compatibility.
130    ///
131    /// Appended before the prompt. Use typed fields when available;
132    /// duplicating a typed field here may cause unpredictable CLI behavior.
133    pub extra_args: Vec<String>,
134}
135
136impl ClaudeConfig {
137    /// Returns the CLI binary path, defaulting to `"claude"`.
138    #[must_use]
139    pub fn cli_path_or_default(&self) -> &str {
140        self.cli_path.as_deref().unwrap_or(DEFAULT_CLI_PATH)
141    }
142
143    /// Returns a new builder.
144    #[must_use]
145    pub fn builder() -> ClaudeConfigBuilder {
146        ClaudeConfigBuilder::default()
147    }
148
149    /// Creates a builder pre-filled with this configuration's values.
150    #[must_use]
151    pub fn to_builder(&self) -> ClaudeConfigBuilder {
152        ClaudeConfigBuilder {
153            preset: self.preset.clone(),
154            cli_path: self.cli_path.clone(),
155            model: self.model.clone(),
156            system_prompt: self.system_prompt.clone(),
157            append_system_prompt: self.append_system_prompt.clone(),
158            max_turns: self.max_turns,
159            timeout: self.timeout,
160            stream_idle_timeout: self.stream_idle_timeout,
161            fallback_model: self.fallback_model.clone(),
162            effort: self.effort.clone(),
163            max_budget_usd: self.max_budget_usd,
164            allowed_tools: self.allowed_tools.clone(),
165            disallowed_tools: self.disallowed_tools.clone(),
166            tools: self.tools.clone(),
167            mcp_config: self.mcp_config.clone(),
168            setting_sources: self.setting_sources.clone(),
169            settings: self.settings.clone(),
170            json_schema: self.json_schema.clone(),
171            include_partial_messages: self.include_partial_messages,
172            include_hook_events: self.include_hook_events,
173            permission_mode: self.permission_mode.clone(),
174            dangerously_skip_permissions: self.dangerously_skip_permissions,
175            add_dir: self.add_dir.clone(),
176            file: self.file.clone(),
177            resume: self.resume.clone(),
178            session_id: self.session_id.clone(),
179            bare: self.bare,
180            no_session_persistence: self.no_session_persistence,
181            disable_slash_commands: self.disable_slash_commands,
182            strict_mcp_config: self.strict_mcp_config,
183            extra_args: self.extra_args.clone(),
184        }
185    }
186
187    /// Builds common CLI arguments shared by JSON and stream-json modes.
188    ///
189    /// Argument generation priority:
190    /// 1. Preset base args
191    /// 2. Builder attributes
192    /// 3. `--output-format` / `--verbose` (added by `to_args()` / `to_stream_args()`)
193    /// 4. `extra_args`
194    /// 5. prompt (added by `to_args()` / `to_stream_args()`)
195    fn base_args(&self) -> Vec<String> {
196        let mut args = self.preset_args();
197        self.push_builder_attrs(&mut args);
198        args
199    }
200
201    /// Returns the base flags determined by the preset.
202    fn preset_args(&self) -> Vec<String> {
203        match &self.preset {
204            Preset::Normal => self.normal_preset_args(),
205            Preset::Minimal => self.minimal_preset_args(),
206            Preset::Bare => Vec::new(),
207            Preset::Custom(custom_args) => self.filtered_custom_args(custom_args),
208        }
209    }
210
211    /// Normal preset: all context-minimization defaults.
212    fn normal_preset_args(&self) -> Vec<String> {
213        let mut args = vec!["--print".into()];
214
215        // no_session_persistence: None → enabled, Some(false) → disabled
216        if self.no_session_persistence != Some(false) {
217            args.push("--no-session-persistence".into());
218        }
219
220        // setting_sources: None → "" (minimal), Some(val) → val
221        args.push("--setting-sources".into());
222        args.push(self.setting_sources.clone().unwrap_or_default());
223
224        // strict_mcp_config: None → enabled, Some(false) → disabled
225        if self.strict_mcp_config != Some(false) {
226            args.push("--strict-mcp-config".into());
227        }
228
229        // mcp_config: [] → '{"mcpServers":{}}' (minimal), non-empty → user values
230        if self.mcp_config.is_empty() {
231            args.push("--mcp-config".into());
232            args.push(r#"{"mcpServers":{}}"#.into());
233        } else {
234            for cfg in &self.mcp_config {
235                args.push("--mcp-config".into());
236                args.push(cfg.clone());
237            }
238        }
239
240        // tools: None → "" (minimal), Some(val) → val
241        args.push("--tools".into());
242        args.push(self.tools.clone().unwrap_or_default());
243
244        // disable_slash_commands: None → enabled, Some(false) → disabled
245        if self.disable_slash_commands != Some(false) {
246            args.push("--disable-slash-commands".into());
247        }
248
249        args
250    }
251
252    /// Minimal preset: only `--print`.
253    fn minimal_preset_args(&self) -> Vec<String> {
254        vec!["--print".into()]
255    }
256
257    /// Filters a custom preset's args by removing flags that builder attributes
258    /// explicitly disabled (e.g., `no_session_persistence == Some(false)`).
259    fn filtered_custom_args(&self, custom_args: &[String]) -> Vec<String> {
260        custom_args
261            .iter()
262            .filter(|arg| {
263                let s = arg.as_str();
264                !((self.no_session_persistence == Some(false) && s == "--no-session-persistence")
265                    || (self.strict_mcp_config == Some(false) && s == "--strict-mcp-config")
266                    || (self.disable_slash_commands == Some(false)
267                        && s == "--disable-slash-commands"))
268            })
269            .cloned()
270            .collect()
271    }
272
273    /// Appends builder-attribute flags to the args vector.
274    ///
275    /// For Normal preset, context-minimization flags are already in the preset
276    /// args with their defaults. For other presets, these flags are only added
277    /// when explicitly set by the user.
278    fn push_builder_attrs(&self, args: &mut Vec<String>) {
279        let is_normal = matches!(self.preset, Preset::Normal);
280
281        // --- Context-minimization flags (preset-aware) ---
282        // For Normal: handled in normal_preset_args() with defaults.
283        // For others: only when explicitly set.
284        if !is_normal {
285            if self.no_session_persistence == Some(true) {
286                args.push("--no-session-persistence".into());
287            }
288
289            if let Some(ref val) = self.setting_sources {
290                args.push("--setting-sources".into());
291                args.push(val.clone());
292            }
293
294            if self.strict_mcp_config == Some(true) {
295                args.push("--strict-mcp-config".into());
296            }
297
298            if !self.mcp_config.is_empty() {
299                for cfg in &self.mcp_config {
300                    args.push("--mcp-config".into());
301                    args.push(cfg.clone());
302                }
303            }
304
305            if let Some(ref val) = self.tools {
306                args.push("--tools".into());
307                args.push(val.clone());
308            }
309
310            if self.disable_slash_commands == Some(true) {
311                args.push("--disable-slash-commands".into());
312            }
313        }
314
315        // system_prompt: Normal defaults to "" (empty); other presets only when set
316        if is_normal {
317            args.push("--system-prompt".into());
318            args.push(self.system_prompt.clone().unwrap_or_default());
319        } else if let Some(ref val) = self.system_prompt {
320            args.push("--system-prompt".into());
321            args.push(val.clone());
322        }
323
324        if let Some(ref val) = self.append_system_prompt {
325            args.push("--append-system-prompt".into());
326            args.push(val.clone());
327        }
328
329        if let Some(ref val) = self.model {
330            args.push("--model".into());
331            args.push(val.clone());
332        }
333
334        if let Some(ref val) = self.fallback_model {
335            args.push("--fallback-model".into());
336            args.push(val.clone());
337        }
338
339        if let Some(ref val) = self.effort {
340            args.push("--effort".into());
341            args.push(val.clone());
342        }
343
344        if let Some(max_turns) = self.max_turns {
345            args.push("--max-turns".into());
346            args.push(max_turns.to_string());
347        }
348
349        if let Some(budget) = self.max_budget_usd {
350            args.push("--max-budget-usd".into());
351            args.push(budget.to_string());
352        }
353
354        if !self.allowed_tools.is_empty() {
355            args.push("--allowedTools".into());
356            args.extend(self.allowed_tools.iter().cloned());
357        }
358
359        if !self.disallowed_tools.is_empty() {
360            args.push("--disallowedTools".into());
361            args.extend(self.disallowed_tools.iter().cloned());
362        }
363
364        if let Some(ref val) = self.settings {
365            args.push("--settings".into());
366            args.push(val.clone());
367        }
368
369        if let Some(ref val) = self.json_schema {
370            args.push("--json-schema".into());
371            args.push(val.clone());
372        }
373
374        if self.include_hook_events == Some(true) {
375            args.push("--include-hook-events".into());
376        }
377
378        if let Some(ref val) = self.permission_mode {
379            args.push("--permission-mode".into());
380            args.push(val.clone());
381        }
382
383        if self.dangerously_skip_permissions == Some(true) {
384            args.push("--dangerously-skip-permissions".into());
385        }
386
387        if !self.add_dir.is_empty() {
388            args.push("--add-dir".into());
389            args.extend(self.add_dir.iter().cloned());
390        }
391
392        if !self.file.is_empty() {
393            args.push("--file".into());
394            args.extend(self.file.iter().cloned());
395        }
396
397        if let Some(ref val) = self.resume {
398            args.push("--resume".into());
399            args.push(val.clone());
400        }
401
402        if let Some(ref val) = self.session_id {
403            args.push("--session-id".into());
404            args.push(val.clone());
405        }
406
407        if self.bare == Some(true) {
408            args.push("--bare".into());
409        }
410    }
411
412    /// Builds command-line arguments for JSON output mode.
413    ///
414    /// Includes fixed options such as `--print --output-format json`.
415    #[must_use]
416    pub fn to_args(&self, prompt: &str) -> Vec<String> {
417        let mut args = self.base_args();
418        args.push("--output-format".into());
419        args.push("json".into());
420        args.extend(self.extra_args.iter().cloned());
421        self.warn_if_no_print(&args);
422        args.push(prompt.into());
423        args
424    }
425
426    /// Builds command-line arguments for stream-json output mode.
427    ///
428    /// Includes `--verbose` (required for stream-json) and optionally
429    /// `--include-partial-messages`.
430    #[must_use]
431    pub fn to_stream_args(&self, prompt: &str) -> Vec<String> {
432        let mut args = self.base_args();
433        args.push("--output-format".into());
434        args.push("stream-json".into());
435        args.push("--verbose".into());
436        if self.include_partial_messages == Some(true) {
437            args.push("--include-partial-messages".into());
438        }
439        args.extend(self.extra_args.iter().cloned());
440        self.warn_if_no_print(&args);
441        args.push(prompt.into());
442        args
443    }
444
445    /// Emits a tracing warning if `--print` / `-p` is not in the final args.
446    fn warn_if_no_print(&self, args: &[String]) {
447        if !args.iter().any(|a| a == "--print" || a == "-p") {
448            trace_warn!(
449                "args do not contain --print; the CLI may start in interactive mode and hang"
450            );
451        }
452    }
453}
454
455/// Builder for [`ClaudeConfig`].
456#[derive(Debug, Clone, Default)]
457pub struct ClaudeConfigBuilder {
458    preset: Preset,
459    cli_path: Option<String>,
460    model: Option<String>,
461    system_prompt: Option<String>,
462    append_system_prompt: Option<String>,
463    max_turns: Option<u32>,
464    timeout: Option<Duration>,
465    stream_idle_timeout: Option<Duration>,
466    fallback_model: Option<String>,
467    effort: Option<String>,
468    max_budget_usd: Option<f64>,
469    allowed_tools: Vec<String>,
470    disallowed_tools: Vec<String>,
471    tools: Option<String>,
472    mcp_config: Vec<String>,
473    setting_sources: Option<String>,
474    settings: Option<String>,
475    json_schema: Option<String>,
476    include_partial_messages: Option<bool>,
477    include_hook_events: Option<bool>,
478    permission_mode: Option<String>,
479    dangerously_skip_permissions: Option<bool>,
480    add_dir: Vec<String>,
481    file: Vec<String>,
482    resume: Option<String>,
483    session_id: Option<String>,
484    bare: Option<bool>,
485    no_session_persistence: Option<bool>,
486    disable_slash_commands: Option<bool>,
487    strict_mcp_config: Option<bool>,
488    extra_args: Vec<String>,
489}
490
491impl ClaudeConfigBuilder {
492    /// Sets the preset that determines which CLI flags are auto-injected.
493    ///
494    /// Defaults to [`Preset::Normal`].
495    #[must_use]
496    pub fn preset(mut self, preset: Preset) -> Self {
497        self.preset = preset;
498        self
499    }
500
501    /// Sets the path to the `claude` CLI binary.
502    ///
503    /// When not set, `"claude"` is resolved via `PATH`.
504    #[must_use]
505    pub fn cli_path(mut self, path: impl Into<String>) -> Self {
506        self.cli_path = Some(path.into());
507        self
508    }
509
510    /// Sets the model.
511    #[must_use]
512    pub fn model(mut self, model: impl Into<String>) -> Self {
513        self.model = Some(model.into());
514        self
515    }
516
517    /// Sets the system prompt.
518    #[must_use]
519    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
520        self.system_prompt = Some(prompt.into());
521        self
522    }
523
524    /// Sets the append system prompt.
525    #[must_use]
526    pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
527        self.append_system_prompt = Some(prompt.into());
528        self
529    }
530
531    /// Sets the maximum number of turns.
532    #[must_use]
533    pub fn max_turns(mut self, max_turns: u32) -> Self {
534        self.max_turns = Some(max_turns);
535        self
536    }
537
538    /// Sets the timeout duration.
539    #[must_use]
540    pub fn timeout(mut self, timeout: Duration) -> Self {
541        self.timeout = Some(timeout);
542        self
543    }
544
545    /// Sets the idle timeout for streams.
546    ///
547    /// If no event arrives within this duration, the stream yields
548    /// [`ClaudeError::Timeout`](crate::ClaudeError::Timeout) and terminates.
549    #[must_use]
550    pub fn stream_idle_timeout(mut self, timeout: Duration) -> Self {
551        self.stream_idle_timeout = Some(timeout);
552        self
553    }
554
555    /// Sets the fallback model.
556    #[must_use]
557    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
558        self.fallback_model = Some(model.into());
559        self
560    }
561
562    /// Sets the effort level. See [`effort`] constants for known values.
563    #[must_use]
564    pub fn effort(mut self, effort: impl Into<String>) -> Self {
565        self.effort = Some(effort.into());
566        self
567    }
568
569    /// Sets the maximum budget in USD.
570    #[must_use]
571    pub fn max_budget_usd(mut self, budget: f64) -> Self {
572        self.max_budget_usd = Some(budget);
573        self
574    }
575
576    /// Sets allowed tools (replaces any previous values).
577    #[must_use]
578    pub fn allowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
579        self.allowed_tools = tools.into_iter().map(Into::into).collect();
580        self
581    }
582
583    /// Adds a single allowed tool.
584    #[must_use]
585    pub fn add_allowed_tool(mut self, tool: impl Into<String>) -> Self {
586        self.allowed_tools.push(tool.into());
587        self
588    }
589
590    /// Sets disallowed tools (replaces any previous values).
591    #[must_use]
592    pub fn disallowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
593        self.disallowed_tools = tools.into_iter().map(Into::into).collect();
594        self
595    }
596
597    /// Adds a single disallowed tool.
598    #[must_use]
599    pub fn add_disallowed_tool(mut self, tool: impl Into<String>) -> Self {
600        self.disallowed_tools.push(tool.into());
601        self
602    }
603
604    /// Sets the built-in tool set. `""` disables all, `"default"` enables all.
605    #[must_use]
606    pub fn tools(mut self, tools: impl Into<String>) -> Self {
607        self.tools = Some(tools.into());
608        self
609    }
610
611    /// Sets MCP server configs (replaces any previous values).
612    #[must_use]
613    pub fn mcp_configs(mut self, configs: impl IntoIterator<Item = impl Into<String>>) -> Self {
614        self.mcp_config = configs.into_iter().map(Into::into).collect();
615        self
616    }
617
618    /// Adds a single MCP server config.
619    #[must_use]
620    pub fn add_mcp_config(mut self, config: impl Into<String>) -> Self {
621        self.mcp_config.push(config.into());
622        self
623    }
624
625    /// Sets the setting sources to load.
626    #[must_use]
627    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
628        self.setting_sources = Some(sources.into());
629        self
630    }
631
632    /// Sets the path to a settings file or JSON string.
633    #[must_use]
634    pub fn settings(mut self, settings: impl Into<String>) -> Self {
635        self.settings = Some(settings.into());
636        self
637    }
638
639    /// Sets the JSON Schema for structured output.
640    #[must_use]
641    pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
642        self.json_schema = Some(schema.into());
643        self
644    }
645
646    /// Enables or disables partial message chunks in stream output.
647    #[must_use]
648    pub fn include_partial_messages(mut self, enabled: bool) -> Self {
649        self.include_partial_messages = Some(enabled);
650        self
651    }
652
653    /// Enables or disables hook events in stream output.
654    #[must_use]
655    pub fn include_hook_events(mut self, enabled: bool) -> Self {
656        self.include_hook_events = Some(enabled);
657        self
658    }
659
660    /// Sets the permission mode. See [`permission_mode`] constants for known values.
661    #[must_use]
662    pub fn permission_mode(mut self, mode: impl Into<String>) -> Self {
663        self.permission_mode = Some(mode.into());
664        self
665    }
666
667    /// Enables or disables bypassing all permission checks.
668    #[must_use]
669    pub fn dangerously_skip_permissions(mut self, enabled: bool) -> Self {
670        self.dangerously_skip_permissions = Some(enabled);
671        self
672    }
673
674    /// Sets additional directories (replaces any previous values).
675    #[must_use]
676    pub fn add_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<String>>) -> Self {
677        self.add_dir = dirs.into_iter().map(Into::into).collect();
678        self
679    }
680
681    /// Adds a single additional directory.
682    #[must_use]
683    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
684        self.add_dir.push(dir.into());
685        self
686    }
687
688    /// Sets file resources (replaces any previous values).
689    #[must_use]
690    pub fn files(mut self, files: impl IntoIterator<Item = impl Into<String>>) -> Self {
691        self.file = files.into_iter().map(Into::into).collect();
692        self
693    }
694
695    /// Adds a single file resource.
696    #[must_use]
697    pub fn file(mut self, file: impl Into<String>) -> Self {
698        self.file.push(file.into());
699        self
700    }
701
702    /// Sets the session ID to resume.
703    #[must_use]
704    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
705        self.resume = Some(session_id.into());
706        self
707    }
708
709    /// Sets a specific session ID.
710    #[must_use]
711    pub fn session_id(mut self, id: impl Into<String>) -> Self {
712        self.session_id = Some(id.into());
713        self
714    }
715
716    /// Enables or disables bare/minimal mode.
717    #[must_use]
718    pub fn bare(mut self, enabled: bool) -> Self {
719        self.bare = Some(enabled);
720        self
721    }
722
723    /// Enables or disables session persistence.
724    /// Enabled by default; set to `false` to allow session persistence.
725    #[must_use]
726    pub fn no_session_persistence(mut self, enabled: bool) -> Self {
727        self.no_session_persistence = Some(enabled);
728        self
729    }
730
731    /// Enables or disables slash commands.
732    /// Disabled by default; set to `false` to enable slash commands.
733    #[must_use]
734    pub fn disable_slash_commands(mut self, enabled: bool) -> Self {
735        self.disable_slash_commands = Some(enabled);
736        self
737    }
738
739    /// Enables or disables strict MCP config mode.
740    /// Enabled by default; set to `false` to allow non-`--mcp-config` MCP servers.
741    #[must_use]
742    pub fn strict_mcp_config(mut self, enabled: bool) -> Self {
743        self.strict_mcp_config = Some(enabled);
744        self
745    }
746
747    /// Sets arbitrary extra CLI arguments (replaces any previous values).
748    ///
749    /// These are appended before the prompt. Use typed fields when available;
750    /// duplicating a typed field here may cause unpredictable CLI behavior.
751    #[must_use]
752    pub fn extra_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
753        self.extra_args = args.into_iter().map(Into::into).collect();
754        self
755    }
756
757    /// Adds a single extra CLI argument.
758    #[must_use]
759    pub fn add_extra_arg(mut self, arg: impl Into<String>) -> Self {
760        self.extra_args.push(arg.into());
761        self
762    }
763
764    /// Builds the [`ClaudeConfig`].
765    #[must_use]
766    pub fn build(self) -> ClaudeConfig {
767        ClaudeConfig {
768            preset: self.preset,
769            cli_path: self.cli_path,
770            model: self.model,
771            system_prompt: self.system_prompt,
772            append_system_prompt: self.append_system_prompt,
773            max_turns: self.max_turns,
774            timeout: self.timeout,
775            stream_idle_timeout: self.stream_idle_timeout,
776            fallback_model: self.fallback_model,
777            effort: self.effort,
778            max_budget_usd: self.max_budget_usd,
779            allowed_tools: self.allowed_tools,
780            disallowed_tools: self.disallowed_tools,
781            tools: self.tools,
782            mcp_config: self.mcp_config,
783            setting_sources: self.setting_sources,
784            settings: self.settings,
785            json_schema: self.json_schema,
786            include_partial_messages: self.include_partial_messages,
787            include_hook_events: self.include_hook_events,
788            permission_mode: self.permission_mode,
789            dangerously_skip_permissions: self.dangerously_skip_permissions,
790            add_dir: self.add_dir,
791            file: self.file,
792            resume: self.resume,
793            session_id: self.session_id,
794            bare: self.bare,
795            no_session_persistence: self.no_session_persistence,
796            disable_slash_commands: self.disable_slash_commands,
797            strict_mcp_config: self.strict_mcp_config,
798            extra_args: self.extra_args,
799        }
800    }
801}
802
803/// Known values for the `--effort` CLI option.
804pub mod effort {
805    /// Low effort.
806    pub const LOW: &str = "low";
807    /// Medium effort.
808    pub const MEDIUM: &str = "medium";
809    /// High effort.
810    pub const HIGH: &str = "high";
811    /// Maximum effort.
812    pub const MAX: &str = "max";
813}
814
815/// Known values for the `--permission-mode` CLI option.
816pub mod permission_mode {
817    /// Default permission mode.
818    pub const DEFAULT: &str = "default";
819    /// Accept edits without confirmation.
820    pub const ACCEPT_EDITS: &str = "acceptEdits";
821    /// Automatic permission handling.
822    pub const AUTO: &str = "auto";
823    /// Bypass all permission checks.
824    pub const BYPASS_PERMISSIONS: &str = "bypassPermissions";
825    /// Never ask for permission.
826    pub const DONT_ASK: &str = "dontAsk";
827    /// Plan mode.
828    pub const PLAN: &str = "plan";
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn default_config() {
837        let config = ClaudeConfig::default();
838        assert!(config.cli_path.is_none());
839        assert!(config.model.is_none());
840        assert!(config.system_prompt.is_none());
841        assert!(config.max_turns.is_none());
842        assert!(config.timeout.is_none());
843    }
844
845    #[test]
846    fn cli_path_or_default_returns_claude_when_none() {
847        let config = ClaudeConfig::default();
848        assert_eq!(config.cli_path_or_default(), "claude");
849    }
850
851    #[test]
852    fn cli_path_or_default_returns_custom_path() {
853        let config = ClaudeConfig::builder()
854            .cli_path("/usr/local/bin/claude-v2")
855            .build();
856        assert_eq!(config.cli_path_or_default(), "/usr/local/bin/claude-v2");
857    }
858
859    #[test]
860    fn builder_sets_cli_path() {
861        let config = ClaudeConfig::builder().cli_path("/opt/claude").build();
862        assert_eq!(config.cli_path.as_deref(), Some("/opt/claude"));
863    }
864
865    #[test]
866    fn builder_sets_stream_idle_timeout() {
867        let config = ClaudeConfig::builder()
868            .stream_idle_timeout(Duration::from_secs(60))
869            .build();
870        assert_eq!(config.stream_idle_timeout, Some(Duration::from_secs(60)));
871    }
872
873    #[test]
874    fn default_stream_idle_timeout_is_none() {
875        let config = ClaudeConfig::default();
876        assert!(config.stream_idle_timeout.is_none());
877    }
878
879    #[test]
880    fn builder_sets_all_fields() {
881        let config = ClaudeConfig::builder()
882            .model("haiku")
883            .system_prompt("You are helpful")
884            .max_turns(3)
885            .timeout(Duration::from_secs(30))
886            .build();
887
888        assert_eq!(config.model.as_deref(), Some("haiku"));
889        assert_eq!(config.system_prompt.as_deref(), Some("You are helpful"));
890        assert_eq!(config.max_turns, Some(3));
891        assert_eq!(config.timeout, Some(Duration::from_secs(30)));
892    }
893
894    #[test]
895    fn to_args_minimal() {
896        let config = ClaudeConfig::default();
897        let args = config.to_args("hello");
898
899        assert!(args.contains(&"--print".to_string()));
900        assert!(args.contains(&"json".to_string()));
901        assert!(args.contains(&"--no-session-persistence".to_string()));
902        assert!(args.contains(&"--disable-slash-commands".to_string()));
903        assert!(args.contains(&"--strict-mcp-config".to_string()));
904        // system-prompt defaults to empty string
905        let sp_idx = args.iter().position(|a| a == "--system-prompt").unwrap();
906        assert_eq!(args[sp_idx + 1], "");
907        // model, max-turns should not be present
908        assert!(!args.contains(&"--model".to_string()));
909        assert!(!args.contains(&"--max-turns".to_string()));
910        // prompt should be the last argument
911        assert_eq!(args.last().unwrap(), "hello");
912    }
913
914    #[test]
915    fn to_args_with_options() {
916        let config = ClaudeConfig::builder()
917            .model("haiku")
918            .system_prompt("Be concise")
919            .max_turns(5)
920            .build();
921        let args = config.to_args("test prompt");
922
923        let model_idx = args.iter().position(|a| a == "--model").unwrap();
924        assert_eq!(args[model_idx + 1], "haiku");
925
926        let sp_idx = args.iter().position(|a| a == "--system-prompt").unwrap();
927        assert_eq!(args[sp_idx + 1], "Be concise");
928
929        let mt_idx = args.iter().position(|a| a == "--max-turns").unwrap();
930        assert_eq!(args[mt_idx + 1], "5");
931
932        assert_eq!(args.last().unwrap(), "test prompt");
933    }
934
935    #[test]
936    fn to_stream_args_minimal() {
937        let config = ClaudeConfig::default();
938        let args = config.to_stream_args("hello");
939
940        assert!(args.contains(&"--print".to_string()));
941        assert!(args.contains(&"stream-json".to_string()));
942        assert!(args.contains(&"--verbose".to_string()));
943        assert!(!args.contains(&"json".to_string()));
944        assert!(!args.contains(&"--include-partial-messages".to_string()));
945        assert_eq!(args.last().unwrap(), "hello");
946    }
947
948    #[test]
949    fn to_stream_args_with_partial_messages() {
950        let config = ClaudeConfig::builder()
951            .include_partial_messages(true)
952            .build();
953        let args = config.to_stream_args("hello");
954
955        assert!(args.contains(&"--include-partial-messages".to_string()));
956    }
957
958    #[test]
959    fn builder_sets_include_partial_messages() {
960        let config = ClaudeConfig::builder()
961            .include_partial_messages(true)
962            .build();
963        assert_eq!(config.include_partial_messages, Some(true));
964    }
965
966    #[test]
967    fn all_new_fields_in_builder() {
968        let config = ClaudeConfig::builder()
969            .append_system_prompt("extra context")
970            .fallback_model("haiku")
971            .effort("high")
972            .max_budget_usd(1.0)
973            .allowed_tools(["Bash", "Edit"])
974            .disallowed_tools(["Write"])
975            .tools("Bash,Edit")
976            .mcp_configs(["config.json"])
977            .setting_sources("user,project")
978            .settings("settings.json")
979            .json_schema(r#"{"type":"object"}"#)
980            .include_hook_events(true)
981            .permission_mode("auto")
982            .dangerously_skip_permissions(true)
983            .add_dirs(["/path/a"])
984            .file("spec:file.txt")
985            .resume("session-123")
986            .session_id("uuid-456")
987            .bare(true)
988            .no_session_persistence(false)
989            .disable_slash_commands(false)
990            .strict_mcp_config(false)
991            .extra_args(["--custom", "val"])
992            .build();
993
994        assert_eq!(
995            config.append_system_prompt.as_deref(),
996            Some("extra context")
997        );
998        assert_eq!(config.fallback_model.as_deref(), Some("haiku"));
999        assert_eq!(config.effort.as_deref(), Some("high"));
1000        assert_eq!(config.max_budget_usd, Some(1.0));
1001        assert_eq!(config.allowed_tools, vec!["Bash", "Edit"]);
1002        assert_eq!(config.disallowed_tools, vec!["Write"]);
1003        assert_eq!(config.tools.as_deref(), Some("Bash,Edit"));
1004        assert_eq!(config.mcp_config, vec!["config.json"]);
1005        assert_eq!(config.setting_sources.as_deref(), Some("user,project"));
1006        assert_eq!(config.settings.as_deref(), Some("settings.json"));
1007        assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
1008        assert_eq!(config.include_hook_events, Some(true));
1009        assert_eq!(config.permission_mode.as_deref(), Some("auto"));
1010        assert_eq!(config.dangerously_skip_permissions, Some(true));
1011        assert_eq!(config.add_dir, vec!["/path/a"]);
1012        assert_eq!(config.file, vec!["spec:file.txt"]);
1013        assert_eq!(config.resume.as_deref(), Some("session-123"));
1014        assert_eq!(config.session_id.as_deref(), Some("uuid-456"));
1015        assert_eq!(config.bare, Some(true));
1016        assert_eq!(config.no_session_persistence, Some(false));
1017        assert_eq!(config.disable_slash_commands, Some(false));
1018        assert_eq!(config.strict_mcp_config, Some(false));
1019        assert_eq!(config.extra_args, vec!["--custom", "val"]);
1020    }
1021
1022    #[test]
1023    fn default_uses_minimal_context() {
1024        let config = ClaudeConfig::default();
1025        let args = config.to_args("test");
1026
1027        assert!(args.contains(&"--no-session-persistence".to_string()));
1028        assert!(args.contains(&"--strict-mcp-config".to_string()));
1029        assert!(args.contains(&"--disable-slash-commands".to_string()));
1030
1031        let ss_idx = args.iter().position(|a| a == "--setting-sources").unwrap();
1032        assert_eq!(args[ss_idx + 1], "");
1033
1034        let mcp_idx = args.iter().position(|a| a == "--mcp-config").unwrap();
1035        assert_eq!(args[mcp_idx + 1], r#"{"mcpServers":{}}"#);
1036
1037        let tools_idx = args.iter().position(|a| a == "--tools").unwrap();
1038        assert_eq!(args[tools_idx + 1], "");
1039    }
1040
1041    #[test]
1042    fn override_no_session_persistence_false() {
1043        let config = ClaudeConfig::builder()
1044            .no_session_persistence(false)
1045            .build();
1046        let args = config.to_args("test");
1047        assert!(!args.contains(&"--no-session-persistence".to_string()));
1048    }
1049
1050    #[test]
1051    fn override_strict_mcp_config_false() {
1052        let config = ClaudeConfig::builder().strict_mcp_config(false).build();
1053        let args = config.to_args("test");
1054        assert!(!args.contains(&"--strict-mcp-config".to_string()));
1055    }
1056
1057    #[test]
1058    fn override_disable_slash_commands_false() {
1059        let config = ClaudeConfig::builder()
1060            .disable_slash_commands(false)
1061            .build();
1062        let args = config.to_args("test");
1063        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1064    }
1065
1066    #[test]
1067    fn override_tools() {
1068        let config = ClaudeConfig::builder().tools("Bash,Edit").build();
1069        let args = config.to_args("test");
1070        let idx = args.iter().position(|a| a == "--tools").unwrap();
1071        assert_eq!(args[idx + 1], "Bash,Edit");
1072    }
1073
1074    #[test]
1075    fn override_setting_sources() {
1076        let config = ClaudeConfig::builder()
1077            .setting_sources("user,project")
1078            .build();
1079        let args = config.to_args("test");
1080        let idx = args.iter().position(|a| a == "--setting-sources").unwrap();
1081        assert_eq!(args[idx + 1], "user,project");
1082    }
1083
1084    #[test]
1085    fn override_mcp_config() {
1086        let config = ClaudeConfig::builder()
1087            .mcp_configs(["path/config.json"])
1088            .build();
1089        let args = config.to_args("test");
1090        let idx = args.iter().position(|a| a == "--mcp-config").unwrap();
1091        assert_eq!(args[idx + 1], "path/config.json");
1092        assert!(!args.contains(&r#"{"mcpServers":{}}"#.to_string()));
1093    }
1094
1095    #[test]
1096    fn effort_with_constant() {
1097        let config = ClaudeConfig::builder().effort(effort::HIGH).build();
1098        let args = config.to_args("test");
1099        let idx = args.iter().position(|a| a == "--effort").unwrap();
1100        assert_eq!(args[idx + 1], "high");
1101    }
1102
1103    #[test]
1104    fn effort_with_custom_string() {
1105        let config = ClaudeConfig::builder().effort("ultra").build();
1106        let args = config.to_args("test");
1107        let idx = args.iter().position(|a| a == "--effort").unwrap();
1108        assert_eq!(args[idx + 1], "ultra");
1109    }
1110
1111    #[test]
1112    fn allowed_tools_multiple() {
1113        let config = ClaudeConfig::builder()
1114            .allowed_tools(["Bash(git:*)", "Edit", "Read"])
1115            .build();
1116        let args = config.to_args("test");
1117        let idx = args.iter().position(|a| a == "--allowedTools").unwrap();
1118        assert_eq!(args[idx + 1], "Bash(git:*)");
1119        assert_eq!(args[idx + 2], "Edit");
1120        assert_eq!(args[idx + 3], "Read");
1121    }
1122
1123    #[test]
1124    fn bare_flag() {
1125        let config = ClaudeConfig::builder().bare(true).build();
1126        let args = config.to_args("test");
1127        assert!(args.contains(&"--bare".to_string()));
1128    }
1129
1130    #[test]
1131    fn dangerously_skip_permissions_flag() {
1132        let config = ClaudeConfig::builder()
1133            .dangerously_skip_permissions(true)
1134            .build();
1135        let args = config.to_args("test");
1136        assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
1137    }
1138
1139    #[test]
1140    fn resume_session() {
1141        let config = ClaudeConfig::builder().resume("session-abc").build();
1142        let args = config.to_args("test");
1143        let idx = args.iter().position(|a| a == "--resume").unwrap();
1144        assert_eq!(args[idx + 1], "session-abc");
1145    }
1146
1147    #[test]
1148    fn session_id_field() {
1149        let config = ClaudeConfig::builder()
1150            .session_id("550e8400-e29b-41d4-a716-446655440000")
1151            .build();
1152        let args = config.to_args("test");
1153        let idx = args.iter().position(|a| a == "--session-id").unwrap();
1154        assert_eq!(args[idx + 1], "550e8400-e29b-41d4-a716-446655440000");
1155    }
1156
1157    #[test]
1158    fn json_schema_field() {
1159        let schema = r#"{"type":"object","properties":{"name":{"type":"string"}}}"#;
1160        let config = ClaudeConfig::builder().json_schema(schema).build();
1161        let args = config.to_args("test");
1162        let idx = args.iter().position(|a| a == "--json-schema").unwrap();
1163        assert_eq!(args[idx + 1], schema);
1164    }
1165
1166    #[test]
1167    fn add_dir_multiple() {
1168        let config = ClaudeConfig::builder()
1169            .add_dirs(["/path/a", "/path/b"])
1170            .build();
1171        let args = config.to_args("test");
1172        let idx = args.iter().position(|a| a == "--add-dir").unwrap();
1173        assert_eq!(args[idx + 1], "/path/a");
1174        assert_eq!(args[idx + 2], "/path/b");
1175    }
1176
1177    #[test]
1178    fn file_multiple() {
1179        let config = ClaudeConfig::builder()
1180            .files(["file_abc:doc.txt", "file_def:img.png"])
1181            .build();
1182        let args = config.to_args("test");
1183        let idx = args.iter().position(|a| a == "--file").unwrap();
1184        assert_eq!(args[idx + 1], "file_abc:doc.txt");
1185        assert_eq!(args[idx + 2], "file_def:img.png");
1186    }
1187
1188    #[test]
1189    fn extra_args_before_prompt() {
1190        let config = ClaudeConfig::builder()
1191            .extra_args(["--custom-flag", "value"])
1192            .build();
1193        let args = config.to_args("my prompt");
1194        let custom_idx = args.iter().position(|a| a == "--custom-flag").unwrap();
1195        let prompt_idx = args.iter().position(|a| a == "my prompt").unwrap();
1196        assert!(custom_idx < prompt_idx);
1197        assert_eq!(args[custom_idx + 1], "value");
1198    }
1199
1200    #[test]
1201    fn extra_args_with_typed_fields() {
1202        let config = ClaudeConfig::builder()
1203            .model("sonnet")
1204            .extra_args(["--custom", "val"])
1205            .build();
1206        let args = config.to_args("test");
1207        assert!(args.contains(&"--model".to_string()));
1208        assert!(args.contains(&"sonnet".to_string()));
1209        assert!(args.contains(&"--custom".to_string()));
1210        assert!(args.contains(&"val".to_string()));
1211    }
1212
1213    #[test]
1214    fn disallowed_tools_multiple() {
1215        let config = ClaudeConfig::builder()
1216            .disallowed_tools(["Write", "Bash"])
1217            .build();
1218        let args = config.to_args("test");
1219        let idx = args.iter().position(|a| a == "--disallowedTools").unwrap();
1220        assert_eq!(args[idx + 1], "Write");
1221        assert_eq!(args[idx + 2], "Bash");
1222    }
1223
1224    #[test]
1225    fn to_builder_round_trip_fields() {
1226        let original = ClaudeConfig::builder()
1227            .cli_path("/custom/claude")
1228            .model("haiku")
1229            .system_prompt("test")
1230            .max_turns(5)
1231            .timeout(Duration::from_secs(30))
1232            .stream_idle_timeout(Duration::from_secs(45))
1233            .no_session_persistence(false)
1234            .resume("session-123")
1235            .build();
1236
1237        let rebuilt = original.to_builder().build();
1238
1239        assert_eq!(rebuilt.cli_path, original.cli_path);
1240        assert_eq!(rebuilt.model, original.model);
1241        assert_eq!(rebuilt.system_prompt, original.system_prompt);
1242        assert_eq!(rebuilt.max_turns, original.max_turns);
1243        assert_eq!(rebuilt.timeout, original.timeout);
1244        assert_eq!(rebuilt.stream_idle_timeout, original.stream_idle_timeout);
1245        assert_eq!(
1246            rebuilt.no_session_persistence,
1247            original.no_session_persistence
1248        );
1249        assert_eq!(rebuilt.resume, original.resume);
1250    }
1251
1252    #[test]
1253    fn to_builder_round_trip_args() {
1254        let config = ClaudeConfig::builder()
1255            .model("haiku")
1256            .max_turns(3)
1257            .effort("high")
1258            .allowed_tools(["Bash", "Read"])
1259            .no_session_persistence(false)
1260            .build();
1261
1262        let rebuilt = config.to_builder().build();
1263        assert_eq!(config.to_args("hi"), rebuilt.to_args("hi"));
1264    }
1265
1266    // --- Preset tests ---
1267
1268    #[test]
1269    fn default_preset_is_normal() {
1270        let config = ClaudeConfig::default();
1271        assert_eq!(config.preset, Preset::Normal);
1272    }
1273
1274    #[test]
1275    fn builder_default_preset_is_normal() {
1276        let config = ClaudeConfig::builder().build();
1277        assert_eq!(config.preset, Preset::Normal);
1278    }
1279
1280    #[test]
1281    fn explicit_normal_preset_matches_default_to_args() {
1282        let default_config = ClaudeConfig::default();
1283        let explicit_config = ClaudeConfig::builder().preset(Preset::Normal).build();
1284        assert_eq!(
1285            default_config.to_args("test"),
1286            explicit_config.to_args("test")
1287        );
1288    }
1289
1290    #[test]
1291    fn explicit_normal_preset_matches_default_to_stream_args() {
1292        let default_config = ClaudeConfig::default();
1293        let explicit_config = ClaudeConfig::builder().preset(Preset::Normal).build();
1294        assert_eq!(
1295            default_config.to_stream_args("test"),
1296            explicit_config.to_stream_args("test")
1297        );
1298    }
1299
1300    #[test]
1301    fn to_builder_preserves_preset() {
1302        let config = ClaudeConfig::builder()
1303            .preset(Preset::Minimal)
1304            .model("haiku")
1305            .build();
1306        let rebuilt = config.to_builder().build();
1307        assert_eq!(rebuilt.preset, Preset::Minimal);
1308    }
1309
1310    #[test]
1311    fn minimal_preset_to_args() {
1312        let config = ClaudeConfig::builder().preset(Preset::Minimal).build();
1313        let args = config.to_args("test");
1314
1315        assert!(args.contains(&"--print".to_string()));
1316        assert!(args.contains(&"--output-format".to_string()));
1317        assert!(args.contains(&"json".to_string()));
1318        assert_eq!(args.last().unwrap(), "test");
1319
1320        // Context-minimization flags must NOT be present
1321        assert!(!args.contains(&"--no-session-persistence".to_string()));
1322        assert!(!args.contains(&"--strict-mcp-config".to_string()));
1323        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1324        assert!(!args.contains(&"--setting-sources".to_string()));
1325        assert!(!args.contains(&"--mcp-config".to_string()));
1326        assert!(!args.contains(&"--tools".to_string()));
1327        assert!(!args.contains(&"--system-prompt".to_string()));
1328    }
1329
1330    #[test]
1331    fn minimal_preset_to_stream_args() {
1332        let config = ClaudeConfig::builder().preset(Preset::Minimal).build();
1333        let args = config.to_stream_args("test");
1334
1335        assert!(args.contains(&"--print".to_string()));
1336        assert!(args.contains(&"--output-format".to_string()));
1337        assert!(args.contains(&"stream-json".to_string()));
1338        assert!(args.contains(&"--verbose".to_string()));
1339        assert_eq!(args.last().unwrap(), "test");
1340
1341        assert!(!args.contains(&"--no-session-persistence".to_string()));
1342        assert!(!args.contains(&"--system-prompt".to_string()));
1343    }
1344
1345    #[test]
1346    fn minimal_preset_with_builder_add() {
1347        let config = ClaudeConfig::builder()
1348            .preset(Preset::Minimal)
1349            .no_session_persistence(true)
1350            .model("haiku")
1351            .build();
1352        let args = config.to_args("test");
1353
1354        assert!(args.contains(&"--print".to_string()));
1355        assert!(args.contains(&"--no-session-persistence".to_string()));
1356        assert!(args.contains(&"--model".to_string()));
1357    }
1358
1359    #[test]
1360    fn minimal_preset_with_system_prompt() {
1361        let config = ClaudeConfig::builder()
1362            .preset(Preset::Minimal)
1363            .system_prompt("Be helpful")
1364            .build();
1365        let args = config.to_args("test");
1366
1367        let idx = args.iter().position(|a| a == "--system-prompt").unwrap();
1368        assert_eq!(args[idx + 1], "Be helpful");
1369    }
1370
1371    #[test]
1372    fn bare_preset_to_args() {
1373        let config = ClaudeConfig::builder().preset(Preset::Bare).build();
1374        let args = config.to_args("test");
1375
1376        // Only --output-format json and prompt
1377        assert!(args.contains(&"--output-format".to_string()));
1378        assert!(args.contains(&"json".to_string()));
1379        assert_eq!(args.last().unwrap(), "test");
1380
1381        // No preset flags at all
1382        assert!(!args.contains(&"--print".to_string()));
1383        assert!(!args.contains(&"--no-session-persistence".to_string()));
1384        assert!(!args.contains(&"--strict-mcp-config".to_string()));
1385        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1386        assert!(!args.contains(&"--setting-sources".to_string()));
1387        assert!(!args.contains(&"--mcp-config".to_string()));
1388        assert!(!args.contains(&"--tools".to_string()));
1389        assert!(!args.contains(&"--system-prompt".to_string()));
1390    }
1391
1392    #[test]
1393    fn bare_preset_to_stream_args() {
1394        let config = ClaudeConfig::builder().preset(Preset::Bare).build();
1395        let args = config.to_stream_args("test");
1396
1397        assert!(args.contains(&"--output-format".to_string()));
1398        assert!(args.contains(&"stream-json".to_string()));
1399        assert!(args.contains(&"--verbose".to_string()));
1400        assert_eq!(args.last().unwrap(), "test");
1401
1402        assert!(!args.contains(&"--print".to_string()));
1403    }
1404
1405    #[test]
1406    fn bare_preset_with_extra_args() {
1407        let config = ClaudeConfig::builder()
1408            .preset(Preset::Bare)
1409            .extra_args(["--print", "--cli-mode"])
1410            .build();
1411        let args = config.to_args("test");
1412
1413        assert!(args.contains(&"--print".to_string()));
1414        assert!(args.contains(&"--cli-mode".to_string()));
1415    }
1416
1417    #[test]
1418    fn extra_args_after_format() {
1419        let config = ClaudeConfig::builder()
1420            .preset(Preset::Bare)
1421            .extra_args(["--new-flag"])
1422            .build();
1423        let args = config.to_args("test");
1424
1425        // extra_args should appear after --output-format (for last-wins override)
1426        let format_idx = args.iter().position(|a| a == "--output-format").unwrap();
1427        let flag_idx = args.iter().position(|a| a == "--new-flag").unwrap();
1428        assert!(flag_idx > format_idx);
1429    }
1430
1431    // --- Custom preset tests ---
1432
1433    #[test]
1434    fn custom_preset_to_args() {
1435        let config = ClaudeConfig::builder()
1436            .preset(Preset::Custom(vec![
1437                "--print".into(),
1438                "--no-session-persistence".into(),
1439            ]))
1440            .build();
1441        let args = config.to_args("test");
1442
1443        assert!(args.contains(&"--print".to_string()));
1444        assert!(args.contains(&"--no-session-persistence".to_string()));
1445        assert!(args.contains(&"--output-format".to_string()));
1446        assert_eq!(args.last().unwrap(), "test");
1447
1448        // Flags NOT in the custom preset should be absent
1449        assert!(!args.contains(&"--strict-mcp-config".to_string()));
1450        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1451    }
1452
1453    #[test]
1454    fn custom_preset_is_reusable() {
1455        let preset = Preset::Custom(vec!["--print".into(), "--no-session-persistence".into()]);
1456
1457        let config1 = ClaudeConfig::builder()
1458            .preset(preset.clone())
1459            .model("haiku")
1460            .build();
1461        let config2 = ClaudeConfig::builder()
1462            .preset(preset)
1463            .model("sonnet")
1464            .build();
1465
1466        let args1 = config1.to_args("test");
1467        let args2 = config2.to_args("test");
1468
1469        // Both should have the preset flags
1470        assert!(args1.contains(&"--print".to_string()));
1471        assert!(args2.contains(&"--print".to_string()));
1472        assert!(args1.contains(&"--no-session-persistence".to_string()));
1473        assert!(args2.contains(&"--no-session-persistence".to_string()));
1474    }
1475
1476    #[test]
1477    fn custom_preset_builder_override_remove() {
1478        let config = ClaudeConfig::builder()
1479            .preset(Preset::Custom(vec![
1480                "--print".into(),
1481                "--no-session-persistence".into(),
1482            ]))
1483            .no_session_persistence(false) // remove from custom preset
1484            .build();
1485        let args = config.to_args("test");
1486
1487        assert!(args.contains(&"--print".to_string()));
1488        assert!(!args.contains(&"--no-session-persistence".to_string()));
1489    }
1490
1491    #[test]
1492    fn custom_preset_builder_override_remove_strict_mcp() {
1493        let config = ClaudeConfig::builder()
1494            .preset(Preset::Custom(vec![
1495                "--print".into(),
1496                "--strict-mcp-config".into(),
1497            ]))
1498            .strict_mcp_config(false)
1499            .build();
1500        let args = config.to_args("test");
1501
1502        assert!(!args.contains(&"--strict-mcp-config".to_string()));
1503    }
1504
1505    #[test]
1506    fn custom_preset_builder_override_remove_disable_slash_commands() {
1507        let config = ClaudeConfig::builder()
1508            .preset(Preset::Custom(vec![
1509                "--print".into(),
1510                "--disable-slash-commands".into(),
1511            ]))
1512            .disable_slash_commands(false)
1513            .build();
1514        let args = config.to_args("test");
1515
1516        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1517    }
1518
1519    // --- Priority override tests ---
1520
1521    #[test]
1522    fn priority_extra_args_appended_last() {
1523        let config = ClaudeConfig::builder()
1524            .preset(Preset::Normal)
1525            .extra_args(["--new-flag"])
1526            .build();
1527        let args = config.to_args("test");
1528
1529        let prompt_idx = args.iter().position(|a| a == "test").unwrap();
1530        let flag_idx = args.iter().position(|a| a == "--new-flag").unwrap();
1531        assert!(flag_idx < prompt_idx);
1532        assert!(flag_idx > 0); // not the first arg
1533    }
1534
1535    #[test]
1536    fn priority_extra_args_overrides_format() {
1537        let config = ClaudeConfig::builder()
1538            .preset(Preset::Normal)
1539            .extra_args(["--output-format", "new"])
1540            .build();
1541        let args = config.to_args("test");
1542
1543        // Both the library-injected and user-specified --output-format present
1544        let format_positions: Vec<_> = args
1545            .iter()
1546            .enumerate()
1547            .filter(|(_, a)| a.as_str() == "--output-format")
1548            .map(|(i, _)| i)
1549            .collect();
1550        assert_eq!(format_positions.len(), 2);
1551        // User-specified comes after library-injected
1552        assert!(format_positions[1] > format_positions[0]);
1553    }
1554
1555    #[test]
1556    fn priority_full_stack() {
1557        let config = ClaudeConfig::builder()
1558            .preset(Preset::Minimal)
1559            .model("haiku")
1560            .extra_args(["--model", "sonnet"])
1561            .build();
1562        let args = config.to_args("test");
1563
1564        // Both --model values present
1565        let model_positions: Vec<_> = args
1566            .iter()
1567            .enumerate()
1568            .filter(|(_, a)| a.as_str() == "--model")
1569            .map(|(i, _)| i)
1570            .collect();
1571        assert_eq!(model_positions.len(), 2);
1572        // haiku (builder attr) before sonnet (extra_args)
1573        assert_eq!(args[model_positions[0] + 1], "haiku");
1574        assert_eq!(args[model_positions[1] + 1], "sonnet");
1575    }
1576
1577    // --- Boolean flag override tests ---
1578
1579    #[test]
1580    fn bool_flag_none_follows_normal_preset() {
1581        // Normal includes these flags by default when None
1582        let config = ClaudeConfig::builder().preset(Preset::Normal).build();
1583        let args = config.to_args("test");
1584        assert!(args.contains(&"--no-session-persistence".to_string()));
1585        assert!(args.contains(&"--strict-mcp-config".to_string()));
1586        assert!(args.contains(&"--disable-slash-commands".to_string()));
1587    }
1588
1589    #[test]
1590    fn bool_flag_none_follows_minimal_preset() {
1591        // Minimal does NOT include these flags when None
1592        let config = ClaudeConfig::builder().preset(Preset::Minimal).build();
1593        let args = config.to_args("test");
1594        assert!(!args.contains(&"--no-session-persistence".to_string()));
1595        assert!(!args.contains(&"--strict-mcp-config".to_string()));
1596        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1597    }
1598
1599    #[test]
1600    fn bool_flag_true_adds_to_minimal() {
1601        let config = ClaudeConfig::builder()
1602            .preset(Preset::Minimal)
1603            .no_session_persistence(true)
1604            .strict_mcp_config(true)
1605            .disable_slash_commands(true)
1606            .build();
1607        let args = config.to_args("test");
1608        assert!(args.contains(&"--no-session-persistence".to_string()));
1609        assert!(args.contains(&"--strict-mcp-config".to_string()));
1610        assert!(args.contains(&"--disable-slash-commands".to_string()));
1611    }
1612
1613    #[test]
1614    fn bool_flag_false_removes_from_normal() {
1615        let config = ClaudeConfig::builder()
1616            .preset(Preset::Normal)
1617            .no_session_persistence(false)
1618            .strict_mcp_config(false)
1619            .disable_slash_commands(false)
1620            .build();
1621        let args = config.to_args("test");
1622        assert!(!args.contains(&"--no-session-persistence".to_string()));
1623        assert!(!args.contains(&"--strict-mcp-config".to_string()));
1624        assert!(!args.contains(&"--disable-slash-commands".to_string()));
1625    }
1626
1627    // --- Hang protection: --print presence tests ---
1628
1629    #[test]
1630    fn normal_preset_contains_print() {
1631        let config = ClaudeConfig::builder().preset(Preset::Normal).build();
1632        let args = config.to_args("test");
1633        assert!(args.contains(&"--print".to_string()));
1634    }
1635
1636    #[test]
1637    fn minimal_preset_contains_print() {
1638        let config = ClaudeConfig::builder().preset(Preset::Minimal).build();
1639        let args = config.to_args("test");
1640        assert!(args.contains(&"--print".to_string()));
1641    }
1642
1643    #[test]
1644    fn bare_preset_no_print() {
1645        let config = ClaudeConfig::builder().preset(Preset::Bare).build();
1646        let args = config.to_args("test");
1647        assert!(!args.contains(&"--print".to_string()));
1648    }
1649
1650    #[test]
1651    fn bare_preset_with_print_in_extra_args() {
1652        let config = ClaudeConfig::builder()
1653            .preset(Preset::Bare)
1654            .extra_args(["-p"])
1655            .build();
1656        let args = config.to_args("test");
1657        assert!(args.contains(&"-p".to_string()));
1658    }
1659
1660    #[test]
1661    fn custom_preset_without_print() {
1662        let config = ClaudeConfig::builder()
1663            .preset(Preset::Custom(vec!["--no-session-persistence".into()]))
1664            .build();
1665        let args = config.to_args("test");
1666        assert!(!args.contains(&"--print".to_string()));
1667    }
1668
1669    #[cfg(feature = "tracing")]
1670    #[tracing_test::traced_test]
1671    #[test]
1672    fn warn_if_no_print_fires_for_bare_preset() {
1673        let config = ClaudeConfig::builder().preset(Preset::Bare).build();
1674        let _args = config.to_args("test");
1675        assert!(logs_contain("args do not contain --print"));
1676    }
1677
1678    #[cfg(feature = "tracing")]
1679    #[tracing_test::traced_test]
1680    #[test]
1681    fn warn_if_no_print_fires_for_bare_preset_stream() {
1682        let config = ClaudeConfig::builder().preset(Preset::Bare).build();
1683        let _args = config.to_stream_args("test");
1684        assert!(logs_contain("args do not contain --print"));
1685    }
1686
1687    #[cfg(feature = "tracing")]
1688    #[tracing_test::traced_test]
1689    #[test]
1690    fn warn_if_no_print_fires_for_custom_preset_without_print() {
1691        let config = ClaudeConfig::builder()
1692            .preset(Preset::Custom(vec!["--no-session-persistence".into()]))
1693            .build();
1694        let _args = config.to_args("test");
1695        assert!(logs_contain("args do not contain --print"));
1696    }
1697
1698    #[cfg(feature = "tracing")]
1699    #[tracing_test::traced_test]
1700    #[test]
1701    fn warn_if_no_print_does_not_fire_for_normal_preset() {
1702        let config = ClaudeConfig::builder().preset(Preset::Normal).build();
1703        let _args = config.to_args("test");
1704        assert!(!logs_contain("args do not contain --print"));
1705    }
1706
1707    #[cfg(feature = "tracing")]
1708    #[tracing_test::traced_test]
1709    #[test]
1710    fn warn_if_no_print_does_not_fire_for_minimal_preset() {
1711        let config = ClaudeConfig::builder().preset(Preset::Minimal).build();
1712        let _args = config.to_args("test");
1713        assert!(!logs_contain("args do not contain --print"));
1714    }
1715
1716    #[cfg(feature = "tracing")]
1717    #[tracing_test::traced_test]
1718    #[test]
1719    fn warn_if_no_print_does_not_fire_for_custom_preset_with_print() {
1720        let config = ClaudeConfig::builder()
1721            .preset(Preset::Custom(vec!["--print".into()]))
1722            .build();
1723        let _args = config.to_args("test");
1724        assert!(!logs_contain("args do not contain --print"));
1725    }
1726}