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#[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 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 pub fn discover_from(start_dir: &Path, cli_args: &CliArgs) -> Result<Self, XCheckerError> {
45 let mut source_attribution = HashMap::new();
46
47 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 config.validate()?;
463
464 Ok(config)
465 }
466
467 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 if current_dir.parent().is_none() {
483 break;
484 }
485
486 if current_dir.join(".git").exists()
488 || current_dir.join(".hg").exists()
489 || current_dir.join(".svn").exists()
490 {
491 break;
493 }
494
495 current_dir = current_dir.parent().unwrap().to_path_buf();
496 }
497
498 Ok(None)
499 }
500
501 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 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 pub fn discover_from_env_and_fs() -> Result<Self, XCheckerError> {
556 let cli_args = CliArgs::default();
559 Self::discover(&cli_args)
560 }
561}