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, Deserialize, Eq, PartialEq, Serialize)]
421#[serde(default)]
422pub struct SkillsConfig {
423 pub enabled: bool,
424}
425
426impl Default for SkillsConfig {
427 fn default() -> Self {
428 Self { enabled: true }
429 }
430}
431
432#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
433#[serde(rename_all = "lowercase")]
434pub enum MemorySkillMode {
435 Off,
436 Suggest,
437 #[default]
438 Stage,
439}
440
441#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
442#[serde(rename_all = "snake_case")]
443pub enum MemorySkillCandidateKind {
444 HowToSkill,
445 AntiPatternSkill,
446 RemediationSkill,
447}
448
449impl MemorySkillCandidateKind {
450 pub fn as_str(self) -> &'static str {
451 match self {
452 Self::HowToSkill => "how_to_skill",
453 Self::AntiPatternSkill => "anti_pattern_skill",
454 Self::RemediationSkill => "remediation_skill",
455 }
456 }
457}
458
459impl std::fmt::Display for MemorySkillCandidateKind {
460 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461 formatter.write_str(self.as_str())
462 }
463}
464
465#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
466#[serde(default)]
467pub struct MemorySkillConfig {
468 pub enabled: bool,
469 pub mode: MemorySkillMode,
470 pub candidate_dir: String,
471 pub approved_dir: String,
472 pub approved_slug_prefix: String,
473 pub toolbox_delivery_registry: String,
474 pub allow_global_writes: bool,
475 pub require_claim_evidence: bool,
476 pub max_candidates_per_commit: usize,
477 pub max_skill_bytes: usize,
478 pub pre_push_blocks_pending: bool,
479 pub redact_secrets: bool,
484 pub signals: MemorySkillSignalsConfig,
485 pub review: MemorySkillReviewConfig,
486 pub scan: MemorySkillScanConfig,
487}
488
489impl Default for MemorySkillConfig {
490 fn default() -> Self {
491 Self {
492 enabled: true,
493 mode: MemorySkillMode::Stage,
494 candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
495 approved_dir: ".agents/skills".to_owned(),
496 approved_slug_prefix: "generated-".to_owned(),
497 toolbox_delivery_registry: String::new(),
498 allow_global_writes: false,
499 require_claim_evidence: true,
500 max_candidates_per_commit: 1,
501 max_skill_bytes: 12_000,
502 pre_push_blocks_pending: false,
503 redact_secrets: true,
504 signals: MemorySkillSignalsConfig::default(),
505 review: MemorySkillReviewConfig::default(),
506 scan: MemorySkillScanConfig::default(),
507 }
508 }
509}
510
511impl MemorySkillConfig {
512 pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
513 skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
514 }
515}
516
517#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
518#[serde(default)]
519pub struct MemorySkillSignalsConfig {
520 pub min_occurrences: usize,
521 pub require_reusable_procedure: bool,
522 pub capture_passes_as_how_to: bool,
523 pub capture_rejections_as_remediation: bool,
524 pub capture_rejections_as_antipattern: bool,
525 pub rejection_precedence: Vec<MemorySkillCandidateKind>,
526}
527
528impl Default for MemorySkillSignalsConfig {
529 fn default() -> Self {
530 Self {
531 min_occurrences: 2,
532 require_reusable_procedure: true,
533 capture_passes_as_how_to: true,
534 capture_rejections_as_remediation: true,
535 capture_rejections_as_antipattern: true,
536 rejection_precedence: vec![
537 MemorySkillCandidateKind::AntiPatternSkill,
538 MemorySkillCandidateKind::RemediationSkill,
539 ],
540 }
541 }
542}
543
544#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
545#[serde(default)]
546pub struct MemorySkillReviewConfig {
547 pub require_adversarial_review: bool,
548 pub require_reviewed_ledger_entry: bool,
549 pub require_pass_for_how_to: bool,
550 pub require_structured_findings_checked: bool,
551 pub reject_without_ledger_entry: bool,
552}
553
554impl Default for MemorySkillReviewConfig {
555 fn default() -> Self {
556 Self {
557 require_adversarial_review: true,
558 require_reviewed_ledger_entry: true,
559 require_pass_for_how_to: true,
560 require_structured_findings_checked: true,
561 reject_without_ledger_entry: true,
562 }
563 }
564}
565
566#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
567#[serde(default)]
568pub struct MemorySkillScanConfig {
569 pub secret_detection: SecretDetectionMode,
570 pub secret_detector: String,
573 pub allow_secret_detector_command: bool,
574 pub secret_detector_timeout_seconds: u64,
575 pub entropy_threshold: f64,
576 pub blocked_patterns: Vec<String>,
577 pub blocked_patterns_case_insensitive: bool,
578}
579
580impl Default for MemorySkillScanConfig {
581 fn default() -> Self {
582 Self {
583 secret_detection: SecretDetectionMode::Required,
584 secret_detector: String::new(),
585 allow_secret_detector_command: false,
586 secret_detector_timeout_seconds: 10,
587 entropy_threshold: 4.5,
588 blocked_patterns: Vec::new(),
589 blocked_patterns_case_insensitive: true,
590 }
591 }
592}
593
594impl MemorySkillScanConfig {
595 pub fn effective_blocked_patterns(&self) -> Vec<String> {
596 let mut patterns = Vec::new();
597 for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
598 push_unique_pattern(
599 &mut patterns,
600 pattern,
601 self.blocked_patterns_case_insensitive,
602 );
603 }
604 for pattern in &self.blocked_patterns {
605 push_unique_pattern(
606 &mut patterns,
607 pattern,
608 self.blocked_patterns_case_insensitive,
609 );
610 }
611 patterns
612 }
613}
614
615fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
616 let pattern = pattern.trim();
617 if pattern.is_empty()
618 || patterns.iter().any(|existing| {
619 if case_insensitive {
620 existing.eq_ignore_ascii_case(pattern)
621 } else {
622 existing == pattern
623 }
624 })
625 {
626 return;
627 }
628 patterns.push(pattern.to_owned());
629}
630
631#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
632#[serde(rename_all = "kebab-case")]
633pub enum SecretDetectionMode {
634 #[default]
635 Required,
636 BestEffort,
637 Off,
638}
639
640#[derive(Debug, Error)]
641pub enum ConfigError {
642 #[error("failed to read config {path}: {source}")]
643 Read {
644 path: PathBuf,
645 #[source]
646 source: io::Error,
647 },
648 #[error("failed to parse config {path}: {source}")]
649 Parse {
650 path: PathBuf,
651 #[source]
652 source: toml::de::Error,
653 },
654 #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
655 ArbiterNotDistinct { writer: String },
656 #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
657 InvalidMemorySkillEntropyThreshold { value: f64 },
658 #[error("memory_skill.max_skill_bytes must be greater than 0")]
659 InvalidMemorySkillMaxSkillBytes,
660 #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
661 InvalidMemorySkillSecretDetectorTimeout,
662}
663
664fn default_ledger_dir() -> String {
665 DEFAULT_STATE_DIR.to_owned()
666}
667
668fn default_writer() -> String {
669 "codex".to_owned()
670}
671
672fn default_pairs() -> BTreeMap<String, AdversarialPair> {
674 let mut pairs = BTreeMap::new();
675 pairs.insert(
676 "codex".to_owned(),
677 AdversarialPair {
678 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
679 arbiter: Some(HarnessSelection::new(
680 "pi",
681 "openai-codex/gpt-5.5",
682 Effort::highest(),
683 )),
684 },
685 );
686 pairs.insert(
687 "claude".to_owned(),
688 AdversarialPair {
689 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
690 arbiter: Some(HarnessSelection::new(
696 "gemini",
697 "gemini-2.5-pro",
698 Effort::highest(),
699 )),
700 },
701 );
702 pairs.insert(
703 "pi".to_owned(),
704 AdversarialPair {
705 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
706 arbiter: Some(HarnessSelection::new(
707 "claude",
708 "claude-opus-4-8",
709 Effort::highest(),
710 )),
711 },
712 );
713 pairs.insert(
716 "grok".to_owned(),
717 AdversarialPair {
718 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
719 arbiter: Some(HarnessSelection::new("codex", "gpt-5.5", Effort::highest())),
720 },
721 );
722 pairs
723}
724
725pub(crate) fn normalized_model(model: &str) -> String {
732 let model = model.trim();
733 let short = model
734 .rsplit_once('/')
735 .and_then(|(_, suffix)| {
736 let s = suffix.trim();
737 if s.is_empty() { None } else { Some(s) }
738 })
739 .unwrap_or(model);
740 short.to_ascii_lowercase()
741}
742
743#[cfg(test)]
744mod tests {
745 use std::path::Path;
746
747 use super::{Effort, TruthMirrorConfig};
748
749 #[test]
750 fn default_config_has_four_opposed_pairs() {
751 let config = TruthMirrorConfig::default();
752
753 assert_eq!(config.pairs.len(), 4);
754 assert_eq!(config.ledger_dir, ".truth");
755 assert!(config.skills.enabled);
756 assert!(config.memory_skill.enabled);
757 assert!(config.memory_skill.effective_enabled(&config.skills));
758 assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
759 let codex = config.pair_for("codex").unwrap();
760 assert_eq!(codex.reviewer.harness, "claude");
761 assert_eq!(codex.reviewer.model, "claude-opus-4-8");
762 assert_eq!(codex.reviewer.effort, Effort::Xhigh);
763 let grok = config.pair_for("grok").unwrap();
764 assert_eq!(grok.reviewer.harness, "claude");
765 assert_eq!(grok.reviewer.model, "claude-opus-4-8");
766 }
767
768 #[test]
769 fn effort_serializes_lowercase() {
770 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
771 assert_eq!(Effort::highest(), Effort::Xhigh);
772 }
773
774 #[test]
775 fn pairs_config_parses_and_resolves_by_writer() {
776 let contents = r#"
779default_writer = "claude"
780
781[pairs.claude]
782reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
783arbiter = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
784
785[pairs.codex]
786reviewer = { harness = "claude", model = "claude-opus-4-8" }
787"#;
788 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
789
790 let claude_pair = config.pair_for("claude").unwrap();
791 assert_eq!(claude_pair.reviewer.harness, "codex");
792 assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
793 assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
794
795 let codex_pair = config.pair_for("codex").unwrap();
797 assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
798 }
799
800 #[test]
801 fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
802 let contents = r#"
805[pairs.claude]
806reviewer = { harness = "codex", model = "gpt-5.5" }
807arbiter = { harness = "pi", model = "openai-codex/gpt-5.5" }
808"#;
809 let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
810 assert!(result.is_err());
811 let err = result.unwrap_err().to_string();
812 assert!(
813 err.contains("arbiter"),
814 "expected arbiter error, got: {err}"
815 );
816 }
817
818 #[test]
819 fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
820 let config = TruthMirrorConfig::default();
826 let pair = config.pair_for("claude").expect("claude pair must exist");
827 let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
828 assert_eq!(
829 arbiter.harness, "gemini",
830 "claude arbiter harness must be gemini"
831 );
832 assert_ne!(
833 super::normalized_model(&arbiter.model),
834 super::normalized_model(&pair.reviewer.model),
835 "claude arbiter must be distinct from reviewer after normalization"
836 );
837 }
838
839 #[test]
840 fn normalized_strips_provider_prefix_for_comparison() {
841 assert_eq!(
843 super::normalized_model("openai-codex/gpt-5.5"),
844 super::normalized_model("gpt-5.5")
845 );
846 assert_ne!(super::normalized_model("/"), super::normalized_model(""));
848 }
849
850 #[test]
851 fn legacy_review_block_overrides_default_pair() {
852 let contents = r#"
856ledger_dir = ".truth-mirror"
857
858[review.watched]
859harness = "codex"
860model = "gpt-5.5"
861
862[review.reviewer]
863harness = "gemini"
864model = "gemini-3-pro"
865"#;
866 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
867
868 let pair = config.pair_for("codex").unwrap();
869 assert_eq!(pair.reviewer.harness, "gemini");
870 assert_eq!(pair.reviewer.model, "gemini-3-pro");
871 assert!(config.pair_for("claude").is_some());
873 assert!(config.pair_for("pi").is_some());
874 assert!(config.pair_for("grok").is_some());
875 }
876
877 #[test]
878 fn explicit_pairs_win_over_legacy_review() {
879 let contents = r#"
882[pairs.codex]
883reviewer = { harness = "claude", model = "explicit-model" }
884
885[review.watched]
886harness = "codex"
887model = "gpt-5.5"
888
889[review.reviewer]
890harness = "gemini"
891model = "legacy-model"
892"#;
893 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
894
895 let pair = config.pair_for("codex").unwrap();
896 assert_eq!(pair.reviewer.harness, "claude");
897 assert_eq!(pair.reviewer.model, "explicit-model");
898 }
899
900 #[test]
901 fn explicit_pairs_win_case_insensitively_over_legacy() {
902 let contents = r#"
905[pairs.CODEX]
906reviewer = { harness = "claude", model = "explicit-model" }
907
908[review.watched]
909harness = "codex"
910model = "gpt-5.5"
911
912[review.reviewer]
913harness = "gemini"
914model = "legacy-model"
915"#;
916 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
917
918 let pair = config.pair_for("codex").unwrap();
919 assert_eq!(pair.reviewer.model, "explicit-model");
920 assert_eq!(config.pairs.len(), 1);
922 }
923
924 #[test]
925 fn arbiter_equal_to_reviewer_model_is_rejected() {
926 let contents = r#"
927[pairs.codex]
928reviewer = { harness = "claude", model = "same-model" }
929arbiter = { harness = "pi", model = "same-model" }
930"#;
931 let error =
932 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
933
934 assert!(matches!(
935 error,
936 super::ConfigError::ArbiterNotDistinct { .. }
937 ));
938 }
939
940 #[test]
941 fn missing_config_loads_default() {
942 let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
943
944 assert_eq!(config.pairs.len(), 4);
945 assert!(config.ground_truth.enabled);
946 assert_eq!(config.history.window_user, 3);
947 assert!(!config.enforcement.is_enabled());
948 }
949
950 #[test]
951 fn gates_config_parses_and_builds_policy() {
952 let contents = r#"
953[pairs.codex]
954reviewer = { harness = "claude", model = "claude-opus-4-8" }
955
956[gates]
957fake_markers = ["pretend-pass"]
958evidence_patterns = ["jira:"]
959marker_ignore_paths = [".md", "vendor/"]
960"#;
961 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
962
963 let default_marker = ["mock", "as", "real"].join("-");
968 let policy = config.gates.to_policy();
969 assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
970 assert!(policy.fake_markers.contains(&default_marker));
971 assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
972 assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
973 assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
974 assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
975 }
976
977 #[test]
978 fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
979 let skills_enabled = super::SkillsConfig { enabled: true };
980 let skills_disabled = super::SkillsConfig { enabled: false };
981 let mut memory = super::MemorySkillConfig {
982 enabled: true,
983 mode: super::MemorySkillMode::Stage,
984 ..super::MemorySkillConfig::default()
985 };
986
987 assert!(memory.effective_enabled(&skills_enabled));
988 assert!(!memory.effective_enabled(&skills_disabled));
989
990 memory.enabled = false;
991 assert!(!memory.effective_enabled(&skills_enabled));
992
993 memory.enabled = true;
994 memory.mode = super::MemorySkillMode::Off;
995 assert!(!memory.effective_enabled(&skills_enabled));
996 }
997
998 #[test]
999 fn missing_config_file_enables_memory_skill() {
1000 let temp = tempfile::tempdir().unwrap();
1001 let config = TruthMirrorConfig::load_or_default(temp.path().join("missing.toml")).unwrap();
1002
1003 assert!(config.skills.enabled);
1004 assert!(config.memory_skill.enabled);
1005 assert!(config.memory_skill.effective_enabled(&config.skills));
1006 }
1007
1008 #[test]
1009 fn empty_config_enables_memory_skill() {
1010 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), "").unwrap();
1011
1012 assert!(config.skills.enabled);
1013 assert!(config.memory_skill.enabled);
1014 assert!(config.memory_skill.effective_enabled(&config.skills));
1015 }
1016
1017 #[test]
1018 fn config_without_skill_sections_enables_memory_skill() {
1019 let contents = r#"
1020[history]
1021window_user = 5
1022"#;
1023 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1024
1025 assert!(config.skills.enabled);
1026 assert!(config.memory_skill.enabled);
1027 assert!(config.memory_skill.effective_enabled(&config.skills));
1028 }
1029
1030 #[test]
1031 fn memory_skill_rejection_precedence_is_legacy_compatible() {
1032 let contents = r#"
1033[memory_skill.signals]
1034rejection_precedence = ["how_to_skill"]
1035"#;
1036
1037 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1038
1039 assert_eq!(
1040 config.memory_skill.signals.rejection_precedence,
1041 vec![super::MemorySkillCandidateKind::HowToSkill]
1042 );
1043 }
1044
1045 #[test]
1046 fn memory_skill_entropy_threshold_must_be_finite() {
1047 let contents = r#"
1048[memory_skill.scan]
1049entropy_threshold = inf
1050"#;
1051
1052 let error =
1053 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1054
1055 assert!(matches!(
1056 error,
1057 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1058 ));
1059 }
1060
1061 #[test]
1062 fn memory_skill_entropy_threshold_must_be_positive() {
1063 let contents = r#"
1064[memory_skill.scan]
1065entropy_threshold = 0.0
1066"#;
1067
1068 let error =
1069 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1070
1071 assert!(matches!(
1072 error,
1073 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1074 ));
1075 }
1076
1077 #[test]
1078 fn memory_skill_max_skill_bytes_must_be_positive() {
1079 let contents = r#"
1080[memory_skill]
1081max_skill_bytes = 0
1082"#;
1083
1084 let error =
1085 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1086
1087 assert!(matches!(
1088 error,
1089 super::ConfigError::InvalidMemorySkillMaxSkillBytes
1090 ));
1091 }
1092
1093 #[test]
1094 fn memory_skill_secret_detector_timeout_must_be_positive() {
1095 let contents = r#"
1096[memory_skill.scan]
1097secret_detector_timeout_seconds = 0
1098"#;
1099
1100 let error =
1101 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1102
1103 assert!(matches!(
1104 error,
1105 super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1106 ));
1107 }
1108
1109 #[test]
1110 fn empty_gate_lists_fall_back_to_defaults() {
1111 let policy = super::GatesConfig {
1113 fake_markers: Vec::new(),
1114 evidence_patterns: Vec::new(),
1115 marker_ignore_paths: Vec::new(),
1116 }
1117 .to_policy();
1118
1119 assert!(!policy.fake_markers.is_empty());
1120 assert!(!policy.evidence_patterns.is_empty());
1121 assert!(!policy.marker_ignore_paths.is_empty());
1122 }
1123
1124 #[test]
1125 fn memory_skill_blocked_patterns_trim_and_dedupe() {
1126 let config = super::MemorySkillScanConfig {
1127 blocked_patterns: vec![
1128 " API_KEY ".to_owned(),
1129 " custom ".to_owned(),
1130 " ".to_owned(),
1131 "custom".to_owned(),
1132 ],
1133 ..super::MemorySkillScanConfig::default()
1134 };
1135
1136 let patterns = config.effective_blocked_patterns();
1137
1138 assert_eq!(
1139 patterns
1140 .iter()
1141 .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1142 .count(),
1143 1
1144 );
1145 assert_eq!(
1146 patterns
1147 .iter()
1148 .filter(|pattern| *pattern == "custom")
1149 .count(),
1150 1
1151 );
1152 assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1153 }
1154
1155 #[test]
1156 fn pair_keys_are_lowercased() {
1157 let contents = r#"
1158[pairs.CODEX]
1159reviewer = { harness = "claude", model = "claude-opus-4-8" }
1160"#;
1161 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1162
1163 assert!(config.pair_for("codex").is_some());
1164 assert!(config.pair_for("CoDeX").is_some());
1165 }
1166}