Skip to main content

truth_mirror/
config.rs

1//! truth-mirror configuration: adversarial pairs, reasoning effort, gates,
2//! ground-truth, trajectory, and enforcement.
3
4use 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/// Reasoning effort (`C`). Highest is `Xhigh`.
33#[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    /// The highest supported effort — the default reviewer aggressiveness.
57    pub fn highest() -> Self {
58        Effort::Xhigh
59    }
60
61    /// Value accepted by Claude's `--effort`. Verified: claude takes
62    /// `low|medium|high|xhigh|max` and has no `minimal`, so `Minimal` clamps to
63    /// `low`. (Codex and Pi both accept `minimal`.)
64    pub fn claude_value(self) -> &'static str {
65        match self {
66            Effort::Minimal => "low",
67            other => other.as_str(),
68        }
69    }
70}
71
72/// A concrete reviewer/arbiter selection: harness + model + reasoning effort.
73#[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/// The opposed reviewer (and optional second-pass arbiter) for one writer harness.
92#[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    /// Writer harness assumed when none is provided on the CLI or commit trailer.
106    #[serde(default = "default_writer")]
107    pub default_writer: String,
108    /// Adversarial pairs keyed by writer harness (lowercase). Empty in the parsed
109    /// form when `[pairs]` is absent; `normalize` fills defaults / folds legacy.
110    #[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    /// Legacy `[review]` block, folded into `pairs` on load for back-compat.
127    #[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    /// Resolve the effective `pairs`: explicit `[pairs]` win; a legacy `[review]`
179    /// block folds in as an OVERRIDE for its writer (on top of defaults so other
180    /// writers still resolve); with neither, all writers get default pairs.
181    fn normalize(&mut self) {
182        // Lowercase explicit pair keys FIRST so every comparison below is
183        // case-insensitive (matching `pair_for`). Otherwise a `[pairs.CODEX]` could
184        // be clobbered by a legacy `[review]` folded under `codex`.
185        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            // Legacy-only config: seed defaults so non-legacy writers still resolve.
196            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                // Explicit `[pairs]` win: legacy only fills a writer they omit.
211                self.pairs.entry(writer).or_insert(pair);
212            } else {
213                // Legacy-only config: override the seeded default for this writer.
214                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    /// The adversarial pair for a writer harness (case-insensitive).
250    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/// Legacy single watched/reviewer pair (pre-adversarial-pairs config).
260#[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/// Strict goal-loop thresholds. `N == 0` disables that stop condition.
273#[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/// Deterministic-gate configuration: banned diff sentinels, evidence patterns,
305/// and diff paths excluded from marker scanning.
306#[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    /// The resolved gate policy: config values plus built-in defaults for any
326    /// list left empty, so an empty config never silently disables a gate.
327    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
346/// Built-in defaults ALWAYS apply; configured values are additive. A repo can add
347/// its own banned tokens or evidence patterns but cannot silently disable the
348/// deterministic anti-lie defaults by setting a shorter list.
349fn 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/// Ground-truth constraint loading.
360#[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/// Conversation-trajectory window.
384#[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    /// Optional repo-relative JSONL transcript (`{role,text}` per line). When
391    /// unset or missing, recent commits are used as the trajectory proxy.
392    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/// Enforcement escalation thresholds. `0` disables a condition.
407#[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    /// Historical config key: when true, `memory_skill::scan_text` rejects the
474    /// whole candidate on secret matches; it does not redact matched text in
475    /// place. Scanning is fully disabled only when this is false and
476    /// `memory_skill.scan.secret_detection = "off"`.
477    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    /// Optional scanner command. A non-zero exit is treated as a detected
565    /// finding; best-effort mode only downgrades operational failures.
566    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
666/// Sensible opposed pairs so a repo with no `[pairs]` still reviews adversarially.
667fn 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        // Omitted effort defaults to highest.
756        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        // Reviewer distinct from the built-in codex default (claude/opus-4-8) so
763        // the test proves the legacy block actually overrides the default rather
764        // than coincidentally matching it.
765        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        // Other writers still resolve from defaults.
782        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        // A mixed migration config must not let legacy [review] clobber a modern
789        // explicit [pairs.<writer>] entry.
790        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        // `[pairs.CODEX]` (uppercase) must not be clobbered by a legacy [review]
812        // folded under lowercase `codex`.
813        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        // No duplicate CODEX/codex entries.
830        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        // Config values are ADDITIVE: built-in defaults always apply, plus the
873        // repo's custom entries. A repo can add but not silently disable defaults.
874        // The default marker literal is built at runtime so it never appears as an
875        // added line in this file (truth-mirror's own gate would flag it).
876        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        // An explicitly empty list must not silently disable a gate.
989        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}