1mod 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 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 #[must_use]
66 pub fn model_for_phase(&self, phase: crate::types::PhaseId) -> String {
67 use crate::types::PhaseId;
68
69 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 phase_model
82 .or_else(|| self.defaults.model.clone())
83 .unwrap_or_else(|| "haiku".to_string())
84 }
85
86 #[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 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#[cfg(any(test, feature = "test-utils"))]
144pub mod test_utils {
145 use std::env;
146
147 pub fn clear_config_env_vars() {
170 let keys: Vec<String> = env::vars()
176 .map(|(k, _)| k)
177 .filter(|k| k.starts_with("XCHECKER_"))
178 .collect();
179
180 for key in keys {
182 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 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()), max_turns: None,
260 packet_max_bytes: None,
261 packet_max_lines: None,
262 output_format: None,
263 verbose: Some(true), 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 assert_eq!(config.defaults.model, Some("opus".to_string()));
292 assert_eq!(config.defaults.verbose, Some(true));
293
294 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 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), ..Default::default()
315 };
316
317 let result = Config::discover(&cli_args);
318 assert!(result.is_err());
319
320 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 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 #[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 let invalid_toml_cases = [
392 "[[[ invalid brackets",
393 "[defaults\nkey = value", "key = ", "[defaults]\nkey value", "[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 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 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 assert_eq!(config.defaults.model, Some("sonnet".to_string()));
440 assert!(!config.selectors.include.is_empty()); assert!(config.runner.mode.is_some()); }
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 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 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 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 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 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 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 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), ..Default::default()
553 };
554
555 let result = Config::discover(&cli_args);
556 assert!(result.is_err());
557
558 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 let cli_args = CliArgs {
572 packet_max_bytes: Some(20_000_000), ..Default::default()
574 };
575
576 let result = Config::discover(&cli_args);
577 assert!(result.is_err());
578
579 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 assert!(result.is_err(), "Should fail for invalid glob pattern");
637 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 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), ..Default::default()
790 };
791
792 let config = Config::discover(&cli_args).unwrap();
793
794 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 #[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()), packet_max_bytes: Some(32768), ..Default::default()
830 };
831
832 let config = Config::discover(&cli_args).unwrap();
833
834 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 #[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 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 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 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 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 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 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 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 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 #[test]
1270 fn test_prompt_template_parsing() {
1271 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 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 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 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 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 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 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 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 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 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 assert_eq!(config.llm.prompt_template, None);
1633 }
1634
1635 #[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 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 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 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 assert_eq!(cfg.model_for_phase(PhaseId::Requirements), "haiku");
1683 assert_eq!(cfg.model_for_phase(PhaseId::Design), "sonnet");
1685 assert_eq!(cfg.model_for_phase(PhaseId::Tasks), "sonnet");
1686 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 cfg.phases.design = Some(PhaseConfig {
1699 model: Some("opus".to_string()),
1700 ..Default::default()
1701 });
1702
1703 assert_eq!(cfg.model_for_phase(PhaseId::Design), "opus");
1705 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 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 assert_eq!(config.model_for_phase(PhaseId::Requirements), "haiku");
1782 assert_eq!(config.model_for_phase(PhaseId::Design), "sonnet");
1784 assert_eq!(config.model_for_phase(PhaseId::Tasks), "sonnet");
1785 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 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 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 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 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 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 #[test]
1891 fn test_strict_validation_defaults_to_false() {
1892 let cfg = Config::minimal_for_testing();
1893 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 #[test]
1960 fn test_config_builder_default() {
1961 let config = Config::builder().build().unwrap();
1962
1963 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 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) .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 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 let builder = ConfigBuilder::default();
2149 let config = builder.build().unwrap();
2150
2151 assert_eq!(config.defaults.max_turns, Some(6));
2153 assert_eq!(config.defaults.packet_max_bytes, Some(65536));
2154 }
2155
2156 #[test]
2159 fn test_discover_from_env_and_fs_uses_defaults() {
2160 let _guard = config_env_guard();
2161 let _home = crate::paths::with_isolated_home();
2163
2164 let config = Config::discover_from_env_and_fs().unwrap();
2166
2167 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 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 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 let config = Config::discover_from(temp_dir.path(), &CliArgs::default()).unwrap();
2208
2209 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 assert_eq!(config.defaults.packet_max_lines, Some(1200));
2216
2217 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 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 let config_env_fs = Config::discover_from_env_and_fs().unwrap();
2241 let config_discover = Config::discover(&CliArgs::default()).unwrap();
2242
2243 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 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 #[test]
2273 fn test_security_config_defaults() {
2274 let config = Config::builder().build().unwrap();
2275
2276 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 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 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 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 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 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 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}