Skip to main content

xchecker_config/config/
discovery.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::env;
4use std::path::{Path, PathBuf};
5
6use crate::error::{ConfigError, XCheckerError};
7
8use super::{
9    ClaudeConfig, CliArgs, Config, ConfigSource, Defaults, GeminiConfig, HooksConfig, LlmConfig,
10    PhasesConfig, RunnerConfig, SecurityConfig, Selectors,
11};
12
13/// TOML configuration file structure
14#[derive(Debug, Deserialize, Serialize)]
15struct TomlConfig {
16    defaults: Option<Defaults>,
17    selectors: Option<Selectors>,
18    runner: Option<RunnerConfig>,
19    llm: Option<LlmConfig>,
20    phases: Option<PhasesConfig>,
21    hooks: Option<HooksConfig>,
22    security: Option<SecurityConfig>,
23}
24
25impl Config {
26    /// Discover and load configuration with precedence: CLI > file > defaults
27    ///
28    /// Uses current working directory for config file discovery when no explicit
29    /// path is provided in cli_args.
30    pub fn discover(cli_args: &CliArgs) -> Result<Self, XCheckerError> {
31        let start_dir = std::env::current_dir().map_err(|e| {
32            XCheckerError::Config(ConfigError::DiscoveryFailed {
33                reason: format!("Failed to get current directory: {e}"),
34            })
35        })?;
36        Self::discover_from(&start_dir, cli_args)
37    }
38
39    /// Discover and load configuration starting from a specific directory
40    ///
41    /// This is the path-driven variant used by tests to avoid process-global state.
42    /// Uses the given directory for config file discovery when no explicit path
43    /// is provided in cli_args.
44    pub fn discover_from(start_dir: &Path, cli_args: &CliArgs) -> Result<Self, XCheckerError> {
45        let mut source_attribution = HashMap::new();
46
47        // Start with built-in defaults
48        let mut defaults = Defaults::default();
49        let mut selectors = Selectors::default();
50        let mut runner = RunnerConfig::default();
51        let mut llm = LlmConfig {
52            provider: None,
53            fallback_provider: None,
54            claude: None,
55            gemini: None,
56            openrouter: None,
57            anthropic: None,
58            execution_strategy: None,
59            prompt_template: None,
60        };
61        let mut hooks = HooksConfig::default();
62        let mut phases = PhasesConfig::default();
63        let mut security = SecurityConfig::default();
64
65        // Track default sources
66        source_attribution.insert("max_turns".to_string(), ConfigSource::Default);
67        source_attribution.insert("packet_max_bytes".to_string(), ConfigSource::Default);
68        source_attribution.insert("packet_max_lines".to_string(), ConfigSource::Default);
69        source_attribution.insert("output_format".to_string(), ConfigSource::Default);
70        source_attribution.insert("verbose".to_string(), ConfigSource::Default);
71        source_attribution.insert("runner_mode".to_string(), ConfigSource::Default);
72        source_attribution.insert("phase_timeout".to_string(), ConfigSource::Default);
73        source_attribution.insert("stdout_cap_bytes".to_string(), ConfigSource::Default);
74        source_attribution.insert("stderr_cap_bytes".to_string(), ConfigSource::Default);
75        source_attribution.insert("lock_ttl_seconds".to_string(), ConfigSource::Default);
76        source_attribution.insert("debug_packet".to_string(), ConfigSource::Default);
77        source_attribution.insert("allow_links".to_string(), ConfigSource::Default);
78
79        // Discover and load config file (if not explicitly provided)
80        let config_path = if let Some(explicit_path) = &cli_args.config_path {
81            Some(explicit_path.clone())
82        } else {
83            Self::discover_config_file_from(start_dir)?
84        };
85
86        if let Some(path) = &config_path {
87            let file_config = Self::load_config_file(path)?;
88
89            let config_source = ConfigSource::Config;
90
91            // Apply config file values (override defaults)
92            if let Some(file_defaults) = file_config.defaults {
93                if file_defaults.model.is_some() {
94                    defaults.model = file_defaults.model;
95                    source_attribution.insert("model".to_string(), config_source.clone());
96                }
97                if file_defaults.max_turns.is_some() {
98                    defaults.max_turns = file_defaults.max_turns;
99                    source_attribution.insert("max_turns".to_string(), config_source.clone());
100                }
101                if file_defaults.packet_max_bytes.is_some() {
102                    defaults.packet_max_bytes = file_defaults.packet_max_bytes;
103                    source_attribution
104                        .insert("packet_max_bytes".to_string(), config_source.clone());
105                }
106                if file_defaults.packet_max_lines.is_some() {
107                    defaults.packet_max_lines = file_defaults.packet_max_lines;
108                    source_attribution
109                        .insert("packet_max_lines".to_string(), config_source.clone());
110                }
111                if file_defaults.output_format.is_some() {
112                    defaults.output_format = file_defaults.output_format;
113                    source_attribution.insert("output_format".to_string(), config_source.clone());
114                }
115                if file_defaults.verbose.is_some() {
116                    defaults.verbose = file_defaults.verbose;
117                    source_attribution.insert("verbose".to_string(), config_source.clone());
118                }
119                if file_defaults.phase_timeout.is_some() {
120                    defaults.phase_timeout = file_defaults.phase_timeout;
121                    source_attribution.insert("phase_timeout".to_string(), config_source.clone());
122                }
123                if file_defaults.stdout_cap_bytes.is_some() {
124                    defaults.stdout_cap_bytes = file_defaults.stdout_cap_bytes;
125                    source_attribution
126                        .insert("stdout_cap_bytes".to_string(), config_source.clone());
127                }
128                if file_defaults.stderr_cap_bytes.is_some() {
129                    defaults.stderr_cap_bytes = file_defaults.stderr_cap_bytes;
130                    source_attribution
131                        .insert("stderr_cap_bytes".to_string(), config_source.clone());
132                }
133                if file_defaults.lock_ttl_seconds.is_some() {
134                    defaults.lock_ttl_seconds = file_defaults.lock_ttl_seconds;
135                    source_attribution
136                        .insert("lock_ttl_seconds".to_string(), config_source.clone());
137                }
138                if file_defaults.debug_packet.is_some() {
139                    defaults.debug_packet = file_defaults.debug_packet;
140                    source_attribution.insert("debug_packet".to_string(), config_source.clone());
141                }
142                if file_defaults.allow_links.is_some() {
143                    defaults.allow_links = file_defaults.allow_links;
144                    source_attribution.insert("allow_links".to_string(), config_source.clone());
145                }
146                if file_defaults.strict_validation.is_some() {
147                    defaults.strict_validation = file_defaults.strict_validation;
148                    source_attribution
149                        .insert("strict_validation".to_string(), config_source.clone());
150                }
151            }
152
153            if let Some(file_selectors) = file_config.selectors {
154                if !file_selectors.include.is_empty() {
155                    selectors.include = file_selectors.include;
156                    source_attribution
157                        .insert("selectors_include".to_string(), config_source.clone());
158                }
159                if !file_selectors.exclude.is_empty() {
160                    selectors.exclude = file_selectors.exclude;
161                    source_attribution
162                        .insert("selectors_exclude".to_string(), config_source.clone());
163                }
164            }
165
166            if let Some(file_runner) = file_config.runner {
167                if file_runner.mode.is_some() {
168                    runner.mode = file_runner.mode;
169                    source_attribution.insert("runner_mode".to_string(), config_source.clone());
170                }
171                if file_runner.distro.is_some() {
172                    runner.distro = file_runner.distro;
173                    source_attribution.insert("runner_distro".to_string(), config_source.clone());
174                }
175                if file_runner.claude_path.is_some() {
176                    runner.claude_path = file_runner.claude_path;
177                    source_attribution.insert("claude_path".to_string(), config_source.clone());
178                }
179            }
180
181            if let Some(file_llm) = file_config.llm {
182                if file_llm.provider.is_some() {
183                    llm.provider = file_llm.provider;
184                    source_attribution.insert("llm_provider".to_string(), config_source.clone());
185                }
186                if file_llm.fallback_provider.is_some() {
187                    llm.fallback_provider = file_llm.fallback_provider;
188                    source_attribution
189                        .insert("llm_fallback_provider".to_string(), config_source.clone());
190                }
191                if let Some(file_claude) = file_llm.claude
192                    && file_claude.binary.is_some()
193                {
194                    llm.claude = Some(file_claude);
195                    source_attribution
196                        .insert("llm_claude_binary".to_string(), config_source.clone());
197                }
198                if let Some(file_gemini) = file_llm.gemini {
199                    llm.gemini = Some(file_gemini);
200                    source_attribution
201                        .insert("llm_gemini_config".to_string(), config_source.clone());
202                }
203                if let Some(file_openrouter) = file_llm.openrouter {
204                    llm.openrouter = Some(file_openrouter);
205                    source_attribution
206                        .insert("llm_openrouter_config".to_string(), config_source.clone());
207                }
208                if let Some(file_anthropic) = file_llm.anthropic {
209                    llm.anthropic = Some(file_anthropic);
210                    source_attribution
211                        .insert("llm_anthropic_config".to_string(), config_source.clone());
212                }
213                if file_llm.execution_strategy.is_some() {
214                    llm.execution_strategy = file_llm.execution_strategy;
215                    source_attribution
216                        .insert("execution_strategy".to_string(), config_source.clone());
217                }
218                if file_llm.prompt_template.is_some() {
219                    llm.prompt_template = file_llm.prompt_template;
220                    source_attribution.insert("prompt_template".to_string(), config_source.clone());
221                }
222            }
223
224            // Load phases configuration from file
225            if let Some(file_phases) = file_config.phases {
226                phases = file_phases;
227                source_attribution.insert("phases".to_string(), config_source.clone());
228            }
229
230            // Load hooks configuration from file
231            if let Some(file_hooks) = file_config.hooks {
232                hooks = file_hooks;
233                source_attribution.insert("hooks".to_string(), config_source.clone());
234            }
235
236            // Load security configuration from file
237            if let Some(file_security) = file_config.security {
238                security = file_security;
239                source_attribution.insert("security".to_string(), config_source);
240            }
241        }
242
243        // Apply CLI overrides (highest priority)
244        if let Some(model) = &cli_args.model {
245            defaults.model = Some(model.clone());
246            source_attribution.insert("model".to_string(), ConfigSource::Cli);
247        }
248        if let Some(max_turns) = cli_args.max_turns {
249            defaults.max_turns = Some(max_turns);
250            source_attribution.insert("max_turns".to_string(), ConfigSource::Cli);
251        }
252        if let Some(packet_max_bytes) = cli_args.packet_max_bytes {
253            defaults.packet_max_bytes = Some(packet_max_bytes);
254            source_attribution.insert("packet_max_bytes".to_string(), ConfigSource::Cli);
255        }
256        if let Some(packet_max_lines) = cli_args.packet_max_lines {
257            defaults.packet_max_lines = Some(packet_max_lines);
258            source_attribution.insert("packet_max_lines".to_string(), ConfigSource::Cli);
259        }
260        if let Some(output_format) = &cli_args.output_format {
261            defaults.output_format = Some(output_format.clone());
262            source_attribution.insert("output_format".to_string(), ConfigSource::Cli);
263        }
264        if let Some(verbose) = cli_args.verbose {
265            defaults.verbose = Some(verbose);
266            source_attribution.insert("verbose".to_string(), ConfigSource::Cli);
267        }
268        if let Some(runner_mode) = &cli_args.runner_mode {
269            runner.mode = Some(runner_mode.clone());
270            source_attribution.insert("runner_mode".to_string(), ConfigSource::Cli);
271        }
272        if let Some(runner_distro) = &cli_args.runner_distro {
273            runner.distro = Some(runner_distro.clone());
274            source_attribution.insert("runner_distro".to_string(), ConfigSource::Cli);
275        }
276        if let Some(claude_path) = &cli_args.claude_path {
277            runner.claude_path = Some(claude_path.clone());
278            source_attribution.insert("claude_path".to_string(), ConfigSource::Cli);
279        }
280        if let Some(phase_timeout) = cli_args.phase_timeout {
281            defaults.phase_timeout = Some(phase_timeout);
282            source_attribution.insert("phase_timeout".to_string(), ConfigSource::Cli);
283        }
284        if let Some(stdout_cap_bytes) = cli_args.stdout_cap_bytes {
285            defaults.stdout_cap_bytes = Some(stdout_cap_bytes);
286            source_attribution.insert("stdout_cap_bytes".to_string(), ConfigSource::Cli);
287        }
288        if let Some(stderr_cap_bytes) = cli_args.stderr_cap_bytes {
289            defaults.stderr_cap_bytes = Some(stderr_cap_bytes);
290            source_attribution.insert("stderr_cap_bytes".to_string(), ConfigSource::Cli);
291        }
292        if let Some(lock_ttl_seconds) = cli_args.lock_ttl_seconds {
293            defaults.lock_ttl_seconds = Some(lock_ttl_seconds);
294            source_attribution.insert("lock_ttl_seconds".to_string(), ConfigSource::Cli);
295        }
296        if cli_args.debug_packet {
297            defaults.debug_packet = Some(true);
298            source_attribution.insert("debug_packet".to_string(), ConfigSource::Cli);
299        }
300        if cli_args.allow_links {
301            defaults.allow_links = Some(true);
302            source_attribution.insert("allow_links".to_string(), ConfigSource::Cli);
303        }
304        if let Some(strict_validation) = cli_args.strict_validation {
305            defaults.strict_validation = Some(strict_validation);
306            source_attribution.insert("strict_validation".to_string(), ConfigSource::Cli);
307        }
308
309        // Apply security pattern overrides (CLI > file > defaults)
310        if !cli_args.extra_secret_pattern.is_empty() {
311            security
312                .extra_secret_patterns
313                .extend(cli_args.extra_secret_pattern.clone());
314            source_attribution.insert("security".to_string(), ConfigSource::Cli);
315        }
316        if !cli_args.ignore_secret_pattern.is_empty() {
317            security
318                .ignore_secret_patterns
319                .extend(cli_args.ignore_secret_pattern.clone());
320            source_attribution.insert("security".to_string(), ConfigSource::Cli);
321        }
322
323        // Apply LLM configuration with precedence: CLI > env > config > defaults
324        // Check environment variable first
325        if let Ok(env_provider) = env::var("XCHECKER_LLM_PROVIDER")
326            && !env_provider.is_empty()
327        {
328            llm.provider = Some(env_provider);
329            source_attribution.insert("llm_provider".to_string(), ConfigSource::Env);
330        }
331
332        // CLI flag overrides environment variable
333        if let Some(provider) = &cli_args.llm_provider {
334            llm.provider = Some(provider.clone());
335            source_attribution.insert("llm_provider".to_string(), ConfigSource::Cli);
336        }
337
338        // Default to "claude-cli" if no provider is set
339        if llm.provider.is_none() {
340            llm.provider = Some("claude-cli".to_string());
341            source_attribution.insert("llm_provider".to_string(), ConfigSource::Default);
342        }
343
344        // Apply fallback provider configuration with precedence: CLI > env > config
345        if let Ok(env_fallback) = env::var("XCHECKER_LLM_FALLBACK_PROVIDER")
346            && !env_fallback.is_empty()
347        {
348            llm.fallback_provider = Some(env_fallback);
349            source_attribution.insert("llm_fallback_provider".to_string(), ConfigSource::Env);
350        }
351
352        if let Some(fallback_provider) = &cli_args.llm_fallback_provider {
353            llm.fallback_provider = Some(fallback_provider.clone());
354            source_attribution.insert("llm_fallback_provider".to_string(), ConfigSource::Cli);
355        }
356
357        // Apply prompt template configuration with precedence: CLI > env > config
358        if let Ok(env_template) = env::var("XCHECKER_LLM_PROMPT_TEMPLATE")
359            && !env_template.is_empty()
360        {
361            llm.prompt_template = Some(env_template);
362            source_attribution.insert("prompt_template".to_string(), ConfigSource::Env);
363        }
364
365        if let Some(prompt_template) = &cli_args.prompt_template {
366            llm.prompt_template = Some(prompt_template.clone());
367            source_attribution.insert("prompt_template".to_string(), ConfigSource::Cli);
368        }
369
370        // Apply Claude binary configuration
371        if let Some(binary) = &cli_args.llm_claude_binary {
372            if llm.claude.is_none() {
373                llm.claude = Some(ClaudeConfig { binary: None });
374            }
375            if let Some(claude_config) = &mut llm.claude {
376                claude_config.binary = Some(binary.clone());
377                source_attribution.insert("llm_claude_binary".to_string(), ConfigSource::Cli);
378            }
379        }
380
381        // Apply Gemini binary configuration
382        if let Some(binary) = &cli_args.llm_gemini_binary {
383            if llm.gemini.is_none() {
384                llm.gemini = Some(GeminiConfig {
385                    binary: None,
386                    default_model: None,
387                    profiles: None,
388                });
389            }
390            if let Some(gemini_config) = &mut llm.gemini {
391                gemini_config.binary = Some(binary.clone());
392                source_attribution.insert("llm_gemini_binary".to_string(), ConfigSource::Cli);
393            }
394        }
395
396        // Apply Gemini default model configuration with precedence: CLI > env > config
397        if let Ok(env_default_model) = env::var("XCHECKER_LLM_GEMINI_DEFAULT_MODEL")
398            && !env_default_model.is_empty()
399        {
400            if llm.gemini.is_none() {
401                llm.gemini = Some(GeminiConfig {
402                    binary: None,
403                    default_model: None,
404                    profiles: None,
405                });
406            }
407            if let Some(gemini_config) = &mut llm.gemini {
408                gemini_config.default_model = Some(env_default_model);
409                source_attribution
410                    .insert("llm_gemini_default_model".to_string(), ConfigSource::Env);
411            }
412        }
413
414        if let Some(default_model) = &cli_args.llm_gemini_default_model {
415            if llm.gemini.is_none() {
416                llm.gemini = Some(GeminiConfig {
417                    binary: None,
418                    default_model: None,
419                    profiles: None,
420                });
421            }
422            if let Some(gemini_config) = &mut llm.gemini {
423                gemini_config.default_model = Some(default_model.clone());
424                source_attribution
425                    .insert("llm_gemini_default_model".to_string(), ConfigSource::Cli);
426            }
427        }
428
429        // Apply execution strategy configuration with precedence: CLI > env > config > default
430        // Check environment variable (overrides config file)
431        if let Ok(env_strategy) = env::var("XCHECKER_EXECUTION_STRATEGY")
432            && !env_strategy.is_empty()
433        {
434            llm.execution_strategy = Some(env_strategy);
435            source_attribution.insert("execution_strategy".to_string(), ConfigSource::Env);
436        }
437
438        // CLI flag overrides everything
439        if let Some(strategy) = &cli_args.execution_strategy {
440            llm.execution_strategy = Some(strategy.clone());
441            source_attribution.insert("execution_strategy".to_string(), ConfigSource::Cli);
442        }
443
444        // Default to "controlled" if not specified
445        if llm.execution_strategy.is_none() {
446            llm.execution_strategy = Some("controlled".to_string());
447            source_attribution.insert("execution_strategy".to_string(), ConfigSource::Default);
448        }
449
450        let config = Self {
451            defaults,
452            selectors,
453            runner,
454            llm,
455            phases,
456            hooks,
457            security,
458            source_attribution,
459        };
460
461        // Validate the final configuration
462        config.validate()?;
463
464        Ok(config)
465    }
466
467    /// Discover config file by searching upward from a given directory
468    ///
469    /// This is the path-driven variant used by tests to avoid process-global state.
470    /// Walks up the directory tree looking for `.xchecker/config.toml`, stopping
471    /// at repository root markers (.git, .hg, .svn) or filesystem root.
472    pub fn discover_config_file_from(start_dir: &Path) -> Result<Option<PathBuf>, XCheckerError> {
473        let mut current_dir = start_dir.to_path_buf();
474
475        loop {
476            let config_path = current_dir.join(".xchecker").join("config.toml");
477            if config_path.exists() {
478                return Ok(Some(config_path));
479            }
480
481            // Check if we've reached the filesystem root or repository root
482            if current_dir.parent().is_none() {
483                break;
484            }
485
486            // Check for repository root markers
487            if current_dir.join(".git").exists()
488                || current_dir.join(".hg").exists()
489                || current_dir.join(".svn").exists()
490            {
491                // Stop at repository root if no config found
492                break;
493            }
494
495            current_dir = current_dir.parent().unwrap().to_path_buf();
496        }
497
498        Ok(None)
499    }
500
501    /// Load configuration from TOML file
502    fn load_config_file(path: &Path) -> Result<TomlConfig, XCheckerError> {
503        match std::fs::read_to_string(path) {
504            Ok(content) => toml::from_str(&content).map_err(|e| {
505                XCheckerError::Config(ConfigError::InvalidFile(format!(
506                    "Failed to parse TOML config file {}: {e}",
507                    path.display()
508                )))
509            }),
510            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
511                // Missing config file is OK - return empty config (will use defaults)
512                Ok(TomlConfig {
513                    defaults: None,
514                    selectors: None,
515                    runner: None,
516                    llm: None,
517                    phases: None,
518                    hooks: None,
519                    security: None,
520                })
521            }
522            Err(e) => Err(XCheckerError::Config(ConfigError::DiscoveryFailed {
523                reason: format!("Failed to read config file {}: {}", path.display(), e),
524            })),
525        }
526    }
527
528    /// Discover configuration from environment and filesystem.
529    ///
530    /// This method uses the same discovery logic as the CLI:
531    /// - `XCHECKER_HOME` environment variable (if set)
532    /// - Upward search for `.xchecker/config.toml` from current directory
533    /// - Built-in defaults
534    ///
535    /// Precedence: config file > defaults
536    ///
537    /// This is the recommended method for library consumers who want CLI-like
538    /// behavior without needing to construct `CliArgs`.
539    ///
540    /// # Example
541    ///
542    /// ```rust,no_run
543    /// use xchecker_config::Config;
544    ///
545    /// let config = Config::discover_from_env_and_fs()
546    ///     .expect("Failed to discover config");
547    /// ```
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if:
552    /// - The current directory cannot be determined
553    /// - A config file exists but cannot be parsed
554    /// - Configuration validation fails
555    pub fn discover_from_env_and_fs() -> Result<Self, XCheckerError> {
556        // Use empty CliArgs to get config file + defaults behavior
557        // This matches CLI semantics without any CLI overrides
558        let cli_args = CliArgs::default();
559        Self::discover(&cli_args)
560    }
561}