Skip to main content

xchecker_config/config/
mod.rs

1//! Configuration management for xchecker
2//!
3//! This module provides hierarchical configuration with discovery and precedence:
4//! CLI > file > defaults. Supports TOML configuration files with `[defaults]`,
5//! `[selectors]`, and `[runner]` sections.
6
7mod builder;
8mod cli_args;
9mod discovery;
10mod model;
11mod selectors;
12mod sources;
13mod validation;
14
15pub use builder::ConfigBuilder;
16pub use cli_args::CliArgs;
17pub use model::*;
18pub use selectors::ALWAYS_EXCLUDE_PATTERNS;
19pub use xchecker_prompt_template::PromptTemplate;
20pub use xchecker_selectors::*;
21pub use xchecker_utils::types::ConfigSource;
22
23use crate::error::{ConfigError, XCheckerError};
24use xchecker_utils::runner::RunnerMode;
25
26impl Config {
27    /// Convert runner mode string to enum
28    pub fn get_runner_mode(&self) -> Result<RunnerMode, XCheckerError> {
29        let mode_str = self.runner.mode.as_deref().unwrap_or("auto");
30        match mode_str {
31            "auto" => Ok(RunnerMode::Auto),
32            "native" => Ok(RunnerMode::Native),
33            "wsl" => Ok(RunnerMode::Wsl),
34            _ => Err(XCheckerError::Config(ConfigError::InvalidValue {
35                key: "runner_mode".to_string(),
36                value: format!("Unknown runner mode: {mode_str}"),
37            })),
38        }
39    }
40
41    /// Get the model to use for a specific phase.
42    ///
43    /// Precedence (highest to lowest):
44    /// 1. Phase-specific override (`[phases.<phase>].model`)
45    /// 2. Global default (`[defaults].model`)
46    /// 3. Hard default: `"haiku"` (fast, cost-effective for testing/development)
47    ///
48    /// # Example
49    ///
50    /// ```toml
51    /// [defaults]
52    /// model = "haiku"
53    ///
54    /// [phases.design]
55    /// model = "sonnet"
56    ///
57    /// [phases.tasks]
58    /// model = "sonnet"
59    /// ```
60    ///
61    /// With the above config:
62    /// - `model_for_phase(Requirements)` -> "haiku"
63    /// - `model_for_phase(Design)` -> "sonnet"
64    /// - `model_for_phase(Tasks)` -> "sonnet"
65    #[must_use]
66    pub fn model_for_phase(&self, phase: crate::types::PhaseId) -> String {
67        use crate::types::PhaseId;
68
69        // First, check for phase-specific override
70        let phase_model = match phase {
71            PhaseId::Requirements => self.phases.requirements.as_ref(),
72            PhaseId::Design => self.phases.design.as_ref(),
73            PhaseId::Tasks => self.phases.tasks.as_ref(),
74            PhaseId::Review => self.phases.review.as_ref(),
75            PhaseId::Fixup => self.phases.fixup.as_ref(),
76            PhaseId::Final => self.phases.final_.as_ref(),
77        }
78        .and_then(|pc| pc.model.clone());
79
80        // Precedence: phase-specific > global default > "haiku"
81        phase_model
82            .or_else(|| self.defaults.model.clone())
83            .unwrap_or_else(|| "haiku".to_string())
84    }
85
86    /// Check if strict validation is enabled.
87    ///
88    /// When strict validation is enabled, phase output validation failures
89    /// (meta-summaries, too-short output, missing required sections) become
90    /// hard errors that fail the phase. When disabled, validation issues are
91    /// logged as warnings only.
92    ///
93    /// # Returns
94    ///
95    /// Returns `true` if strict validation is enabled, `false` otherwise.
96    /// Defaults to `false` if not explicitly configured.
97    #[must_use]
98    pub fn strict_validation(&self) -> bool {
99        self.defaults.strict_validation.unwrap_or(false)
100    }
101}
102
103impl xchecker_redaction::SecretConfigProvider for Config {
104    fn extra_secret_patterns(&self) -> &[String] {
105        &self.security.extra_secret_patterns
106    }
107
108    fn ignore_secret_patterns(&self) -> &[String] {
109        &self.security.ignore_secret_patterns
110    }
111}
112
113#[cfg(any(test, feature = "test-utils"))]
114impl Config {
115    /// Create a minimal Config for testing purposes
116    ///
117    /// This creates a Config with default values suitable for unit tests
118    /// that don't require full configuration discovery.
119    pub fn minimal_for_testing() -> Self {
120        Config {
121            defaults: Defaults::default(),
122            selectors: Selectors::default(),
123            runner: RunnerConfig::default(),
124            llm: LlmConfig {
125                provider: None,
126                fallback_provider: None,
127                claude: None,
128                gemini: None,
129                openrouter: None,
130                anthropic: None,
131                execution_strategy: None,
132                prompt_template: None,
133            },
134            phases: PhasesConfig::default(),
135            hooks: HooksConfig::default(),
136            security: SecurityConfig::default(),
137            source_attribution: std::collections::HashMap::new(),
138        }
139    }
140}
141
142/// Test utilities for config testing (available via test-utils feature)
143#[cfg(any(test, feature = "test-utils"))]
144pub mod test_utils {
145    use std::env;
146
147    /// Clear all xchecker config-related environment variables.
148    ///
149    /// Clears all environment variables starting with `XCHECKER_` to ensure
150    /// test isolation. This approach prevents drift as new env vars are added.
151    ///
152    /// # Safety
153    ///
154    /// This function removes environment variables, which is inherently
155    /// process-global state. Tests using this should be serialized or use
156    /// appropriate synchronization.
157    ///
158    /// # Example
159    ///
160    /// ```rust,ignore
161    /// use xchecker_config::config::test_utils::clear_config_env_vars;
162    ///
163    /// #[test]
164    /// fn my_config_test() {
165    ///     clear_config_env_vars();
166    ///     // ... test code that may set XCHECKER_* env vars
167    /// }
168    /// ```
169    pub fn clear_config_env_vars() {
170        // SAFETY: We're only removing xchecker-specific env vars in test contexts.
171        // The env var operations are thread-unsafe but tests using this function
172        // should be serialized via config_env_guard or similar synchronization.
173
174        // Collect keys first to avoid iterator invalidation
175        let keys: Vec<String> = env::vars()
176            .map(|(k, _)| k)
177            .filter(|k| k.starts_with("XCHECKER_"))
178            .collect();
179
180        // Remove all XCHECKER_* environment variables
181        for key in keys {
182            // SAFETY: Called only in test contexts with proper synchronization
183            unsafe {
184                env::remove_var(&key);
185            }
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::fs;
194    use std::path::{Path, PathBuf};
195    use std::sync::{Mutex, MutexGuard, OnceLock};
196    use tempfile::TempDir;
197
198    // Global lock for tests that mutate process-global state (env vars, cwd).
199    // Tests that use `config_env_guard()` will be serialized.
200    static CONFIG_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
201
202    fn config_env_guard() -> MutexGuard<'static, ()> {
203        let guard = CONFIG_ENV_LOCK
204            .get_or_init(|| Mutex::new(()))
205            .lock()
206            .unwrap();
207        super::test_utils::clear_config_env_vars();
208        guard
209    }
210
211    fn create_test_config_file(dir: &Path, content: &str) -> PathBuf {
212        let xchecker_dir = dir.join(".xchecker");
213        crate::paths::ensure_dir_all(&xchecker_dir).unwrap();
214
215        let config_path = xchecker_dir.join("config.toml");
216        fs::write(&config_path, content).unwrap();
217
218        config_path
219    }
220
221    #[test]
222    fn test_default_config() {
223        let defaults = Defaults::default();
224        assert_eq!(defaults.max_turns, Some(6));
225        assert_eq!(defaults.packet_max_bytes, Some(65536));
226        assert_eq!(defaults.packet_max_lines, Some(1200));
227        assert_eq!(defaults.output_format, Some("stream-json".to_string()));
228        assert_eq!(defaults.verbose, Some(false));
229
230        let selectors = Selectors::default();
231        assert!(selectors.include.contains(&"README.md".to_string()));
232        assert!(selectors.exclude.contains(&"target/**".to_string()));
233
234        let runner = RunnerConfig::default();
235        assert_eq!(runner.mode, Some("auto".to_string()));
236    }
237
238    #[test]
239    fn test_config_discovery_with_cli_override() {
240        let _guard = config_env_guard();
241        let _home = crate::paths::with_isolated_home();
242        let temp_dir = TempDir::new().unwrap();
243        let _config_path = create_test_config_file(
244            temp_dir.path(),
245            r#"
246[defaults]
247model = "sonnet"
248max_turns = 10
249packet_max_bytes = 32768
250
251[runner]
252mode = "native"
253"#,
254        );
255
256        let cli_args = CliArgs {
257            config_path: None,
258            model: Some("opus".to_string()), // CLI override
259            max_turns: None,
260            packet_max_bytes: None,
261            packet_max_lines: None,
262            output_format: None,
263            verbose: Some(true), // CLI override
264            runner_mode: None,
265            runner_distro: None,
266            claude_path: None,
267            allow: vec![],
268            deny: vec![],
269            dangerously_skip_permissions: false,
270            ignore_secret_pattern: vec![],
271            extra_secret_pattern: vec![],
272            phase_timeout: None,
273            stdout_cap_bytes: None,
274            stderr_cap_bytes: None,
275            lock_ttl_seconds: None,
276            debug_packet: false,
277            allow_links: false,
278            strict_validation: None,
279            llm_provider: None,
280            llm_claude_binary: None,
281            llm_gemini_binary: None,
282            llm_gemini_default_model: None,
283            llm_fallback_provider: None,
284            prompt_template: None,
285            execution_strategy: None,
286        };
287
288        let config = Config::discover_from(temp_dir.path(), &cli_args).unwrap();
289
290        // CLI overrides should take precedence
291        assert_eq!(config.defaults.model, Some("opus".to_string()));
292        assert_eq!(config.defaults.verbose, Some(true));
293
294        // Config file values should be used where no CLI override
295        assert_eq!(config.defaults.max_turns, Some(10));
296        assert_eq!(config.defaults.packet_max_bytes, Some(32768));
297        assert_eq!(config.runner.mode, Some("native".to_string()));
298
299        // Check source attribution
300        assert_eq!(
301            config.source_attribution.get("model"),
302            Some(&ConfigSource::Cli)
303        );
304        assert_eq!(
305            config.source_attribution.get("verbose"),
306            Some(&ConfigSource::Cli)
307        );
308    }
309
310    #[test]
311    fn test_config_validation() {
312        let cli_args = CliArgs {
313            max_turns: Some(0), // Invalid
314            ..Default::default()
315        };
316
317        let result = Config::discover(&cli_args);
318        assert!(result.is_err());
319
320        // Assert on structured error type, not string content
321        let error = result.unwrap_err();
322        match error {
323            XCheckerError::Config(ConfigError::InvalidValue { key, .. }) => {
324                assert_eq!(key, "max_turns");
325            }
326            _ => panic!("Expected Config InvalidValue error for max_turns"),
327        }
328    }
329
330    #[test]
331    fn test_effective_config() {
332        let _guard = config_env_guard();
333        let temp_dir = TempDir::new().unwrap();
334        let _config_path = create_test_config_file(
335            temp_dir.path(),
336            r#"
337[defaults]
338model = "sonnet"
339max_turns = 8
340"#,
341        );
342
343        let cli_args = CliArgs {
344            verbose: Some(true),
345            ..Default::default()
346        };
347
348        let config = Config::discover_from(temp_dir.path(), &cli_args).unwrap();
349        let effective = config.effective_config();
350
351        // Check that values and sources are correctly reported
352        assert_eq!(effective.get("model").unwrap().0, "sonnet");
353        assert_eq!(effective.get("model").unwrap().1, "config");
354
355        assert_eq!(effective.get("verbose").unwrap().0, "true");
356        assert_eq!(effective.get("verbose").unwrap().1, "cli");
357
358        assert_eq!(effective.get("max_turns").unwrap().0, "8");
359        assert_eq!(effective.get("max_turns").unwrap().1, "config");
360    }
361
362    #[test]
363    fn test_invalid_toml_config() {
364        let _guard = config_env_guard();
365        let _home = crate::paths::with_isolated_home();
366        let temp_dir = TempDir::new().unwrap();
367        let xchecker_dir = temp_dir.path().join(".xchecker");
368        crate::paths::ensure_dir_all(&xchecker_dir).unwrap();
369
370        let config_path = xchecker_dir.join("config.toml");
371        fs::write(&config_path, "invalid toml content [[[").unwrap();
372
373        let cli_args = CliArgs::default();
374
375        let result = Config::discover_from(temp_dir.path(), &cli_args);
376        assert!(result.is_err());
377        let error_msg = result.unwrap_err().to_string();
378        assert!(
379            error_msg.contains("Invalid configuration file")
380                || error_msg.contains("Failed to parse TOML config file")
381        );
382    }
383
384    // ===== Edge Case Tests (Task 9.7) =====
385    #[test]
386    fn test_config_with_invalid_toml_syntax() {
387        let _guard = config_env_guard();
388        let _home = crate::paths::with_isolated_home();
389
390        // Test various invalid TOML syntaxes
391        let invalid_toml_cases = [
392            "[[[ invalid brackets",
393            "[defaults\nkey = value", // Missing closing bracket
394            "key = ",                 // Missing value
395            "[defaults]\nkey value",  // Missing equals
396            "[defaults]\nkey = 'unclosed string",
397        ];
398
399        for (i, invalid_toml) in invalid_toml_cases.iter().enumerate() {
400            let temp_dir = TempDir::new().unwrap();
401            let config_path = create_test_config_file(temp_dir.path(), invalid_toml);
402
403            // Use explicit config path instead of changing directory
404            let cli_args = CliArgs {
405                config_path: Some(config_path),
406                ..Default::default()
407            };
408            let result = Config::discover_from(temp_dir.path(), &cli_args);
409
410            assert!(
411                result.is_err(),
412                "Should fail for invalid TOML case {i}: {invalid_toml}"
413            );
414        }
415    }
416
417    #[test]
418    fn test_config_with_missing_sections() {
419        let _guard = config_env_guard();
420        let _home = crate::paths::with_isolated_home();
421        let temp_dir = TempDir::new().unwrap();
422
423        // Config with only [defaults] section (missing [selectors] and [runner])
424        let config_path = create_test_config_file(
425            temp_dir.path(),
426            r#"
427[defaults]
428model = "sonnet"
429"#,
430        );
431
432        let cli_args = CliArgs {
433            config_path: Some(config_path),
434            ..Default::default()
435        };
436        let config = Config::discover(&cli_args).unwrap();
437
438        // Should use defaults for missing sections
439        assert_eq!(config.defaults.model, Some("sonnet".to_string()));
440        assert!(!config.selectors.include.is_empty()); // Should have default selectors
441        assert!(config.runner.mode.is_some()); // Should have default runner mode
442    }
443
444    #[test]
445    fn test_config_with_empty_file() {
446        let _guard = config_env_guard();
447        let _home = crate::paths::with_isolated_home();
448        let temp_dir = TempDir::new().unwrap();
449
450        // Empty config file
451        let config_path = create_test_config_file(temp_dir.path(), "");
452
453        let cli_args = CliArgs {
454            config_path: Some(config_path),
455            ..Default::default()
456        };
457        let config = Config::discover(&cli_args).unwrap();
458
459        // Should use all defaults
460        assert_eq!(config.defaults.max_turns, Some(6));
461        assert_eq!(config.defaults.packet_max_bytes, Some(65536));
462    }
463
464    #[test]
465    fn test_config_with_only_comments() {
466        let _guard = config_env_guard();
467        let _home = crate::paths::with_isolated_home();
468        let temp_dir = TempDir::new().unwrap();
469
470        // Config with only comments
471        let config_path = create_test_config_file(
472            temp_dir.path(),
473            r#"
474# This is a comment
475# Another comment
476# [defaults]
477# model = "sonnet"
478"#,
479        );
480
481        let cli_args = CliArgs {
482            config_path: Some(config_path),
483            ..Default::default()
484        };
485        let config = Config::discover(&cli_args).unwrap();
486
487        // Should use all defaults
488        assert_eq!(config.defaults.max_turns, Some(6));
489    }
490
491    #[test]
492    fn test_config_with_wrong_types() {
493        let _guard = config_env_guard();
494        let _home = crate::paths::with_isolated_home();
495        let temp_dir = TempDir::new().unwrap();
496
497        // Config with wrong types (string instead of number)
498        let config_path = create_test_config_file(
499            temp_dir.path(),
500            r#"
501[defaults]
502max_turns = "not a number"
503"#,
504        );
505
506        let cli_args = CliArgs {
507            config_path: Some(config_path),
508            ..Default::default()
509        };
510        let result = Config::discover(&cli_args);
511
512        assert!(
513            result.is_err(),
514            "Should fail when max_turns is a string instead of number"
515        );
516    }
517
518    #[test]
519    fn test_config_with_unknown_fields() {
520        let _guard = config_env_guard();
521        let _home = crate::paths::with_isolated_home();
522        let temp_dir = TempDir::new().unwrap();
523
524        // Config with unknown fields (should be ignored by serde's default behavior)
525        let config_path = create_test_config_file(
526            temp_dir.path(),
527            r#"
528[defaults]
529model = "sonnet"
530unknown_field = "should be ignored"
531another_unknown = 123
532
533[unknown_section]
534key = "value"
535"#,
536        );
537
538        let cli_args = CliArgs {
539            config_path: Some(config_path),
540            ..Default::default()
541        };
542        let config = Config::discover(&cli_args).unwrap();
543
544        // Should successfully load known fields and ignore unknown ones
545        assert_eq!(config.defaults.model, Some("sonnet".to_string()));
546    }
547
548    #[test]
549    fn test_config_validation_with_zero_values() {
550        let cli_args = CliArgs {
551            packet_max_bytes: Some(0), // Invalid
552            ..Default::default()
553        };
554
555        let result = Config::discover(&cli_args);
556        assert!(result.is_err());
557
558        // Assert on structured error type
559        let error = result.unwrap_err();
560        match error {
561            XCheckerError::Config(ConfigError::InvalidValue { key, .. }) => {
562                assert_eq!(key, "packet_max_bytes");
563            }
564            _ => panic!("Expected Config InvalidValue error for packet_max_bytes"),
565        }
566    }
567
568    #[test]
569    fn test_config_validation_with_excessive_values() {
570        // Test packet_max_bytes exceeding limit
571        let cli_args = CliArgs {
572            packet_max_bytes: Some(20_000_000), // Exceeds 10MB limit
573            ..Default::default()
574        };
575
576        let result = Config::discover(&cli_args);
577        assert!(result.is_err());
578
579        // Assert on structured error type
580        let error = result.unwrap_err();
581        match error {
582            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
583                assert_eq!(key, "packet_max_bytes");
584                assert!(value.contains("exceeds maximum"));
585            }
586            _ => panic!("Expected Config InvalidValue error for packet_max_bytes exceeding limit"),
587        }
588    }
589
590    #[test]
591    fn test_config_validation_with_invalid_runner_mode() {
592        let _guard = config_env_guard();
593        let _home = crate::paths::with_isolated_home();
594        let temp_dir = TempDir::new().unwrap();
595
596        let config_path = create_test_config_file(
597            temp_dir.path(),
598            r#"
599[runner]
600mode = "invalid_mode"
601"#,
602        );
603
604        let cli_args = CliArgs {
605            config_path: Some(config_path),
606            ..Default::default()
607        };
608        let result = Config::discover(&cli_args);
609
610        assert!(result.is_err(), "Should fail for invalid runner mode");
611        assert!(result.unwrap_err().to_string().contains("runner_mode"));
612    }
613
614    #[test]
615    fn test_config_validation_with_invalid_glob_patterns() {
616        let _guard = config_env_guard();
617        let _home = crate::paths::with_isolated_home();
618        let temp_dir = TempDir::new().unwrap();
619
620        let config_path = create_test_config_file(
621            temp_dir.path(),
622            r#"
623[selectors]
624include = ["[invalid-glob"]
625exclude = []
626"#,
627        );
628
629        let cli_args = CliArgs {
630            config_path: Some(config_path),
631            ..Default::default()
632        };
633        let result = Config::discover(&cli_args);
634
635        // The validation should catch the invalid glob pattern
636        assert!(result.is_err(), "Should fail for invalid glob pattern");
637        // The error chain includes the validation error about the glob
638        let err_msg = format!("{:?}", result.unwrap_err());
639        assert!(
640            err_msg.contains("glob")
641                || err_msg.contains("Invalid")
642                || err_msg.contains("pattern")
643                || err_msg.contains("selectors"),
644            "Error should be related to glob/pattern validation, got: {err_msg}"
645        );
646    }
647
648    #[test]
649    fn test_config_with_unicode_values() {
650        let _guard = config_env_guard();
651        let _home = crate::paths::with_isolated_home();
652        let temp_dir = TempDir::new().unwrap();
653
654        let config_path = create_test_config_file(
655            temp_dir.path(),
656            r#"
657[defaults]
658model = "claude-测试-🚀"
659
660[selectors]
661include = ["文档/**/*.md", "README-日本語.md"]
662exclude = []
663"#,
664        );
665
666        let cli_args = CliArgs {
667            config_path: Some(config_path),
668            ..Default::default()
669        };
670        let config = Config::discover(&cli_args).unwrap();
671
672        assert_eq!(config.defaults.model, Some("claude-测试-🚀".to_string()));
673        assert!(
674            config
675                .selectors
676                .include
677                .contains(&"文档/**/*.md".to_string())
678        );
679    }
680
681    #[test]
682    fn test_config_with_very_long_values() {
683        let _guard = config_env_guard();
684        let _home = crate::paths::with_isolated_home();
685        let temp_dir = TempDir::new().unwrap();
686
687        let long_model = "a".repeat(1000);
688        let config_content = format!(
689            r#"
690[defaults]
691model = "{long_model}"
692"#
693        );
694
695        let config_path = create_test_config_file(temp_dir.path(), &config_content);
696
697        let cli_args = CliArgs {
698            config_path: Some(config_path),
699            ..Default::default()
700        };
701        let config = Config::discover(&cli_args).unwrap();
702
703        assert_eq!(config.defaults.model, Some(long_model));
704    }
705
706    #[test]
707    fn test_config_with_special_characters() {
708        let _guard = config_env_guard();
709        let _home = crate::paths::with_isolated_home();
710        let temp_dir = TempDir::new().unwrap();
711
712        let config_path = create_test_config_file(
713            temp_dir.path(),
714            r#"
715[defaults]
716model = "sonnet-@#$%"
717
718[selectors]
719include = ["**/*.{rs,toml}", "path/with spaces/*.md"]
720exclude = ["**/[test]/**"]
721"#,
722        );
723
724        let cli_args = CliArgs {
725            config_path: Some(config_path),
726            ..Default::default()
727        };
728        let config = Config::discover(&cli_args).unwrap();
729
730        assert_eq!(config.defaults.model, Some("sonnet-@#$%".to_string()));
731        assert!(
732            config
733                .selectors
734                .include
735                .contains(&"path/with spaces/*.md".to_string())
736        );
737    }
738
739    #[test]
740    fn test_config_with_boundary_values() {
741        let _guard = config_env_guard();
742        let _home = crate::paths::with_isolated_home();
743        let temp_dir = TempDir::new().unwrap();
744
745        // Test minimum valid values
746        let config_path = create_test_config_file(
747            temp_dir.path(),
748            r"
749[defaults]
750max_turns = 1
751packet_max_bytes = 1
752packet_max_lines = 1
753phase_timeout = 5
754stdout_cap_bytes = 1024
755stderr_cap_bytes = 1024
756lock_ttl_seconds = 60
757",
758        );
759
760        let cli_args = CliArgs {
761            config_path: Some(config_path),
762            ..Default::default()
763        };
764        let config = Config::discover(&cli_args).unwrap();
765
766        assert_eq!(config.defaults.max_turns, Some(1));
767        assert_eq!(config.defaults.packet_max_bytes, Some(1));
768        assert_eq!(config.defaults.phase_timeout, Some(5));
769    }
770
771    #[test]
772    fn test_config_source_attribution_accuracy() {
773        let _guard = config_env_guard();
774        let _home = crate::paths::with_isolated_home();
775        let temp_dir = TempDir::new().unwrap();
776
777        let config_path = create_test_config_file(
778            temp_dir.path(),
779            r#"
780[defaults]
781model = "sonnet"
782max_turns = 10
783"#,
784        );
785
786        let cli_args = CliArgs {
787            config_path: Some(config_path),
788            verbose: Some(true), // CLI override
789            ..Default::default()
790        };
791
792        let config = Config::discover(&cli_args).unwrap();
793
794        // Check source attribution
795        assert_eq!(
796            config.source_attribution.get("verbose"),
797            Some(&ConfigSource::Cli)
798        );
799        assert!(matches!(
800            config.source_attribution.get("model"),
801            Some(ConfigSource::Config)
802        ));
803        assert_eq!(
804            config.source_attribution.get("packet_max_bytes"),
805            Some(&ConfigSource::Default)
806        );
807    }
808
809    // ===== Edge Case Tests for Task 9.7 =====
810
811    #[test]
812    fn test_config_source_attribution() {
813        let _guard = config_env_guard();
814        let _home = crate::paths::with_isolated_home();
815        let temp_dir = TempDir::new().unwrap();
816        let config_path = create_test_config_file(
817            temp_dir.path(),
818            r#"
819[defaults]
820model = "sonnet"
821max_turns = 10
822"#,
823        );
824
825        let cli_args = CliArgs {
826            config_path: Some(config_path),
827            model: Some("opus".to_string()), // CLI override
828            packet_max_bytes: Some(32768),   // CLI override
829            ..Default::default()
830        };
831
832        let config = Config::discover(&cli_args).unwrap();
833
834        // Check source attribution
835        assert!(matches!(
836            config.source_attribution.get("model"),
837            Some(ConfigSource::Cli)
838        ));
839        assert!(matches!(
840            config.source_attribution.get("max_turns"),
841            Some(ConfigSource::Config)
842        ));
843        assert!(matches!(
844            config.source_attribution.get("packet_max_bytes"),
845            Some(ConfigSource::Cli)
846        ));
847    }
848
849    // ===== LLM Provider and Execution Strategy Validation Tests (V11-V14 enforcement) =====
850
851    #[test]
852    fn test_llm_provider_defaults_to_claude_cli() {
853        let _guard = config_env_guard();
854        let _home = crate::paths::with_isolated_home();
855        let cli_args = CliArgs::default();
856
857        let config = Config::discover(&cli_args).unwrap();
858
859        // Should default to claude-cli
860        assert_eq!(config.llm.provider, Some("claude-cli".to_string()));
861        assert_eq!(
862            config.source_attribution.get("llm_provider"),
863            Some(&ConfigSource::Default)
864        );
865    }
866
867    #[test]
868    fn test_execution_strategy_defaults_to_controlled() {
869        let _guard = config_env_guard();
870        let _home = crate::paths::with_isolated_home();
871        let cli_args = CliArgs::default();
872
873        let config = Config::discover(&cli_args).unwrap();
874
875        // Should default to controlled
876        assert_eq!(
877            config.llm.execution_strategy,
878            Some("controlled".to_string())
879        );
880        assert_eq!(
881            config.source_attribution.get("execution_strategy"),
882            Some(&ConfigSource::Default)
883        );
884    }
885
886    #[test]
887    fn test_llm_provider_rejects_invalid_providers() {
888        let _guard = config_env_guard();
889        let _home = crate::paths::with_isolated_home();
890
891        // In V14, claude-cli, gemini-cli, openrouter, and anthropic are valid
892        // Only unknown providers should be rejected
893        let invalid_providers = vec!["openai", "invalid"];
894
895        for provider in invalid_providers {
896            let cli_args = CliArgs {
897                llm_provider: Some(provider.to_string()),
898                ..Default::default()
899            };
900
901            let result = Config::discover(&cli_args);
902            assert!(result.is_err(), "Should reject provider: {}", provider);
903
904            let error = result.unwrap_err();
905            match error {
906                XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
907                    assert_eq!(key, "llm.provider");
908                    assert!(
909                        value.contains(provider),
910                        "Error message should mention the invalid provider: {}",
911                        value
912                    );
913                    // anthropic should mention V14+, others should mention supported providers
914                    if provider == "anthropic" {
915                        assert!(
916                            value.contains("V14+") || value.contains("reserved"),
917                            "Error message should mention version restriction for anthropic: {}",
918                            value
919                        );
920                    } else {
921                        assert!(
922                            value.contains("Supported providers")
923                                || value.contains("not supported"),
924                            "Error message should mention supported providers: {}",
925                            value
926                        );
927                    }
928                }
929                _ => panic!("Expected Config InvalidValue error for llm.provider"),
930            }
931        }
932    }
933
934    #[test]
935    fn test_llm_fallback_provider_from_config_file() {
936        let _guard = config_env_guard();
937        let _home = crate::paths::with_isolated_home();
938        let temp_dir = TempDir::new().unwrap();
939        let config_path = create_test_config_file(
940            temp_dir.path(),
941            r#"
942[llm]
943provider = "claude-cli"
944fallback_provider = "anthropic"
945
946[llm.anthropic]
947model = "claude-sonnet-4-20250514"
948"#,
949        );
950
951        let cli_args = CliArgs {
952            config_path: Some(config_path),
953            ..Default::default()
954        };
955
956        let config = Config::discover(&cli_args).unwrap();
957
958        assert_eq!(config.llm.fallback_provider, Some("anthropic".to_string()));
959        assert_eq!(
960            config.source_attribution.get("llm_fallback_provider"),
961            Some(&ConfigSource::Config)
962        );
963    }
964
965    #[test]
966    fn test_llm_fallback_provider_rejects_invalid_provider() {
967        let _guard = config_env_guard();
968        let _home = crate::paths::with_isolated_home();
969        let temp_dir = TempDir::new().unwrap();
970        let config_path = create_test_config_file(
971            temp_dir.path(),
972            r#"
973[llm]
974provider = "claude-cli"
975fallback_provider = "invalid"
976"#,
977        );
978
979        let cli_args = CliArgs {
980            config_path: Some(config_path),
981            ..Default::default()
982        };
983
984        let result = Config::discover(&cli_args);
985        assert!(result.is_err(), "Should reject invalid fallback provider");
986
987        let error = result.unwrap_err();
988        match error {
989            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
990                assert_eq!(key, "llm.fallback_provider");
991                assert!(
992                    value.contains("invalid"),
993                    "Error message should mention the invalid provider: {}",
994                    value
995                );
996            }
997            _ => panic!("Expected Config InvalidValue error for llm.fallback_provider"),
998        }
999    }
1000
1001    #[test]
1002    fn test_llm_fallback_provider_prompt_template_incompatible() {
1003        let _guard = config_env_guard();
1004        let _home = crate::paths::with_isolated_home();
1005        let temp_dir = TempDir::new().unwrap();
1006        let config_path = create_test_config_file(
1007            temp_dir.path(),
1008            r#"
1009[llm]
1010provider = "claude-cli"
1011fallback_provider = "openrouter"
1012prompt_template = "claude-optimized"
1013
1014[llm.openrouter]
1015model = "google/gemini-2.0-flash-lite"
1016"#,
1017        );
1018
1019        let cli_args = CliArgs {
1020            config_path: Some(config_path),
1021            ..Default::default()
1022        };
1023
1024        let result = Config::discover(&cli_args);
1025        assert!(
1026            result.is_err(),
1027            "Should reject incompatible prompt_template for fallback provider"
1028        );
1029
1030        let error = result.unwrap_err();
1031        match error {
1032            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
1033                assert_eq!(key, "llm.prompt_template");
1034                assert!(
1035                    value.contains("openrouter"),
1036                    "Error should mention fallback provider, got: {}",
1037                    value
1038                );
1039            }
1040            _ => panic!("Expected Config InvalidValue error for llm.prompt_template"),
1041        }
1042    }
1043
1044    #[test]
1045    fn test_execution_strategy_rejects_invalid_strategies() {
1046        let _guard = config_env_guard();
1047        let _home = crate::paths::with_isolated_home();
1048
1049        let invalid_strategies = vec!["externaltool", "external_tool", "agent", "batch", "invalid"];
1050
1051        for strategy in invalid_strategies {
1052            let cli_args = CliArgs {
1053                execution_strategy: Some(strategy.to_string()),
1054                ..Default::default()
1055            };
1056
1057            let result = Config::discover(&cli_args);
1058            assert!(
1059                result.is_err(),
1060                "Should reject execution strategy: {}",
1061                strategy
1062            );
1063
1064            let error = result.unwrap_err();
1065            match error {
1066                XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
1067                    assert_eq!(key, "llm.execution_strategy");
1068                    assert!(
1069                        value.contains(strategy),
1070                        "Error message should mention the invalid strategy: {}",
1071                        value
1072                    );
1073                    assert!(
1074                        value.contains("V11-V14"),
1075                        "Error message should mention version restriction: {}",
1076                        value
1077                    );
1078                }
1079                _ => panic!("Expected Config InvalidValue error for llm.execution_strategy"),
1080            }
1081        }
1082    }
1083
1084    #[test]
1085    fn test_llm_provider_accepts_claude_cli() {
1086        let _guard = config_env_guard();
1087        let _home = crate::paths::with_isolated_home();
1088
1089        let cli_args = CliArgs {
1090            llm_provider: Some("claude-cli".to_string()),
1091            ..Default::default()
1092        };
1093
1094        let config = Config::discover(&cli_args).unwrap();
1095        assert_eq!(config.llm.provider, Some("claude-cli".to_string()));
1096    }
1097
1098    #[test]
1099    fn test_execution_strategy_accepts_controlled() {
1100        let _guard = config_env_guard();
1101        let _home = crate::paths::with_isolated_home();
1102
1103        let cli_args = CliArgs {
1104            execution_strategy: Some("controlled".to_string()),
1105            ..Default::default()
1106        };
1107
1108        let config = Config::discover(&cli_args).unwrap();
1109        assert_eq!(
1110            config.llm.execution_strategy,
1111            Some("controlled".to_string())
1112        );
1113    }
1114
1115    #[test]
1116    fn test_llm_config_from_config_file_with_invalid_provider() {
1117        let _guard = config_env_guard();
1118        let _home = crate::paths::with_isolated_home();
1119        let temp_dir = TempDir::new().unwrap();
1120
1121        // Use a truly invalid provider that will never be supported
1122        let config_path = create_test_config_file(
1123            temp_dir.path(),
1124            r#"
1125[llm]
1126provider = "invalid-provider-xyz"
1127"#,
1128        );
1129
1130        let cli_args = CliArgs {
1131            config_path: Some(config_path),
1132            ..Default::default()
1133        };
1134
1135        let result = Config::discover(&cli_args);
1136        assert!(
1137            result.is_err(),
1138            "Should reject invalid provider from config file"
1139        );
1140
1141        let error = result.unwrap_err();
1142        match error {
1143            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
1144                assert_eq!(key, "llm.provider");
1145                assert!(value.contains("invalid-provider-xyz"));
1146            }
1147            _ => panic!("Expected Config InvalidValue error"),
1148        }
1149    }
1150
1151    #[test]
1152    fn test_llm_config_from_config_file_with_invalid_strategy() {
1153        let _guard = config_env_guard();
1154        let _home = crate::paths::with_isolated_home();
1155        let temp_dir = TempDir::new().unwrap();
1156
1157        let config_path = create_test_config_file(
1158            temp_dir.path(),
1159            r#"
1160[llm]
1161execution_strategy = "externaltool"
1162"#,
1163        );
1164
1165        let cli_args = CliArgs {
1166            config_path: Some(config_path),
1167            ..Default::default()
1168        };
1169
1170        let result = Config::discover(&cli_args);
1171        assert!(
1172            result.is_err(),
1173            "Should reject invalid execution strategy from config file"
1174        );
1175
1176        let error = result.unwrap_err();
1177        match error {
1178            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
1179                assert_eq!(key, "llm.execution_strategy");
1180                assert!(value.contains("externaltool"));
1181            }
1182            _ => panic!("Expected Config InvalidValue error"),
1183        }
1184    }
1185
1186    #[test]
1187    fn test_llm_config_from_config_file_with_valid_values() {
1188        let _guard = config_env_guard();
1189        let _home = crate::paths::with_isolated_home();
1190        let temp_dir = TempDir::new().unwrap();
1191
1192        let config_path = create_test_config_file(
1193            temp_dir.path(),
1194            r#"
1195[llm]
1196provider = "claude-cli"
1197execution_strategy = "controlled"
1198"#,
1199        );
1200
1201        let cli_args = CliArgs {
1202            config_path: Some(config_path.clone()),
1203            ..Default::default()
1204        };
1205
1206        let config = Config::discover(&cli_args).unwrap();
1207
1208        assert_eq!(config.llm.provider, Some("claude-cli".to_string()));
1209        assert_eq!(
1210            config.llm.execution_strategy,
1211            Some("controlled".to_string())
1212        );
1213
1214        // Verify source attribution
1215        assert!(matches!(
1216            config.source_attribution.get("llm_provider"),
1217            Some(ConfigSource::Config)
1218        ));
1219        assert!(matches!(
1220            config.source_attribution.get("execution_strategy"),
1221            Some(ConfigSource::Config)
1222        ));
1223    }
1224
1225    #[test]
1226    fn test_llm_config_cli_overrides_config_file() {
1227        let _guard = config_env_guard();
1228        let _home = crate::paths::with_isolated_home();
1229        let temp_dir = TempDir::new().unwrap();
1230
1231        let config_path = create_test_config_file(
1232            temp_dir.path(),
1233            r#"
1234[llm]
1235provider = "claude-cli"
1236execution_strategy = "controlled"
1237"#,
1238        );
1239
1240        // CLI args explicitly set same values (should override with Cli source)
1241        let cli_args = CliArgs {
1242            config_path: Some(config_path),
1243            llm_provider: Some("claude-cli".to_string()),
1244            execution_strategy: Some("controlled".to_string()),
1245            ..Default::default()
1246        };
1247
1248        let config = Config::discover(&cli_args).unwrap();
1249
1250        assert_eq!(config.llm.provider, Some("claude-cli".to_string()));
1251        assert_eq!(
1252            config.llm.execution_strategy,
1253            Some("controlled".to_string())
1254        );
1255
1256        // Verify CLI takes precedence in source attribution
1257        assert_eq!(
1258            config.source_attribution.get("llm_provider"),
1259            Some(&ConfigSource::Cli)
1260        );
1261        assert_eq!(
1262            config.source_attribution.get("execution_strategy"),
1263            Some(&ConfigSource::Cli)
1264        );
1265    }
1266
1267    // ===== Prompt Template Validation Tests (Requirement 3.7.6) =====
1268
1269    #[test]
1270    fn test_prompt_template_parsing() {
1271        // Test valid template names
1272        assert_eq!(
1273            PromptTemplate::parse("default").unwrap(),
1274            PromptTemplate::Default
1275        );
1276        assert_eq!(
1277            PromptTemplate::parse("claude-optimized").unwrap(),
1278            PromptTemplate::ClaudeOptimized
1279        );
1280        assert_eq!(
1281            PromptTemplate::parse("claude_optimized").unwrap(),
1282            PromptTemplate::ClaudeOptimized
1283        );
1284        assert_eq!(
1285            PromptTemplate::parse("claude").unwrap(),
1286            PromptTemplate::ClaudeOptimized
1287        );
1288        assert_eq!(
1289            PromptTemplate::parse("openai-compatible").unwrap(),
1290            PromptTemplate::OpenAiCompatible
1291        );
1292        assert_eq!(
1293            PromptTemplate::parse("openai_compatible").unwrap(),
1294            PromptTemplate::OpenAiCompatible
1295        );
1296        assert_eq!(
1297            PromptTemplate::parse("openai").unwrap(),
1298            PromptTemplate::OpenAiCompatible
1299        );
1300        assert_eq!(
1301            PromptTemplate::parse("openrouter").unwrap(),
1302            PromptTemplate::OpenAiCompatible
1303        );
1304
1305        // Test case insensitivity
1306        assert_eq!(
1307            PromptTemplate::parse("DEFAULT").unwrap(),
1308            PromptTemplate::Default
1309        );
1310        assert_eq!(
1311            PromptTemplate::parse("Claude-Optimized").unwrap(),
1312            PromptTemplate::ClaudeOptimized
1313        );
1314
1315        // Test invalid template names
1316        assert!(PromptTemplate::parse("invalid").is_err());
1317        assert!(PromptTemplate::parse("unknown-template").is_err());
1318    }
1319
1320    #[test]
1321    fn test_prompt_template_provider_compatibility() {
1322        // Default template is compatible with all providers
1323        assert!(
1324            PromptTemplate::Default
1325                .validate_provider_compatibility("claude-cli")
1326                .is_ok()
1327        );
1328        assert!(
1329            PromptTemplate::Default
1330                .validate_provider_compatibility("gemini-cli")
1331                .is_ok()
1332        );
1333        assert!(
1334            PromptTemplate::Default
1335                .validate_provider_compatibility("openrouter")
1336                .is_ok()
1337        );
1338        assert!(
1339            PromptTemplate::Default
1340                .validate_provider_compatibility("anthropic")
1341                .is_ok()
1342        );
1343
1344        // Claude-optimized template is compatible with Claude CLI and Anthropic
1345        assert!(
1346            PromptTemplate::ClaudeOptimized
1347                .validate_provider_compatibility("claude-cli")
1348                .is_ok()
1349        );
1350        assert!(
1351            PromptTemplate::ClaudeOptimized
1352                .validate_provider_compatibility("anthropic")
1353                .is_ok()
1354        );
1355        assert!(
1356            PromptTemplate::ClaudeOptimized
1357                .validate_provider_compatibility("gemini-cli")
1358                .is_err()
1359        );
1360        assert!(
1361            PromptTemplate::ClaudeOptimized
1362                .validate_provider_compatibility("openrouter")
1363                .is_err()
1364        );
1365
1366        // OpenAI-compatible template is compatible with OpenRouter and Gemini
1367        assert!(
1368            PromptTemplate::OpenAiCompatible
1369                .validate_provider_compatibility("openrouter")
1370                .is_ok()
1371        );
1372        assert!(
1373            PromptTemplate::OpenAiCompatible
1374                .validate_provider_compatibility("gemini-cli")
1375                .is_ok()
1376        );
1377        assert!(
1378            PromptTemplate::OpenAiCompatible
1379                .validate_provider_compatibility("claude-cli")
1380                .is_err()
1381        );
1382        assert!(
1383            PromptTemplate::OpenAiCompatible
1384                .validate_provider_compatibility("anthropic")
1385                .is_err()
1386        );
1387    }
1388
1389    #[test]
1390    fn test_prompt_template_as_str() {
1391        assert_eq!(PromptTemplate::Default.as_str(), "default");
1392        assert_eq!(PromptTemplate::ClaudeOptimized.as_str(), "claude-optimized");
1393        assert_eq!(
1394            PromptTemplate::OpenAiCompatible.as_str(),
1395            "openai-compatible"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_prompt_template_compatible_providers() {
1401        assert_eq!(
1402            PromptTemplate::Default.compatible_providers(),
1403            &["claude-cli", "gemini-cli", "openrouter", "anthropic"]
1404        );
1405        assert_eq!(
1406            PromptTemplate::ClaudeOptimized.compatible_providers(),
1407            &["claude-cli", "anthropic"]
1408        );
1409        assert_eq!(
1410            PromptTemplate::OpenAiCompatible.compatible_providers(),
1411            &["openrouter", "gemini-cli"]
1412        );
1413    }
1414
1415    #[test]
1416    fn test_config_with_valid_prompt_template() {
1417        let _guard = config_env_guard();
1418        let _home = crate::paths::with_isolated_home();
1419        let temp_dir = TempDir::new().unwrap();
1420
1421        // Test default template with claude-cli
1422        let config_path = create_test_config_file(
1423            temp_dir.path(),
1424            r#"
1425[llm]
1426provider = "claude-cli"
1427prompt_template = "default"
1428"#,
1429        );
1430
1431        let cli_args = CliArgs {
1432            config_path: Some(config_path),
1433            ..Default::default()
1434        };
1435
1436        let config = Config::discover(&cli_args).unwrap();
1437        assert_eq!(config.llm.prompt_template, Some("default".to_string()));
1438    }
1439
1440    #[test]
1441    fn test_config_with_claude_optimized_template_and_claude_provider() {
1442        let _guard = config_env_guard();
1443        let _home = crate::paths::with_isolated_home();
1444        let temp_dir = TempDir::new().unwrap();
1445
1446        let config_path = create_test_config_file(
1447            temp_dir.path(),
1448            r#"
1449[llm]
1450provider = "claude-cli"
1451prompt_template = "claude-optimized"
1452"#,
1453        );
1454
1455        let cli_args = CliArgs {
1456            config_path: Some(config_path),
1457            ..Default::default()
1458        };
1459
1460        let config = Config::discover(&cli_args).unwrap();
1461        assert_eq!(
1462            config.llm.prompt_template,
1463            Some("claude-optimized".to_string())
1464        );
1465    }
1466
1467    #[test]
1468    fn test_config_with_openai_compatible_template_and_openrouter_provider() {
1469        let _guard = config_env_guard();
1470        let _home = crate::paths::with_isolated_home();
1471        let temp_dir = TempDir::new().unwrap();
1472
1473        let config_path = create_test_config_file(
1474            temp_dir.path(),
1475            r#"
1476[llm]
1477provider = "openrouter"
1478prompt_template = "openai-compatible"
1479
1480[llm.openrouter]
1481model = "google/gemini-2.0-flash-lite"
1482"#,
1483        );
1484
1485        let cli_args = CliArgs {
1486            config_path: Some(config_path),
1487            ..Default::default()
1488        };
1489
1490        let config = Config::discover(&cli_args).unwrap();
1491        assert_eq!(
1492            config.llm.prompt_template,
1493            Some("openai-compatible".to_string())
1494        );
1495    }
1496
1497    #[test]
1498    fn test_config_rejects_incompatible_template_and_provider() {
1499        let _guard = config_env_guard();
1500        let _home = crate::paths::with_isolated_home();
1501        let temp_dir = TempDir::new().unwrap();
1502
1503        // Claude-optimized template with OpenRouter provider should fail
1504        let config_path = create_test_config_file(
1505            temp_dir.path(),
1506            r#"
1507[llm]
1508provider = "openrouter"
1509prompt_template = "claude-optimized"
1510
1511[llm.openrouter]
1512model = "google/gemini-2.0-flash-lite"
1513"#,
1514        );
1515
1516        let cli_args = CliArgs {
1517            config_path: Some(config_path),
1518            ..Default::default()
1519        };
1520
1521        let result = Config::discover(&cli_args);
1522        assert!(
1523            result.is_err(),
1524            "Should reject incompatible template and provider"
1525        );
1526
1527        let error = result.unwrap_err();
1528        match error {
1529            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
1530                assert_eq!(key, "llm.prompt_template");
1531                assert!(value.contains("not compatible"));
1532                assert!(value.contains("openrouter"));
1533            }
1534            _ => panic!("Expected Config InvalidValue error for incompatible template"),
1535        }
1536    }
1537
1538    #[test]
1539    fn test_config_rejects_openai_template_with_claude_provider() {
1540        let _guard = config_env_guard();
1541        let _home = crate::paths::with_isolated_home();
1542        let temp_dir = TempDir::new().unwrap();
1543
1544        // OpenAI-compatible template with Claude CLI provider should fail
1545        let config_path = create_test_config_file(
1546            temp_dir.path(),
1547            r#"
1548[llm]
1549provider = "claude-cli"
1550prompt_template = "openai-compatible"
1551"#,
1552        );
1553
1554        let cli_args = CliArgs {
1555            config_path: Some(config_path),
1556            ..Default::default()
1557        };
1558
1559        let result = Config::discover(&cli_args);
1560        assert!(
1561            result.is_err(),
1562            "Should reject incompatible template and provider"
1563        );
1564
1565        let error = result.unwrap_err();
1566        match error {
1567            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
1568                assert_eq!(key, "llm.prompt_template");
1569                assert!(value.contains("not compatible"));
1570                assert!(value.contains("claude-cli"));
1571            }
1572            _ => panic!("Expected Config InvalidValue error for incompatible template"),
1573        }
1574    }
1575
1576    #[test]
1577    fn test_config_rejects_invalid_template_name() {
1578        let _guard = config_env_guard();
1579        let _home = crate::paths::with_isolated_home();
1580        let temp_dir = TempDir::new().unwrap();
1581
1582        let config_path = create_test_config_file(
1583            temp_dir.path(),
1584            r#"
1585[llm]
1586provider = "claude-cli"
1587prompt_template = "invalid-template-name"
1588"#,
1589        );
1590
1591        let cli_args = CliArgs {
1592            config_path: Some(config_path),
1593            ..Default::default()
1594        };
1595
1596        let result = Config::discover(&cli_args);
1597        assert!(result.is_err(), "Should reject invalid template name");
1598
1599        let error = result.unwrap_err();
1600        match error {
1601            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
1602                assert_eq!(key, "llm.prompt_template");
1603                assert!(value.contains("Unknown prompt template"));
1604                assert!(value.contains("invalid-template-name"));
1605            }
1606            _ => panic!("Expected Config InvalidValue error for invalid template name"),
1607        }
1608    }
1609
1610    #[test]
1611    fn test_config_without_prompt_template_uses_default() {
1612        let _guard = config_env_guard();
1613        let _home = crate::paths::with_isolated_home();
1614        let temp_dir = TempDir::new().unwrap();
1615
1616        // Config without prompt_template should work (uses implicit default)
1617        let config_path = create_test_config_file(
1618            temp_dir.path(),
1619            r#"
1620[llm]
1621provider = "claude-cli"
1622"#,
1623        );
1624
1625        let cli_args = CliArgs {
1626            config_path: Some(config_path),
1627            ..Default::default()
1628        };
1629
1630        let config = Config::discover(&cli_args).unwrap();
1631        // prompt_template should be None (implicit default behavior)
1632        assert_eq!(config.llm.prompt_template, None);
1633    }
1634
1635    // ===== Per-Phase Model Configuration Tests (B-series feature) =====
1636
1637    #[test]
1638    fn test_model_for_phase_defaults_to_global() {
1639        use crate::types::PhaseId;
1640
1641        let mut cfg = Config::minimal_for_testing();
1642        cfg.defaults.model = Some("haiku".to_string());
1643
1644        // All phases should use the global default
1645        assert_eq!(cfg.model_for_phase(PhaseId::Requirements), "haiku");
1646        assert_eq!(cfg.model_for_phase(PhaseId::Design), "haiku");
1647        assert_eq!(cfg.model_for_phase(PhaseId::Tasks), "haiku");
1648        assert_eq!(cfg.model_for_phase(PhaseId::Review), "haiku");
1649        assert_eq!(cfg.model_for_phase(PhaseId::Fixup), "haiku");
1650        assert_eq!(cfg.model_for_phase(PhaseId::Final), "haiku");
1651    }
1652
1653    #[test]
1654    fn test_model_for_phase_defaults_to_haiku_when_no_global() {
1655        use crate::types::PhaseId;
1656
1657        let cfg = Config::minimal_for_testing();
1658        // No model set anywhere - should default to "haiku"
1659
1660        assert_eq!(cfg.model_for_phase(PhaseId::Requirements), "haiku");
1661        assert_eq!(cfg.model_for_phase(PhaseId::Design), "haiku");
1662    }
1663
1664    #[test]
1665    fn test_model_for_phase_with_overrides() {
1666        use crate::types::PhaseId;
1667
1668        let mut cfg = Config::minimal_for_testing();
1669        cfg.defaults.model = Some("haiku".to_string());
1670
1671        // Set per-phase overrides for design and tasks
1672        cfg.phases.design = Some(PhaseConfig {
1673            model: Some("sonnet".to_string()),
1674            ..Default::default()
1675        });
1676        cfg.phases.tasks = Some(PhaseConfig {
1677            model: Some("sonnet".to_string()),
1678            ..Default::default()
1679        });
1680
1681        // Requirements should use global default
1682        assert_eq!(cfg.model_for_phase(PhaseId::Requirements), "haiku");
1683        // Design and Tasks should use per-phase override
1684        assert_eq!(cfg.model_for_phase(PhaseId::Design), "sonnet");
1685        assert_eq!(cfg.model_for_phase(PhaseId::Tasks), "sonnet");
1686        // Review should use global default
1687        assert_eq!(cfg.model_for_phase(PhaseId::Review), "haiku");
1688    }
1689
1690    #[test]
1691    fn test_model_for_phase_override_without_global_default() {
1692        use crate::types::PhaseId;
1693
1694        let mut cfg = Config::minimal_for_testing();
1695        // No global model set
1696
1697        // Set per-phase override only for design
1698        cfg.phases.design = Some(PhaseConfig {
1699            model: Some("opus".to_string()),
1700            ..Default::default()
1701        });
1702
1703        // Design should use per-phase override
1704        assert_eq!(cfg.model_for_phase(PhaseId::Design), "opus");
1705        // Other phases should fall back to hard default "haiku"
1706        assert_eq!(cfg.model_for_phase(PhaseId::Requirements), "haiku");
1707        assert_eq!(cfg.model_for_phase(PhaseId::Tasks), "haiku");
1708    }
1709
1710    #[test]
1711    fn test_model_for_phase_with_all_overrides() {
1712        use crate::types::PhaseId;
1713
1714        let mut cfg = Config::minimal_for_testing();
1715        cfg.defaults.model = Some("haiku".to_string());
1716
1717        // Set different models for each phase
1718        cfg.phases.requirements = Some(PhaseConfig {
1719            model: Some("haiku".to_string()),
1720            ..Default::default()
1721        });
1722        cfg.phases.design = Some(PhaseConfig {
1723            model: Some("sonnet".to_string()),
1724            ..Default::default()
1725        });
1726        cfg.phases.tasks = Some(PhaseConfig {
1727            model: Some("sonnet".to_string()),
1728            ..Default::default()
1729        });
1730        cfg.phases.review = Some(PhaseConfig {
1731            model: Some("opus".to_string()),
1732            ..Default::default()
1733        });
1734        cfg.phases.fixup = Some(PhaseConfig {
1735            model: Some("haiku".to_string()),
1736            ..Default::default()
1737        });
1738        cfg.phases.final_ = Some(PhaseConfig {
1739            model: Some("opus".to_string()),
1740            ..Default::default()
1741        });
1742
1743        assert_eq!(cfg.model_for_phase(PhaseId::Requirements), "haiku");
1744        assert_eq!(cfg.model_for_phase(PhaseId::Design), "sonnet");
1745        assert_eq!(cfg.model_for_phase(PhaseId::Tasks), "sonnet");
1746        assert_eq!(cfg.model_for_phase(PhaseId::Review), "opus");
1747        assert_eq!(cfg.model_for_phase(PhaseId::Fixup), "haiku");
1748        assert_eq!(cfg.model_for_phase(PhaseId::Final), "opus");
1749    }
1750
1751    #[test]
1752    fn test_phases_config_from_toml_file() {
1753        let _guard = config_env_guard();
1754        let _home = crate::paths::with_isolated_home();
1755        let temp_dir = TempDir::new().unwrap();
1756
1757        let config_path = create_test_config_file(
1758            temp_dir.path(),
1759            r#"
1760[defaults]
1761model = "haiku"
1762
1763[phases.design]
1764model = "sonnet"
1765
1766[phases.tasks]
1767model = "sonnet"
1768"#,
1769        );
1770
1771        let cli_args = CliArgs {
1772            config_path: Some(config_path),
1773            ..Default::default()
1774        };
1775
1776        let config = Config::discover(&cli_args).unwrap();
1777
1778        use crate::types::PhaseId;
1779
1780        // Requirements should use global default
1781        assert_eq!(config.model_for_phase(PhaseId::Requirements), "haiku");
1782        // Design and Tasks should use per-phase override
1783        assert_eq!(config.model_for_phase(PhaseId::Design), "sonnet");
1784        assert_eq!(config.model_for_phase(PhaseId::Tasks), "sonnet");
1785        // Review should use global default
1786        assert_eq!(config.model_for_phase(PhaseId::Review), "haiku");
1787    }
1788
1789    #[test]
1790    fn test_phases_config_with_all_fields() {
1791        let _guard = config_env_guard();
1792        let _home = crate::paths::with_isolated_home();
1793        let temp_dir = TempDir::new().unwrap();
1794
1795        let config_path = create_test_config_file(
1796            temp_dir.path(),
1797            r#"
1798[defaults]
1799model = "haiku"
1800max_turns = 6
1801
1802[phases.review]
1803model = "opus"
1804max_turns = 10
1805phase_timeout = 1200
1806"#,
1807        );
1808
1809        let cli_args = CliArgs {
1810            config_path: Some(config_path),
1811            ..Default::default()
1812        };
1813
1814        let config = Config::discover(&cli_args).unwrap();
1815
1816        // Verify phases config was loaded
1817        assert!(config.phases.review.is_some());
1818        let review_config = config.phases.review.as_ref().unwrap();
1819        assert_eq!(review_config.model, Some("opus".to_string()));
1820        assert_eq!(review_config.max_turns, Some(10));
1821        assert_eq!(review_config.phase_timeout, Some(1200));
1822
1823        // Verify model_for_phase works
1824        use crate::types::PhaseId;
1825        assert_eq!(config.model_for_phase(PhaseId::Review), "opus");
1826    }
1827
1828    #[test]
1829    fn test_phases_config_empty_section() {
1830        let _guard = config_env_guard();
1831        let _home = crate::paths::with_isolated_home();
1832        let temp_dir = TempDir::new().unwrap();
1833
1834        // Empty phases section should not cause errors
1835        let config_path = create_test_config_file(
1836            temp_dir.path(),
1837            r#"
1838[defaults]
1839model = "haiku"
1840
1841[phases]
1842"#,
1843        );
1844
1845        let cli_args = CliArgs {
1846            config_path: Some(config_path),
1847            ..Default::default()
1848        };
1849
1850        let config = Config::discover(&cli_args).unwrap();
1851
1852        use crate::types::PhaseId;
1853        // Should use global defaults since no per-phase overrides
1854        assert_eq!(config.model_for_phase(PhaseId::Requirements), "haiku");
1855        assert_eq!(config.model_for_phase(PhaseId::Design), "haiku");
1856    }
1857
1858    #[test]
1859    fn test_phases_final_uses_serde_rename() {
1860        let _guard = config_env_guard();
1861        let _home = crate::paths::with_isolated_home();
1862        let temp_dir = TempDir::new().unwrap();
1863
1864        // "final" is a reserved keyword in Rust, so we use "final_" internally
1865        // but TOML uses "final"
1866        let config_path = create_test_config_file(
1867            temp_dir.path(),
1868            r#"
1869[defaults]
1870model = "haiku"
1871
1872[phases.final]
1873model = "opus"
1874"#,
1875        );
1876
1877        let cli_args = CliArgs {
1878            config_path: Some(config_path),
1879            ..Default::default()
1880        };
1881
1882        let config = Config::discover(&cli_args).unwrap();
1883
1884        use crate::types::PhaseId;
1885        assert_eq!(config.model_for_phase(PhaseId::Final), "opus");
1886    }
1887
1888    // ===== Strict Validation Configuration Tests (P1 feature) =====
1889
1890    #[test]
1891    fn test_strict_validation_defaults_to_false() {
1892        let cfg = Config::minimal_for_testing();
1893        // Default should be false (soft validation)
1894        assert!(!cfg.strict_validation());
1895    }
1896
1897    #[test]
1898    fn test_strict_validation_when_set_true() {
1899        let mut cfg = Config::minimal_for_testing();
1900        cfg.defaults.strict_validation = Some(true);
1901        assert!(cfg.strict_validation());
1902    }
1903
1904    #[test]
1905    fn test_strict_validation_when_set_false() {
1906        let mut cfg = Config::minimal_for_testing();
1907        cfg.defaults.strict_validation = Some(false);
1908        assert!(!cfg.strict_validation());
1909    }
1910
1911    #[test]
1912    fn test_strict_validation_from_toml_file() {
1913        let _guard = config_env_guard();
1914        let _home = crate::paths::with_isolated_home();
1915        let temp_dir = TempDir::new().unwrap();
1916
1917        let config_path = create_test_config_file(
1918            temp_dir.path(),
1919            r#"
1920[defaults]
1921strict_validation = true
1922"#,
1923        );
1924
1925        let cli_args = CliArgs {
1926            config_path: Some(config_path),
1927            ..Default::default()
1928        };
1929
1930        let config = Config::discover(&cli_args).unwrap();
1931        assert!(config.strict_validation());
1932    }
1933
1934    #[test]
1935    fn test_strict_validation_from_toml_file_false() {
1936        let _guard = config_env_guard();
1937        let _home = crate::paths::with_isolated_home();
1938        let temp_dir = TempDir::new().unwrap();
1939
1940        let config_path = create_test_config_file(
1941            temp_dir.path(),
1942            r#"
1943[defaults]
1944strict_validation = false
1945"#,
1946        );
1947
1948        let cli_args = CliArgs {
1949            config_path: Some(config_path),
1950            ..Default::default()
1951        };
1952
1953        let config = Config::discover(&cli_args).unwrap();
1954        assert!(!config.strict_validation());
1955    }
1956
1957    // ===== ConfigBuilder Tests (Task 2.1) =====
1958
1959    #[test]
1960    fn test_config_builder_default() {
1961        let config = Config::builder().build().unwrap();
1962
1963        // Should use all defaults
1964        assert_eq!(config.defaults.max_turns, Some(6));
1965        assert_eq!(config.defaults.packet_max_bytes, Some(65536));
1966        assert_eq!(config.defaults.packet_max_lines, Some(1200));
1967        assert_eq!(config.defaults.phase_timeout, Some(600));
1968        assert_eq!(config.runner.mode, Some("auto".to_string()));
1969        assert_eq!(config.llm.provider, Some("claude-cli".to_string()));
1970        assert_eq!(
1971            config.llm.execution_strategy,
1972            Some("controlled".to_string())
1973        );
1974    }
1975
1976    #[test]
1977    fn test_config_builder_with_packet_max_bytes() {
1978        let config = Config::builder().packet_max_bytes(32768).build().unwrap();
1979
1980        assert_eq!(config.defaults.packet_max_bytes, Some(32768));
1981        assert_eq!(
1982            config.source_attribution.get("packet_max_bytes"),
1983            Some(&ConfigSource::Programmatic)
1984        );
1985    }
1986
1987    #[test]
1988    fn test_config_builder_with_packet_max_lines() {
1989        let config = Config::builder().packet_max_lines(600).build().unwrap();
1990
1991        assert_eq!(config.defaults.packet_max_lines, Some(600));
1992        assert_eq!(
1993            config.source_attribution.get("packet_max_lines"),
1994            Some(&ConfigSource::Programmatic)
1995        );
1996    }
1997
1998    #[test]
1999    fn test_config_builder_with_phase_timeout() {
2000        use std::time::Duration;
2001
2002        let config = Config::builder()
2003            .phase_timeout(Duration::from_secs(300))
2004            .build()
2005            .unwrap();
2006
2007        assert_eq!(config.defaults.phase_timeout, Some(300));
2008        assert_eq!(
2009            config.source_attribution.get("phase_timeout"),
2010            Some(&ConfigSource::Programmatic)
2011        );
2012    }
2013
2014    #[test]
2015    fn test_config_builder_with_runner_mode() {
2016        let config = Config::builder().runner_mode("native").build().unwrap();
2017
2018        assert_eq!(config.runner.mode, Some("native".to_string()));
2019        assert_eq!(
2020            config.source_attribution.get("runner_mode"),
2021            Some(&ConfigSource::Programmatic)
2022        );
2023    }
2024
2025    #[test]
2026    fn test_config_builder_with_state_dir() {
2027        let config = Config::builder().state_dir("/custom/path").build().unwrap();
2028
2029        // state_dir is tracked in source attribution
2030        assert_eq!(
2031            config.source_attribution.get("state_dir"),
2032            Some(&ConfigSource::Programmatic)
2033        );
2034    }
2035
2036    #[test]
2037    fn test_config_builder_with_all_options() {
2038        use std::time::Duration;
2039
2040        let config = Config::builder()
2041            .state_dir("/custom/state")
2042            .packet_max_bytes(32768)
2043            .packet_max_lines(600)
2044            .phase_timeout(Duration::from_secs(300))
2045            .runner_mode("native")
2046            .model("sonnet")
2047            .max_turns(10)
2048            .verbose(true)
2049            .llm_provider("claude-cli")
2050            .execution_strategy("controlled")
2051            .build()
2052            .unwrap();
2053
2054        assert_eq!(config.defaults.packet_max_bytes, Some(32768));
2055        assert_eq!(config.defaults.packet_max_lines, Some(600));
2056        assert_eq!(config.defaults.phase_timeout, Some(300));
2057        assert_eq!(config.runner.mode, Some("native".to_string()));
2058        assert_eq!(config.defaults.model, Some("sonnet".to_string()));
2059        assert_eq!(config.defaults.max_turns, Some(10));
2060        assert_eq!(config.defaults.verbose, Some(true));
2061        assert_eq!(config.llm.provider, Some("claude-cli".to_string()));
2062        assert_eq!(
2063            config.llm.execution_strategy,
2064            Some("controlled".to_string())
2065        );
2066    }
2067
2068    #[test]
2069    fn test_config_builder_validation_rejects_invalid_packet_max_bytes() {
2070        let result = Config::builder().packet_max_bytes(0).build();
2071
2072        assert!(result.is_err());
2073        let error = result.unwrap_err();
2074        match error {
2075            XCheckerError::Config(ConfigError::InvalidValue { key, .. }) => {
2076                assert_eq!(key, "packet_max_bytes");
2077            }
2078            _ => panic!("Expected Config InvalidValue error for packet_max_bytes"),
2079        }
2080    }
2081
2082    #[test]
2083    fn test_config_builder_validation_rejects_excessive_packet_max_bytes() {
2084        let result = Config::builder()
2085            .packet_max_bytes(20_000_000) // Exceeds 10MB limit
2086            .build();
2087
2088        assert!(result.is_err());
2089        let error = result.unwrap_err();
2090        match error {
2091            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
2092                assert_eq!(key, "packet_max_bytes");
2093                assert!(value.contains("exceeds maximum"));
2094            }
2095            _ => panic!("Expected Config InvalidValue error for packet_max_bytes"),
2096        }
2097    }
2098
2099    #[test]
2100    fn test_config_builder_validation_rejects_invalid_runner_mode() {
2101        let result = Config::builder().runner_mode("invalid_mode").build();
2102
2103        assert!(result.is_err());
2104        let error = result.unwrap_err();
2105        match error {
2106            XCheckerError::Config(ConfigError::InvalidValue { key, .. }) => {
2107                assert_eq!(key, "runner_mode");
2108            }
2109            _ => panic!("Expected Config InvalidValue error for runner_mode"),
2110        }
2111    }
2112
2113    #[test]
2114    fn test_config_builder_validation_rejects_invalid_execution_strategy() {
2115        let result = Config::builder().execution_strategy("externaltool").build();
2116
2117        assert!(result.is_err());
2118        let error = result.unwrap_err();
2119        match error {
2120            XCheckerError::Config(ConfigError::InvalidValue { key, value }) => {
2121                assert_eq!(key, "llm.execution_strategy");
2122                assert!(value.contains("externaltool"));
2123            }
2124            _ => panic!("Expected Config InvalidValue error for execution_strategy"),
2125        }
2126    }
2127
2128    #[test]
2129    fn test_config_builder_chaining() {
2130        // Test that builder methods can be chained in any order
2131        let config = Config::builder()
2132            .runner_mode("native")
2133            .packet_max_bytes(32768)
2134            .phase_timeout(std::time::Duration::from_secs(300))
2135            .packet_max_lines(600)
2136            .build()
2137            .unwrap();
2138
2139        assert_eq!(config.defaults.packet_max_bytes, Some(32768));
2140        assert_eq!(config.defaults.packet_max_lines, Some(600));
2141        assert_eq!(config.defaults.phase_timeout, Some(300));
2142        assert_eq!(config.runner.mode, Some("native".to_string()));
2143    }
2144
2145    #[test]
2146    fn test_config_builder_default_impl() {
2147        // Test that ConfigBuilder implements Default
2148        let builder = ConfigBuilder::default();
2149        let config = builder.build().unwrap();
2150
2151        // Should use all defaults
2152        assert_eq!(config.defaults.max_turns, Some(6));
2153        assert_eq!(config.defaults.packet_max_bytes, Some(65536));
2154    }
2155
2156    // ===== discover_from_env_and_fs Tests =====
2157
2158    #[test]
2159    fn test_discover_from_env_and_fs_uses_defaults() {
2160        let _guard = config_env_guard();
2161        // Use isolated home to avoid picking up real config files
2162        let _home = crate::paths::with_isolated_home();
2163
2164        // discover_from_env_and_fs should work with no config file present
2165        let config = Config::discover_from_env_and_fs().unwrap();
2166
2167        // Should use all defaults
2168        assert_eq!(config.defaults.max_turns, Some(6));
2169        assert_eq!(config.defaults.packet_max_bytes, Some(65536));
2170        assert_eq!(config.defaults.packet_max_lines, Some(1200));
2171        assert_eq!(config.llm.provider, Some("claude-cli".to_string()));
2172        assert_eq!(
2173            config.llm.execution_strategy,
2174            Some("controlled".to_string())
2175        );
2176
2177        // Source attribution should be defaults
2178        assert_eq!(
2179            config.source_attribution.get("max_turns"),
2180            Some(&ConfigSource::Default)
2181        );
2182        assert_eq!(
2183            config.source_attribution.get("llm_provider"),
2184            Some(&ConfigSource::Default)
2185        );
2186    }
2187
2188    #[test]
2189    fn test_discover_from_env_and_fs_reads_config_file() {
2190        let _guard = config_env_guard();
2191        let _home = crate::paths::with_isolated_home();
2192        let temp_dir = TempDir::new().unwrap();
2193
2194        // Create a config file in a temp directory's .xchecker folder
2195        // (simulating a project with a config file)
2196        let _config_path = create_test_config_file(
2197            temp_dir.path(),
2198            r#"
2199[defaults]
2200model = "sonnet"
2201max_turns = 10
2202packet_max_bytes = 32768
2203"#,
2204        );
2205
2206        // Use discover_from with the temp directory to simulate being in that project
2207        let config = Config::discover_from(temp_dir.path(), &CliArgs::default()).unwrap();
2208
2209        // Should use values from config file
2210        assert_eq!(config.defaults.model, Some("sonnet".to_string()));
2211        assert_eq!(config.defaults.max_turns, Some(10));
2212        assert_eq!(config.defaults.packet_max_bytes, Some(32768));
2213
2214        // Values not in config file should use defaults
2215        assert_eq!(config.defaults.packet_max_lines, Some(1200));
2216
2217        // Source attribution should reflect config file for overridden values
2218        assert!(matches!(
2219            config.source_attribution.get("model"),
2220            Some(ConfigSource::Config)
2221        ));
2222        assert!(matches!(
2223            config.source_attribution.get("max_turns"),
2224            Some(ConfigSource::Config)
2225        ));
2226        // Default values should have Default source
2227        assert_eq!(
2228            config.source_attribution.get("packet_max_lines"),
2229            Some(&ConfigSource::Default)
2230        );
2231    }
2232
2233    #[test]
2234    fn test_discover_from_env_and_fs_matches_discover_with_empty_cli_args() {
2235        let _guard = config_env_guard();
2236        let _home = crate::paths::with_isolated_home();
2237
2238        // Both methods should produce equivalent configs when no config file exists
2239        // (discover_from_env_and_fs is equivalent to discover with empty CliArgs)
2240        let config_env_fs = Config::discover_from_env_and_fs().unwrap();
2241        let config_discover = Config::discover(&CliArgs::default()).unwrap();
2242
2243        // Compare key values - both should use defaults
2244        assert_eq!(config_env_fs.defaults.model, config_discover.defaults.model);
2245        assert_eq!(
2246            config_env_fs.defaults.max_turns,
2247            config_discover.defaults.max_turns
2248        );
2249        assert_eq!(
2250            config_env_fs.defaults.packet_max_bytes,
2251            config_discover.defaults.packet_max_bytes
2252        );
2253        assert_eq!(config_env_fs.llm.provider, config_discover.llm.provider);
2254        assert_eq!(
2255            config_env_fs.llm.execution_strategy,
2256            config_discover.llm.execution_strategy
2257        );
2258
2259        // Source attribution should also match
2260        assert_eq!(
2261            config_env_fs.source_attribution.get("max_turns"),
2262            config_discover.source_attribution.get("max_turns")
2263        );
2264        assert_eq!(
2265            config_env_fs.source_attribution.get("llm_provider"),
2266            config_discover.source_attribution.get("llm_provider")
2267        );
2268    }
2269
2270    // ===== Security Config Tests (Task 23.2) =====
2271
2272    #[test]
2273    fn test_security_config_defaults() {
2274        let config = Config::builder().build().unwrap();
2275
2276        // Security config should have empty defaults
2277        assert!(config.security.extra_secret_patterns.is_empty());
2278        assert!(config.security.ignore_secret_patterns.is_empty());
2279    }
2280
2281    #[test]
2282    fn test_security_config_from_toml_file() {
2283        let _guard = config_env_guard();
2284        let _home = crate::paths::with_isolated_home();
2285        let temp_dir = TempDir::new().unwrap();
2286
2287        let config_path = create_test_config_file(
2288            temp_dir.path(),
2289            r#"
2290[security]
2291extra_secret_patterns = ["CUSTOM_[A-Z0-9]{32}", "MY_SECRET_[A-Za-z0-9]{20}"]
2292ignore_secret_patterns = ["github_pat", "aws_access_key"]
2293"#,
2294        );
2295
2296        let cli_args = CliArgs {
2297            config_path: Some(config_path),
2298            ..Default::default()
2299        };
2300        let config = Config::discover(&cli_args).unwrap();
2301
2302        // Should have extra patterns from file
2303        assert_eq!(config.security.extra_secret_patterns.len(), 2);
2304        assert!(
2305            config
2306                .security
2307                .extra_secret_patterns
2308                .contains(&"CUSTOM_[A-Z0-9]{32}".to_string())
2309        );
2310        assert!(
2311            config
2312                .security
2313                .extra_secret_patterns
2314                .contains(&"MY_SECRET_[A-Za-z0-9]{20}".to_string())
2315        );
2316
2317        // Should have ignore patterns from file
2318        assert_eq!(config.security.ignore_secret_patterns.len(), 2);
2319        assert!(
2320            config
2321                .security
2322                .ignore_secret_patterns
2323                .contains(&"github_pat".to_string())
2324        );
2325        assert!(
2326            config
2327                .security
2328                .ignore_secret_patterns
2329                .contains(&"aws_access_key".to_string())
2330        );
2331
2332        // Source attribution should be config file
2333        assert!(matches!(
2334            config.source_attribution.get("security"),
2335            Some(ConfigSource::Config)
2336        ));
2337    }
2338
2339    #[test]
2340    fn test_security_config_empty_section() {
2341        let _guard = config_env_guard();
2342        let _home = crate::paths::with_isolated_home();
2343        let temp_dir = TempDir::new().unwrap();
2344
2345        let config_path = create_test_config_file(
2346            temp_dir.path(),
2347            r#"
2348[security]
2349"#,
2350        );
2351
2352        let cli_args = CliArgs {
2353            config_path: Some(config_path),
2354            ..Default::default()
2355        };
2356        let config = Config::discover(&cli_args).unwrap();
2357
2358        // Empty security section should use defaults
2359        assert!(config.security.extra_secret_patterns.is_empty());
2360        assert!(config.security.ignore_secret_patterns.is_empty());
2361    }
2362
2363    #[test]
2364    fn test_security_config_builder_methods() {
2365        let config = Config::builder()
2366            .extra_secret_patterns(vec!["PATTERN_A".to_string()])
2367            .add_extra_secret_pattern("PATTERN_B")
2368            .ignore_secret_patterns(vec!["ignore_a".to_string()])
2369            .add_ignore_secret_pattern("ignore_b")
2370            .build()
2371            .unwrap();
2372
2373        // Should have both extra patterns
2374        assert_eq!(config.security.extra_secret_patterns.len(), 2);
2375        assert!(
2376            config
2377                .security
2378                .extra_secret_patterns
2379                .contains(&"PATTERN_A".to_string())
2380        );
2381        assert!(
2382            config
2383                .security
2384                .extra_secret_patterns
2385                .contains(&"PATTERN_B".to_string())
2386        );
2387
2388        // Should have both ignore patterns
2389        assert_eq!(config.security.ignore_secret_patterns.len(), 2);
2390        assert!(
2391            config
2392                .security
2393                .ignore_secret_patterns
2394                .contains(&"ignore_a".to_string())
2395        );
2396        assert!(
2397            config
2398                .security
2399                .ignore_secret_patterns
2400                .contains(&"ignore_b".to_string())
2401        );
2402    }
2403}