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_model(&arbiter.model) == normalized_model(&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(
690 "gemini",
691 "gemini-2.5-pro",
692 Effort::highest(),
693 )),
694 },
695 );
696 pairs.insert(
697 "pi".to_owned(),
698 AdversarialPair {
699 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
700 arbiter: Some(HarnessSelection::new(
701 "claude",
702 "claude-opus-4-8",
703 Effort::highest(),
704 )),
705 },
706 );
707 pairs
708}
709
710pub(crate) fn normalized_model(model: &str) -> String {
717 let model = model.trim();
718 let short = model
719 .rsplit_once('/')
720 .and_then(|(_, suffix)| {
721 let s = suffix.trim();
722 if s.is_empty() { None } else { Some(s) }
723 })
724 .unwrap_or(model);
725 short.to_ascii_lowercase()
726}
727
728#[cfg(test)]
729mod tests {
730 use std::path::Path;
731
732 use super::{Effort, TruthMirrorConfig};
733
734 #[test]
735 fn default_config_has_three_opposed_pairs() {
736 let config = TruthMirrorConfig::default();
737
738 assert_eq!(config.pairs.len(), 3);
739 assert_eq!(config.ledger_dir, ".truth");
740 assert!(!config.skills.enabled);
741 assert!(!config.memory_skill.enabled);
742 assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
743 let codex = config.pair_for("codex").unwrap();
744 assert_eq!(codex.reviewer.harness, "claude");
745 assert_eq!(codex.reviewer.model, "claude-opus-4-8");
746 assert_eq!(codex.reviewer.effort, Effort::Xhigh);
747 }
748
749 #[test]
750 fn effort_serializes_lowercase() {
751 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
752 assert_eq!(Effort::highest(), Effort::Xhigh);
753 }
754
755 #[test]
756 fn pairs_config_parses_and_resolves_by_writer() {
757 let contents = r#"
760default_writer = "claude"
761
762[pairs.claude]
763reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
764arbiter = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
765
766[pairs.codex]
767reviewer = { harness = "claude", model = "claude-opus-4-8" }
768"#;
769 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
770
771 let claude_pair = config.pair_for("claude").unwrap();
772 assert_eq!(claude_pair.reviewer.harness, "codex");
773 assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
774 assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
775
776 let codex_pair = config.pair_for("codex").unwrap();
778 assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
779 }
780
781 #[test]
782 fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
783 let contents = r#"
786[pairs.claude]
787reviewer = { harness = "codex", model = "gpt-5.5" }
788arbiter = { harness = "pi", model = "openai-codex/gpt-5.5" }
789"#;
790 let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
791 assert!(result.is_err());
792 let err = result.unwrap_err().to_string();
793 assert!(
794 err.contains("arbiter"),
795 "expected arbiter error, got: {err}"
796 );
797 }
798
799 #[test]
800 fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
801 let config = TruthMirrorConfig::default();
807 let pair = config.pair_for("claude").expect("claude pair must exist");
808 let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
809 assert_eq!(
810 arbiter.harness, "gemini",
811 "claude arbiter harness must be gemini"
812 );
813 assert_ne!(
814 super::normalized_model(&arbiter.model),
815 super::normalized_model(&pair.reviewer.model),
816 "claude arbiter must be distinct from reviewer after normalization"
817 );
818 }
819
820 #[test]
821 fn normalized_strips_provider_prefix_for_comparison() {
822 assert_eq!(
824 super::normalized_model("openai-codex/gpt-5.5"),
825 super::normalized_model("gpt-5.5")
826 );
827 assert_ne!(super::normalized_model("/"), super::normalized_model(""));
829 }
830
831 #[test]
832 fn legacy_review_block_overrides_default_pair() {
833 let contents = r#"
837ledger_dir = ".truth-mirror"
838
839[review.watched]
840harness = "codex"
841model = "gpt-5.5"
842
843[review.reviewer]
844harness = "gemini"
845model = "gemini-3-pro"
846"#;
847 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
848
849 let pair = config.pair_for("codex").unwrap();
850 assert_eq!(pair.reviewer.harness, "gemini");
851 assert_eq!(pair.reviewer.model, "gemini-3-pro");
852 assert!(config.pair_for("claude").is_some());
854 assert!(config.pair_for("pi").is_some());
855 }
856
857 #[test]
858 fn explicit_pairs_win_over_legacy_review() {
859 let contents = r#"
862[pairs.codex]
863reviewer = { harness = "claude", model = "explicit-model" }
864
865[review.watched]
866harness = "codex"
867model = "gpt-5.5"
868
869[review.reviewer]
870harness = "gemini"
871model = "legacy-model"
872"#;
873 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
874
875 let pair = config.pair_for("codex").unwrap();
876 assert_eq!(pair.reviewer.harness, "claude");
877 assert_eq!(pair.reviewer.model, "explicit-model");
878 }
879
880 #[test]
881 fn explicit_pairs_win_case_insensitively_over_legacy() {
882 let contents = r#"
885[pairs.CODEX]
886reviewer = { harness = "claude", model = "explicit-model" }
887
888[review.watched]
889harness = "codex"
890model = "gpt-5.5"
891
892[review.reviewer]
893harness = "gemini"
894model = "legacy-model"
895"#;
896 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
897
898 let pair = config.pair_for("codex").unwrap();
899 assert_eq!(pair.reviewer.model, "explicit-model");
900 assert_eq!(config.pairs.len(), 1);
902 }
903
904 #[test]
905 fn arbiter_equal_to_reviewer_model_is_rejected() {
906 let contents = r#"
907[pairs.codex]
908reviewer = { harness = "claude", model = "same-model" }
909arbiter = { harness = "pi", model = "same-model" }
910"#;
911 let error =
912 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
913
914 assert!(matches!(
915 error,
916 super::ConfigError::ArbiterNotDistinct { .. }
917 ));
918 }
919
920 #[test]
921 fn missing_config_loads_default() {
922 let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
923
924 assert_eq!(config.pairs.len(), 3);
925 assert!(config.ground_truth.enabled);
926 assert_eq!(config.history.window_user, 3);
927 assert!(!config.enforcement.is_enabled());
928 }
929
930 #[test]
931 fn gates_config_parses_and_builds_policy() {
932 let contents = r#"
933[pairs.codex]
934reviewer = { harness = "claude", model = "claude-opus-4-8" }
935
936[gates]
937fake_markers = ["pretend-pass"]
938evidence_patterns = ["jira:"]
939marker_ignore_paths = [".md", "vendor/"]
940"#;
941 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
942
943 let default_marker = ["mock", "as", "real"].join("-");
948 let policy = config.gates.to_policy();
949 assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
950 assert!(policy.fake_markers.contains(&default_marker));
951 assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
952 assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
953 assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
954 assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
955 }
956
957 #[test]
958 fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
959 let skills_enabled = super::SkillsConfig { enabled: true };
960 let skills_disabled = super::SkillsConfig { enabled: false };
961 let mut memory = super::MemorySkillConfig {
962 enabled: true,
963 mode: super::MemorySkillMode::Stage,
964 ..super::MemorySkillConfig::default()
965 };
966
967 assert!(memory.effective_enabled(&skills_enabled));
968 assert!(!memory.effective_enabled(&skills_disabled));
969
970 memory.enabled = false;
971 assert!(!memory.effective_enabled(&skills_enabled));
972
973 memory.enabled = true;
974 memory.mode = super::MemorySkillMode::Off;
975 assert!(!memory.effective_enabled(&skills_enabled));
976 }
977
978 #[test]
979 fn memory_skill_rejection_precedence_is_legacy_compatible() {
980 let contents = r#"
981[memory_skill.signals]
982rejection_precedence = ["how_to_skill"]
983"#;
984
985 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
986
987 assert_eq!(
988 config.memory_skill.signals.rejection_precedence,
989 vec![super::MemorySkillCandidateKind::HowToSkill]
990 );
991 }
992
993 #[test]
994 fn memory_skill_entropy_threshold_must_be_finite() {
995 let contents = r#"
996[memory_skill.scan]
997entropy_threshold = inf
998"#;
999
1000 let error =
1001 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1002
1003 assert!(matches!(
1004 error,
1005 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1006 ));
1007 }
1008
1009 #[test]
1010 fn memory_skill_entropy_threshold_must_be_positive() {
1011 let contents = r#"
1012[memory_skill.scan]
1013entropy_threshold = 0.0
1014"#;
1015
1016 let error =
1017 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1018
1019 assert!(matches!(
1020 error,
1021 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1022 ));
1023 }
1024
1025 #[test]
1026 fn memory_skill_max_skill_bytes_must_be_positive() {
1027 let contents = r#"
1028[memory_skill]
1029max_skill_bytes = 0
1030"#;
1031
1032 let error =
1033 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1034
1035 assert!(matches!(
1036 error,
1037 super::ConfigError::InvalidMemorySkillMaxSkillBytes
1038 ));
1039 }
1040
1041 #[test]
1042 fn memory_skill_secret_detector_timeout_must_be_positive() {
1043 let contents = r#"
1044[memory_skill.scan]
1045secret_detector_timeout_seconds = 0
1046"#;
1047
1048 let error =
1049 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1050
1051 assert!(matches!(
1052 error,
1053 super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1054 ));
1055 }
1056
1057 #[test]
1058 fn empty_gate_lists_fall_back_to_defaults() {
1059 let policy = super::GatesConfig {
1061 fake_markers: Vec::new(),
1062 evidence_patterns: Vec::new(),
1063 marker_ignore_paths: Vec::new(),
1064 }
1065 .to_policy();
1066
1067 assert!(!policy.fake_markers.is_empty());
1068 assert!(!policy.evidence_patterns.is_empty());
1069 assert!(!policy.marker_ignore_paths.is_empty());
1070 }
1071
1072 #[test]
1073 fn memory_skill_blocked_patterns_trim_and_dedupe() {
1074 let config = super::MemorySkillScanConfig {
1075 blocked_patterns: vec![
1076 " API_KEY ".to_owned(),
1077 " custom ".to_owned(),
1078 " ".to_owned(),
1079 "custom".to_owned(),
1080 ],
1081 ..super::MemorySkillScanConfig::default()
1082 };
1083
1084 let patterns = config.effective_blocked_patterns();
1085
1086 assert_eq!(
1087 patterns
1088 .iter()
1089 .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1090 .count(),
1091 1
1092 );
1093 assert_eq!(
1094 patterns
1095 .iter()
1096 .filter(|pattern| *pattern == "custom")
1097 .count(),
1098 1
1099 );
1100 assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1101 }
1102
1103 #[test]
1104 fn pair_keys_are_lowercased() {
1105 let contents = r#"
1106[pairs.CODEX]
1107reviewer = { harness = "claude", model = "claude-opus-4-8" }
1108"#;
1109 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1110
1111 assert!(config.pair_for("codex").is_some());
1112 assert!(config.pair_for("CoDeX").is_some());
1113 }
1114}