1use std::{
5 collections::BTreeMap,
6 fs, io,
7 path::{Path, PathBuf},
8};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const DEFAULT_STATE_DIR: &str = ".truth";
14pub const LEGACY_STATE_DIR: &str = ".truth-mirror";
15pub const DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS: &[&str] = &[
16 "BEGIN SYSTEM PROMPT",
17 "ignore previous instructions",
18 "api_key",
19 "secret_key",
20 "secret:",
21 "secret=",
22 "token:",
23 "token=",
24 "password:",
25 "password=",
26 "credential:",
27 "credential=",
28 "private_key",
29 "aws_secret_access_key",
30];
31
32#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
34#[serde(rename_all = "lowercase")]
35#[value(rename_all = "lowercase")]
36pub enum Effort {
37 Minimal,
38 Low,
39 Medium,
40 High,
41 #[default]
42 Xhigh,
43}
44
45impl Effort {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Effort::Minimal => "minimal",
49 Effort::Low => "low",
50 Effort::Medium => "medium",
51 Effort::High => "high",
52 Effort::Xhigh => "xhigh",
53 }
54 }
55
56 pub fn highest() -> Self {
58 Effort::Xhigh
59 }
60
61 pub fn claude_value(self) -> &'static str {
65 match self {
66 Effort::Minimal => "low",
67 other => other.as_str(),
68 }
69 }
70}
71
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct HarnessSelection {
75 pub harness: String,
76 pub model: String,
77 #[serde(default)]
78 pub effort: Effort,
79}
80
81impl HarnessSelection {
82 pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
83 Self {
84 harness: harness.into(),
85 model: model.into(),
86 effort,
87 }
88 }
89}
90
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
93pub struct AdversarialPair {
94 pub reviewer: HarnessSelection,
95 #[serde(default)]
96 pub arbiter: Option<HarnessSelection>,
97}
98
99#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
100pub struct TruthMirrorConfig {
101 #[serde(default = "default_ledger_dir")]
102 pub ledger_dir: String,
103 #[serde(default)]
104 pub allow_same_model: bool,
105 #[serde(default = "default_writer")]
107 pub default_writer: String,
108 #[serde(default)]
111 pub pairs: BTreeMap<String, AdversarialPair>,
112 #[serde(default)]
113 pub strict: StrictConfig,
114 #[serde(default)]
115 pub gates: GatesConfig,
116 #[serde(default)]
117 pub ground_truth: GroundTruthConfig,
118 #[serde(default)]
119 pub history: HistoryConfig,
120 #[serde(default)]
121 pub enforcement: EnforcementConfig,
122 #[serde(default)]
123 pub skills: SkillsConfig,
124 #[serde(default)]
125 pub memory_skill: MemorySkillConfig,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub review: Option<LegacyReview>,
129}
130
131impl Default for TruthMirrorConfig {
132 fn default() -> Self {
133 Self {
134 ledger_dir: default_ledger_dir(),
135 allow_same_model: false,
136 default_writer: default_writer(),
137 pairs: default_pairs(),
138 strict: StrictConfig::default(),
139 gates: GatesConfig::default(),
140 ground_truth: GroundTruthConfig::default(),
141 history: HistoryConfig::default(),
142 enforcement: EnforcementConfig::default(),
143 skills: SkillsConfig::default(),
144 memory_skill: MemorySkillConfig::default(),
145 review: None,
146 }
147 }
148}
149
150impl TruthMirrorConfig {
151 pub fn load_for_cli(
152 explicit_path: Option<&Path>,
153 state_dir: &Path,
154 ) -> Result<Self, ConfigError> {
155 let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
156 Self::load_or_default(path)
157 }
158
159 pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
160 let path = path.into();
161 match fs::read_to_string(&path) {
162 Ok(contents) => Self::from_toml_str(&path, &contents),
163 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
164 Err(source) => Err(ConfigError::Read { path, source }),
165 }
166 }
167
168 pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
169 let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
170 path: path.to_path_buf(),
171 source,
172 })?;
173 config.normalize();
174 config.validate()?;
175 Ok(config)
176 }
177
178 fn normalize(&mut self) {
182 let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
186 .into_iter()
187 .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
188 .collect();
189 self.pairs = lowered;
190
191 let had_explicit_pairs = !self.pairs.is_empty();
192 let review = self.review.take();
193
194 if !had_explicit_pairs && review.is_some() {
195 self.pairs = default_pairs();
197 }
198
199 if let Some(review) = review {
200 let writer = review.watched.harness.trim().to_ascii_lowercase();
201 let pair = AdversarialPair {
202 reviewer: HarnessSelection::new(
203 review.reviewer.harness,
204 review.reviewer.model,
205 Effort::highest(),
206 ),
207 arbiter: None,
208 };
209 if had_explicit_pairs {
210 self.pairs.entry(writer).or_insert(pair);
212 } else {
213 self.pairs.insert(writer, pair);
215 }
216 }
217
218 if self.pairs.is_empty() {
219 self.pairs = default_pairs();
220 }
221 }
222
223 fn validate(&self) -> Result<(), ConfigError> {
224 for (writer, pair) in &self.pairs {
225 if let Some(arbiter) = &pair.arbiter
226 && normalized(&arbiter.model) == normalized(&pair.reviewer.model)
227 {
228 return Err(ConfigError::ArbiterNotDistinct {
229 writer: writer.clone(),
230 });
231 }
232 }
233 if !self.memory_skill.scan.entropy_threshold.is_finite()
234 || self.memory_skill.scan.entropy_threshold <= 0.0
235 {
236 return Err(ConfigError::InvalidMemorySkillEntropyThreshold {
237 value: self.memory_skill.scan.entropy_threshold,
238 });
239 }
240 if self.memory_skill.max_skill_bytes == 0 {
241 return Err(ConfigError::InvalidMemorySkillMaxSkillBytes);
242 }
243 if self.memory_skill.scan.secret_detector_timeout_seconds == 0 {
244 return Err(ConfigError::InvalidMemorySkillSecretDetectorTimeout);
245 }
246 Ok(())
247 }
248
249 pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
251 self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
252 }
253
254 pub fn default_path(state_dir: &Path) -> PathBuf {
255 state_dir.join("config.toml")
256 }
257}
258
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
261pub struct LegacyReview {
262 pub watched: LegacyModel,
263 pub reviewer: LegacyModel,
264}
265
266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267pub struct LegacyModel {
268 pub harness: String,
269 pub model: String,
270}
271
272#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(default)]
275pub struct StrictConfig {
276 pub stop_after_lies: u32,
277 pub stop_after_fuckups: u32,
278 pub max_passes: u32,
279}
280
281impl Default for StrictConfig {
282 fn default() -> Self {
283 Self {
284 stop_after_lies: 1,
285 stop_after_fuckups: 3,
286 max_passes: 3,
287 }
288 }
289}
290
291impl StrictConfig {
292 pub fn goal_policy(
293 &self,
294 lies_override: Option<u32>,
295 fuckups_override: Option<u32>,
296 ) -> crate::reviewer::StrictGoalPolicy {
297 crate::reviewer::StrictGoalPolicy {
298 stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
299 stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
300 }
301 }
302}
303
304#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
307#[serde(default)]
308pub struct GatesConfig {
309 pub fake_markers: Vec<String>,
310 pub evidence_patterns: Vec<String>,
311 pub marker_ignore_paths: Vec<String>,
312}
313
314impl Default for GatesConfig {
315 fn default() -> Self {
316 Self {
317 fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
318 evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
319 marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
320 }
321 }
322}
323
324impl GatesConfig {
325 pub fn to_policy(&self) -> crate::claim::GatePolicy {
328 crate::claim::GatePolicy {
329 fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
330 evidence_patterns: union_defaults(
331 &self.evidence_patterns,
332 crate::claim::DEFAULT_EVIDENCE_PATTERNS,
333 ),
334 marker_ignore_paths: union_defaults(
335 &self.marker_ignore_paths,
336 crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
337 ),
338 }
339 }
340}
341
342fn strs(values: &[&str]) -> Vec<String> {
343 values.iter().map(|value| (*value).to_owned()).collect()
344}
345
346fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
350 let mut out: Vec<String> = strs(defaults);
351 for value in values {
352 if !out.iter().any(|existing| existing == value) {
353 out.push(value.clone());
354 }
355 }
356 out
357}
358
359#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
361#[serde(default)]
362pub struct GroundTruthConfig {
363 pub enabled: bool,
364 pub file_names: Vec<String>,
365 pub include_openspec_specs: bool,
366 pub max_bytes: usize,
367}
368
369impl Default for GroundTruthConfig {
370 fn default() -> Self {
371 Self {
372 enabled: true,
373 file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
374 .iter()
375 .map(|name| (*name).to_owned())
376 .collect(),
377 include_openspec_specs: true,
378 max_bytes: 20_000,
379 }
380 }
381}
382
383#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
385#[serde(default)]
386pub struct HistoryConfig {
387 pub window_user: usize,
388 pub window_agent: usize,
389 pub max_bytes: usize,
390 pub transcript_path: Option<String>,
393}
394
395impl Default for HistoryConfig {
396 fn default() -> Self {
397 Self {
398 window_user: 3,
399 window_agent: 10,
400 max_bytes: 12_000,
401 transcript_path: None,
402 }
403 }
404}
405
406#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
408#[serde(default)]
409pub struct EnforcementConfig {
410 pub block_tools_after_unresolved: u32,
411 pub block_tools_after_secs: u64,
412}
413
414impl EnforcementConfig {
415 pub fn is_enabled(&self) -> bool {
416 self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
417 }
418}
419
420#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
421#[serde(default)]
422pub struct SkillsConfig {
423 pub enabled: bool,
424}
425
426#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
427#[serde(rename_all = "lowercase")]
428pub enum MemorySkillMode {
429 Off,
430 Suggest,
431 #[default]
432 Stage,
433}
434
435#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
436#[serde(rename_all = "snake_case")]
437pub enum MemorySkillCandidateKind {
438 HowToSkill,
439 AntiPatternSkill,
440 RemediationSkill,
441}
442
443impl MemorySkillCandidateKind {
444 pub fn as_str(self) -> &'static str {
445 match self {
446 Self::HowToSkill => "how_to_skill",
447 Self::AntiPatternSkill => "anti_pattern_skill",
448 Self::RemediationSkill => "remediation_skill",
449 }
450 }
451}
452
453impl std::fmt::Display for MemorySkillCandidateKind {
454 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 formatter.write_str(self.as_str())
456 }
457}
458
459#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
460#[serde(default)]
461pub struct MemorySkillConfig {
462 pub enabled: bool,
463 pub mode: MemorySkillMode,
464 pub candidate_dir: String,
465 pub approved_dir: String,
466 pub approved_slug_prefix: String,
467 pub toolbox_delivery_registry: String,
468 pub allow_global_writes: bool,
469 pub require_claim_evidence: bool,
470 pub max_candidates_per_commit: usize,
471 pub max_skill_bytes: usize,
472 pub pre_push_blocks_pending: bool,
473 pub redact_secrets: bool,
478 pub signals: MemorySkillSignalsConfig,
479 pub review: MemorySkillReviewConfig,
480 pub scan: MemorySkillScanConfig,
481}
482
483impl Default for MemorySkillConfig {
484 fn default() -> Self {
485 Self {
486 enabled: false,
487 mode: MemorySkillMode::Stage,
488 candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
489 approved_dir: ".agents/skills".to_owned(),
490 approved_slug_prefix: "generated-".to_owned(),
491 toolbox_delivery_registry: String::new(),
492 allow_global_writes: false,
493 require_claim_evidence: true,
494 max_candidates_per_commit: 1,
495 max_skill_bytes: 12_000,
496 pre_push_blocks_pending: false,
497 redact_secrets: true,
498 signals: MemorySkillSignalsConfig::default(),
499 review: MemorySkillReviewConfig::default(),
500 scan: MemorySkillScanConfig::default(),
501 }
502 }
503}
504
505impl MemorySkillConfig {
506 pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
507 skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
508 }
509}
510
511#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
512#[serde(default)]
513pub struct MemorySkillSignalsConfig {
514 pub min_occurrences: usize,
515 pub require_reusable_procedure: bool,
516 pub capture_passes_as_how_to: bool,
517 pub capture_rejections_as_remediation: bool,
518 pub capture_rejections_as_antipattern: bool,
519 pub rejection_precedence: Vec<MemorySkillCandidateKind>,
520}
521
522impl Default for MemorySkillSignalsConfig {
523 fn default() -> Self {
524 Self {
525 min_occurrences: 2,
526 require_reusable_procedure: true,
527 capture_passes_as_how_to: true,
528 capture_rejections_as_remediation: true,
529 capture_rejections_as_antipattern: true,
530 rejection_precedence: vec![
531 MemorySkillCandidateKind::AntiPatternSkill,
532 MemorySkillCandidateKind::RemediationSkill,
533 ],
534 }
535 }
536}
537
538#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
539#[serde(default)]
540pub struct MemorySkillReviewConfig {
541 pub require_adversarial_review: bool,
542 pub require_reviewed_ledger_entry: bool,
543 pub require_pass_for_how_to: bool,
544 pub require_structured_findings_checked: bool,
545 pub reject_without_ledger_entry: bool,
546}
547
548impl Default for MemorySkillReviewConfig {
549 fn default() -> Self {
550 Self {
551 require_adversarial_review: true,
552 require_reviewed_ledger_entry: true,
553 require_pass_for_how_to: true,
554 require_structured_findings_checked: true,
555 reject_without_ledger_entry: true,
556 }
557 }
558}
559
560#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
561#[serde(default)]
562pub struct MemorySkillScanConfig {
563 pub secret_detection: SecretDetectionMode,
564 pub secret_detector: String,
567 pub allow_secret_detector_command: bool,
568 pub secret_detector_timeout_seconds: u64,
569 pub entropy_threshold: f64,
570 pub blocked_patterns: Vec<String>,
571 pub blocked_patterns_case_insensitive: bool,
572}
573
574impl Default for MemorySkillScanConfig {
575 fn default() -> Self {
576 Self {
577 secret_detection: SecretDetectionMode::Required,
578 secret_detector: String::new(),
579 allow_secret_detector_command: false,
580 secret_detector_timeout_seconds: 10,
581 entropy_threshold: 4.5,
582 blocked_patterns: Vec::new(),
583 blocked_patterns_case_insensitive: true,
584 }
585 }
586}
587
588impl MemorySkillScanConfig {
589 pub fn effective_blocked_patterns(&self) -> Vec<String> {
590 let mut patterns = Vec::new();
591 for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
592 push_unique_pattern(
593 &mut patterns,
594 pattern,
595 self.blocked_patterns_case_insensitive,
596 );
597 }
598 for pattern in &self.blocked_patterns {
599 push_unique_pattern(
600 &mut patterns,
601 pattern,
602 self.blocked_patterns_case_insensitive,
603 );
604 }
605 patterns
606 }
607}
608
609fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
610 let pattern = pattern.trim();
611 if pattern.is_empty()
612 || patterns.iter().any(|existing| {
613 if case_insensitive {
614 existing.eq_ignore_ascii_case(pattern)
615 } else {
616 existing == pattern
617 }
618 })
619 {
620 return;
621 }
622 patterns.push(pattern.to_owned());
623}
624
625#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
626#[serde(rename_all = "kebab-case")]
627pub enum SecretDetectionMode {
628 #[default]
629 Required,
630 BestEffort,
631 Off,
632}
633
634#[derive(Debug, Error)]
635pub enum ConfigError {
636 #[error("failed to read config {path}: {source}")]
637 Read {
638 path: PathBuf,
639 #[source]
640 source: io::Error,
641 },
642 #[error("failed to parse config {path}: {source}")]
643 Parse {
644 path: PathBuf,
645 #[source]
646 source: toml::de::Error,
647 },
648 #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
649 ArbiterNotDistinct { writer: String },
650 #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
651 InvalidMemorySkillEntropyThreshold { value: f64 },
652 #[error("memory_skill.max_skill_bytes must be greater than 0")]
653 InvalidMemorySkillMaxSkillBytes,
654 #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
655 InvalidMemorySkillSecretDetectorTimeout,
656}
657
658fn default_ledger_dir() -> String {
659 DEFAULT_STATE_DIR.to_owned()
660}
661
662fn default_writer() -> String {
663 "codex".to_owned()
664}
665
666fn default_pairs() -> BTreeMap<String, AdversarialPair> {
668 let mut pairs = BTreeMap::new();
669 pairs.insert(
670 "codex".to_owned(),
671 AdversarialPair {
672 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
673 arbiter: Some(HarnessSelection::new(
674 "pi",
675 "openai-codex/gpt-5.5",
676 Effort::highest(),
677 )),
678 },
679 );
680 pairs.insert(
681 "claude".to_owned(),
682 AdversarialPair {
683 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
684 arbiter: Some(HarnessSelection::new(
685 "pi",
686 "openai-codex/gpt-5.5",
687 Effort::highest(),
688 )),
689 },
690 );
691 pairs.insert(
692 "pi".to_owned(),
693 AdversarialPair {
694 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
695 arbiter: Some(HarnessSelection::new(
696 "claude",
697 "claude-opus-4-8",
698 Effort::highest(),
699 )),
700 },
701 );
702 pairs
703}
704
705fn normalized(model: &str) -> String {
706 model.trim().to_ascii_lowercase()
707}
708
709#[cfg(test)]
710mod tests {
711 use std::path::Path;
712
713 use super::{Effort, TruthMirrorConfig};
714
715 #[test]
716 fn default_config_has_three_opposed_pairs() {
717 let config = TruthMirrorConfig::default();
718
719 assert_eq!(config.pairs.len(), 3);
720 assert_eq!(config.ledger_dir, ".truth");
721 assert!(!config.skills.enabled);
722 assert!(!config.memory_skill.enabled);
723 assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
724 let codex = config.pair_for("codex").unwrap();
725 assert_eq!(codex.reviewer.harness, "claude");
726 assert_eq!(codex.reviewer.model, "claude-opus-4-8");
727 assert_eq!(codex.reviewer.effort, Effort::Xhigh);
728 }
729
730 #[test]
731 fn effort_serializes_lowercase() {
732 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
733 assert_eq!(Effort::highest(), Effort::Xhigh);
734 }
735
736 #[test]
737 fn pairs_config_parses_and_resolves_by_writer() {
738 let contents = r#"
739default_writer = "claude"
740
741[pairs.claude]
742reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
743arbiter = { harness = "pi", model = "openai-codex/gpt-5.5", effort = "high" }
744
745[pairs.codex]
746reviewer = { harness = "claude", model = "claude-opus-4-8" }
747"#;
748 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
749
750 let claude_pair = config.pair_for("claude").unwrap();
751 assert_eq!(claude_pair.reviewer.harness, "codex");
752 assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
753 assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
754
755 let codex_pair = config.pair_for("codex").unwrap();
757 assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
758 }
759
760 #[test]
761 fn legacy_review_block_overrides_default_pair() {
762 let contents = r#"
766ledger_dir = ".truth-mirror"
767
768[review.watched]
769harness = "codex"
770model = "gpt-5.5"
771
772[review.reviewer]
773harness = "gemini"
774model = "gemini-3-pro"
775"#;
776 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
777
778 let pair = config.pair_for("codex").unwrap();
779 assert_eq!(pair.reviewer.harness, "gemini");
780 assert_eq!(pair.reviewer.model, "gemini-3-pro");
781 assert!(config.pair_for("claude").is_some());
783 assert!(config.pair_for("pi").is_some());
784 }
785
786 #[test]
787 fn explicit_pairs_win_over_legacy_review() {
788 let contents = r#"
791[pairs.codex]
792reviewer = { harness = "claude", model = "explicit-model" }
793
794[review.watched]
795harness = "codex"
796model = "gpt-5.5"
797
798[review.reviewer]
799harness = "gemini"
800model = "legacy-model"
801"#;
802 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
803
804 let pair = config.pair_for("codex").unwrap();
805 assert_eq!(pair.reviewer.harness, "claude");
806 assert_eq!(pair.reviewer.model, "explicit-model");
807 }
808
809 #[test]
810 fn explicit_pairs_win_case_insensitively_over_legacy() {
811 let contents = r#"
814[pairs.CODEX]
815reviewer = { harness = "claude", model = "explicit-model" }
816
817[review.watched]
818harness = "codex"
819model = "gpt-5.5"
820
821[review.reviewer]
822harness = "gemini"
823model = "legacy-model"
824"#;
825 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
826
827 let pair = config.pair_for("codex").unwrap();
828 assert_eq!(pair.reviewer.model, "explicit-model");
829 assert_eq!(config.pairs.len(), 1);
831 }
832
833 #[test]
834 fn arbiter_equal_to_reviewer_model_is_rejected() {
835 let contents = r#"
836[pairs.codex]
837reviewer = { harness = "claude", model = "same-model" }
838arbiter = { harness = "pi", model = "same-model" }
839"#;
840 let error =
841 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
842
843 assert!(matches!(
844 error,
845 super::ConfigError::ArbiterNotDistinct { .. }
846 ));
847 }
848
849 #[test]
850 fn missing_config_loads_default() {
851 let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
852
853 assert_eq!(config.pairs.len(), 3);
854 assert!(config.ground_truth.enabled);
855 assert_eq!(config.history.window_user, 3);
856 assert!(!config.enforcement.is_enabled());
857 }
858
859 #[test]
860 fn gates_config_parses_and_builds_policy() {
861 let contents = r#"
862[pairs.codex]
863reviewer = { harness = "claude", model = "claude-opus-4-8" }
864
865[gates]
866fake_markers = ["pretend-pass"]
867evidence_patterns = ["jira:"]
868marker_ignore_paths = [".md", "vendor/"]
869"#;
870 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
871
872 let default_marker = ["mock", "as", "real"].join("-");
877 let policy = config.gates.to_policy();
878 assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
879 assert!(policy.fake_markers.contains(&default_marker));
880 assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
881 assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
882 assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
883 assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
884 }
885
886 #[test]
887 fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
888 let skills_enabled = super::SkillsConfig { enabled: true };
889 let skills_disabled = super::SkillsConfig { enabled: false };
890 let mut memory = super::MemorySkillConfig {
891 enabled: true,
892 mode: super::MemorySkillMode::Stage,
893 ..super::MemorySkillConfig::default()
894 };
895
896 assert!(memory.effective_enabled(&skills_enabled));
897 assert!(!memory.effective_enabled(&skills_disabled));
898
899 memory.enabled = false;
900 assert!(!memory.effective_enabled(&skills_enabled));
901
902 memory.enabled = true;
903 memory.mode = super::MemorySkillMode::Off;
904 assert!(!memory.effective_enabled(&skills_enabled));
905 }
906
907 #[test]
908 fn memory_skill_rejection_precedence_is_legacy_compatible() {
909 let contents = r#"
910[memory_skill.signals]
911rejection_precedence = ["how_to_skill"]
912"#;
913
914 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
915
916 assert_eq!(
917 config.memory_skill.signals.rejection_precedence,
918 vec![super::MemorySkillCandidateKind::HowToSkill]
919 );
920 }
921
922 #[test]
923 fn memory_skill_entropy_threshold_must_be_finite() {
924 let contents = r#"
925[memory_skill.scan]
926entropy_threshold = inf
927"#;
928
929 let error =
930 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
931
932 assert!(matches!(
933 error,
934 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
935 ));
936 }
937
938 #[test]
939 fn memory_skill_entropy_threshold_must_be_positive() {
940 let contents = r#"
941[memory_skill.scan]
942entropy_threshold = 0.0
943"#;
944
945 let error =
946 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
947
948 assert!(matches!(
949 error,
950 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
951 ));
952 }
953
954 #[test]
955 fn memory_skill_max_skill_bytes_must_be_positive() {
956 let contents = r#"
957[memory_skill]
958max_skill_bytes = 0
959"#;
960
961 let error =
962 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
963
964 assert!(matches!(
965 error,
966 super::ConfigError::InvalidMemorySkillMaxSkillBytes
967 ));
968 }
969
970 #[test]
971 fn memory_skill_secret_detector_timeout_must_be_positive() {
972 let contents = r#"
973[memory_skill.scan]
974secret_detector_timeout_seconds = 0
975"#;
976
977 let error =
978 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
979
980 assert!(matches!(
981 error,
982 super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
983 ));
984 }
985
986 #[test]
987 fn empty_gate_lists_fall_back_to_defaults() {
988 let policy = super::GatesConfig {
990 fake_markers: Vec::new(),
991 evidence_patterns: Vec::new(),
992 marker_ignore_paths: Vec::new(),
993 }
994 .to_policy();
995
996 assert!(!policy.fake_markers.is_empty());
997 assert!(!policy.evidence_patterns.is_empty());
998 assert!(!policy.marker_ignore_paths.is_empty());
999 }
1000
1001 #[test]
1002 fn memory_skill_blocked_patterns_trim_and_dedupe() {
1003 let config = super::MemorySkillScanConfig {
1004 blocked_patterns: vec![
1005 " API_KEY ".to_owned(),
1006 " custom ".to_owned(),
1007 " ".to_owned(),
1008 "custom".to_owned(),
1009 ],
1010 ..super::MemorySkillScanConfig::default()
1011 };
1012
1013 let patterns = config.effective_blocked_patterns();
1014
1015 assert_eq!(
1016 patterns
1017 .iter()
1018 .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1019 .count(),
1020 1
1021 );
1022 assert_eq!(
1023 patterns
1024 .iter()
1025 .filter(|pattern| *pattern == "custom")
1026 .count(),
1027 1
1028 );
1029 assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1030 }
1031
1032 #[test]
1033 fn pair_keys_are_lowercased() {
1034 let contents = r#"
1035[pairs.CODEX]
1036reviewer = { harness = "claude", model = "claude-opus-4-8" }
1037"#;
1038 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1039
1040 assert!(config.pair_for("codex").is_some());
1041 assert!(config.pair_for("CoDeX").is_some());
1042 }
1043}