Skip to main content

truth_mirror/
config.rs

1//! truth-mirror configuration: adversarial pairs, reasoning effort, gates,
2//! ground-truth, trajectory, and enforcement.
3use std::{
4    collections::BTreeMap,
5    fs::{self, OpenOptions},
6    io::{self, Write},
7    path::{Path, PathBuf},
8    sync::atomic::{AtomicU64, Ordering},
9};
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14pub const DEFAULT_STATE_DIR: &str = ".truth";
15pub const LEGACY_STATE_DIR: &str = ".truth-mirror";
16pub const MAX_PETITION_BATCH_SIZE: usize = 32;
17
18/// Environment variable that can force-disable (or leave enabled) truth-mirror.
19pub const TRUTH_MIRROR_ENABLED_ENV: &str = "TRUTH_MIRROR_ENABLED";
20
21static CONFIG_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
22
23/// Checked-in annotated config asset used for both system and project defaults.
24pub const DEFAULT_CONFIG_ASSET: &str = include_str!("../assets/config.toml");
25
26/// Hard safety ceiling for concurrent provider processes.
27pub const MAX_CONCURRENT_REVIEW_RUNS: usize = 4;
28pub const DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS: &[&str] = &[
29    "BEGIN SYSTEM PROMPT",
30    "ignore previous instructions",
31    "api_key",
32    "secret_key",
33    "secret:",
34    "secret=",
35    "token:",
36    "token=",
37    "password:",
38    "password=",
39    "credential:",
40    "credential=",
41    "private_key",
42    "aws_secret_access_key",
43];
44
45/// Reasoning effort (`C`). Highest is `Xhigh`.
46#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
47#[serde(rename_all = "lowercase")]
48#[value(rename_all = "lowercase")]
49pub enum Effort {
50    Minimal,
51    Low,
52    Medium,
53    High,
54    #[default]
55    Xhigh,
56}
57
58impl Effort {
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Effort::Minimal => "minimal",
62            Effort::Low => "low",
63            Effort::Medium => "medium",
64            Effort::High => "high",
65            Effort::Xhigh => "xhigh",
66        }
67    }
68
69    /// The highest supported effort — the default reviewer aggressiveness.
70    pub fn highest() -> Self {
71        Effort::Xhigh
72    }
73
74    /// Value accepted by Claude's `--effort`. Verified: claude takes
75    /// `low|medium|high|xhigh|max` and has no `minimal`, so `Minimal` clamps to
76    /// `low`. (Codex and Pi both accept `minimal`.)
77    pub fn claude_value(self) -> &'static str {
78        match self {
79            Effort::Minimal => "low",
80            other => other.as_str(),
81        }
82    }
83}
84
85/// A concrete reviewer/arbiter selection: harness + model + reasoning effort.
86#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87pub struct HarnessSelection {
88    pub harness: String,
89    pub model: String,
90    #[serde(default)]
91    pub effort: Effort,
92}
93
94impl HarnessSelection {
95    pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
96        Self {
97            harness: harness.into(),
98            model: model.into(),
99            effort,
100        }
101    }
102}
103
104/// The opposed reviewer (and optional second-pass arbiter) for one writer harness.
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106pub struct AdversarialPair {
107    pub reviewer: HarnessSelection,
108    #[serde(default)]
109    pub arbiter: Option<HarnessSelection>,
110}
111
112#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
113pub struct TruthMirrorConfig {
114    #[serde(default = "default_enabled")]
115    pub enabled: bool,
116    #[serde(default = "default_announce_when_disabled")]
117    pub announce_when_disabled: bool,
118    #[serde(default = "default_ledger_dir")]
119    pub ledger_dir: String,
120    #[serde(default)]
121    pub allow_same_model: bool,
122    /// Writer harness assumed when none is provided on the CLI or commit trailer.
123    #[serde(default = "default_writer")]
124    pub default_writer: String,
125    /// Adversarial pairs keyed by writer harness (lowercase). Empty in the parsed
126    /// form when `[pairs]` is absent; `normalize` fills defaults / folds legacy.
127    #[serde(default)]
128    pub pairs: BTreeMap<String, AdversarialPair>,
129    #[serde(default)]
130    pub strict: StrictConfig,
131    #[serde(default)]
132    pub gates: GatesConfig,
133    #[serde(default)]
134    pub ground_truth: GroundTruthConfig,
135    #[serde(default)]
136    pub history: HistoryConfig,
137    #[serde(default)]
138    pub enforcement: EnforcementConfig,
139    #[serde(default)]
140    pub skills: SkillsConfig,
141    #[serde(default)]
142    pub memory_skill: MemorySkillConfig,
143    /// Reviewer subprocess settings (per-review wall-clock timeout).
144    #[serde(default)]
145    pub reviewer: ReviewerPolicyConfig,
146    /// Legacy `[review]` block, folded into `pairs` on load for back-compat.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub review: Option<LegacyReview>,
149}
150
151impl Default for TruthMirrorConfig {
152    fn default() -> Self {
153        Self {
154            enabled: default_enabled(),
155            announce_when_disabled: default_announce_when_disabled(),
156            ledger_dir: default_ledger_dir(),
157            allow_same_model: false,
158            default_writer: default_writer(),
159            pairs: default_pairs(),
160            strict: StrictConfig::default(),
161            gates: GatesConfig::default(),
162            ground_truth: GroundTruthConfig::default(),
163            history: HistoryConfig::default(),
164            enforcement: EnforcementConfig::default(),
165            skills: SkillsConfig::default(),
166            memory_skill: MemorySkillConfig::default(),
167            reviewer: ReviewerPolicyConfig::default(),
168            review: None,
169        }
170    }
171}
172/// Resolve the system configuration directory from `$XDG_CONFIG_HOME` or `$HOME`.
173pub fn system_config_dir() -> Result<PathBuf, ConfigError> {
174    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
175        return Ok(PathBuf::from(xdg).join("truth-mirror"));
176    }
177    if let Some(home) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
178        return Ok(PathBuf::from(home).join(".config/truth-mirror"));
179    }
180    Err(ConfigError::NoSystemConfigDir)
181}
182
183/// Full path to the system `config.toml`.
184pub fn system_config_path() -> Result<PathBuf, ConfigError> {
185    Ok(system_config_dir()?.join("config.toml"))
186}
187
188/// Write `contents` to `path` atomically by hard-linking a synced temporary file.
189/// The link is the create-exclusive primitive: an existing destination is preserved.
190pub(crate) fn write_config_atomically(path: &Path, contents: &str) -> Result<(), ConfigError> {
191    let dir = path
192        .parent()
193        .ok_or_else(|| ConfigError::CreateSystemConfig {
194            path: path.to_path_buf(),
195            source: io::Error::new(io::ErrorKind::InvalidInput, "no parent directory"),
196        })?;
197    fs::create_dir_all(dir).map_err(|source| ConfigError::CreateSystemConfig {
198        path: path.to_path_buf(),
199        source,
200    })?;
201    let serial = CONFIG_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
202    let temp_path = dir.join(format!(
203        ".truth-mirror-config-{}-{serial}.tmp",
204        std::process::id()
205    ));
206    let mut temp = OpenOptions::new()
207        .write(true)
208        .create_new(true)
209        .open(&temp_path)
210        .map_err(|source| ConfigError::CreateSystemConfig {
211            path: path.to_path_buf(),
212            source,
213        })?;
214    if let Err(source) = temp
215        .write_all(contents.as_bytes())
216        .and_then(|()| temp.sync_all())
217    {
218        let _ = fs::remove_file(&temp_path);
219        return Err(ConfigError::CreateSystemConfig {
220            path: path.to_path_buf(),
221            source,
222        });
223    }
224    let linked = fs::hard_link(&temp_path, path);
225    let _ = fs::remove_file(&temp_path);
226    match linked {
227        Ok(()) => Ok(()),
228        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
229        Err(source) => Err(ConfigError::CreateSystemConfig {
230            path: path.to_path_buf(),
231            source,
232        }),
233    }
234}
235
236/// Create the system config from the checked-in asset when absent, preserving any
237/// existing file byte-for-byte. Returns the path to the system config.
238pub fn ensure_system_config() -> Result<PathBuf, ConfigError> {
239    let path = system_config_path()?;
240    if !path.exists() {
241        write_config_atomically(&path, DEFAULT_CONFIG_ASSET)?;
242    }
243    Ok(path)
244}
245/// Create the project-local config from the checked-in asset when absent,
246/// preserving any existing file byte-for-byte. Returns the path to the project
247/// config.
248pub fn ensure_project_config(state_dir: &Path) -> Result<PathBuf, ConfigError> {
249    let path = TruthMirrorConfig::default_path(state_dir);
250    if !path.exists() {
251        write_config_atomically(&path, DEFAULT_CONFIG_ASSET)?;
252    }
253    Ok(path)
254}
255
256/// Deep-merge two TOML values. Tables merge recursively; scalars and arrays are
257/// replaced by the overlay value.
258fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
259    match (base, overlay) {
260        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
261            for (key, overlay_value) in overlay_table {
262                let base_value = base_table
263                    .entry(key)
264                    .or_insert(toml::Value::Table(toml::map::Map::new()));
265                merge_toml(base_value, overlay_value);
266            }
267        }
268        (base, overlay) => *base = overlay,
269    }
270}
271
272fn read_toml_value(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
273    match fs::read_to_string(path) {
274        Ok(contents) => {
275            let table = contents
276                .parse::<toml::Table>()
277                .map_err(|source| ConfigError::Parse {
278                    path: path.to_path_buf(),
279                    source,
280                })?;
281            Ok(Some(toml::Value::Table(table)))
282        }
283        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
284        Err(source) => Err(ConfigError::Read {
285            path: path.to_path_buf(),
286            source,
287        }),
288    }
289}
290
291fn parse_env_enabled() -> Result<Option<bool>, ConfigError> {
292    match std::env::var_os(TRUTH_MIRROR_ENABLED_ENV) {
293        None => Ok(None),
294        Some(raw) => {
295            let s = raw.to_string_lossy().trim().to_ascii_lowercase();
296            match s.as_str() {
297                "true" | "1" => Ok(Some(true)),
298                "false" | "0" => Ok(Some(false)),
299                _ => Err(ConfigError::InvalidEnabledEnv {
300                    value: raw.to_string_lossy().into_owned(),
301                }),
302            }
303        }
304    }
305}
306
307impl TruthMirrorConfig {
308    /// Effective runtime loader: built-in defaults < system config < project or
309    /// explicit config < `TRUTH_MIRROR_ENABLED` env var. The optional system
310    /// layer is read-only and best-effort; installation materializes it.
311    /// Monotonic `enabled`: any system/project/env false disables; true never
312    /// re-enables a lower false layer.
313    pub fn load_for_cli(
314        explicit_path: Option<&Path>,
315        state_dir: &Path,
316    ) -> Result<Self, ConfigError> {
317        let project_path = if let Some(path) = explicit_path {
318            PathBuf::from(path)
319        } else {
320            // Resolve the project config path WITHOUT creating it. Loading config
321            // happens for every command, including `install-hooks --dry-run` and
322            // commands that fail a precondition, and both must leave no `.truth`
323            // behind. Project-local materialization is owned by the install path
324            // (hooks::ensure_state_gitignore -> ensure_default_config).
325            Self::default_path(state_dir)
326        };
327        Self::load_effective(project_path)
328    }
329
330    fn load_effective(project_path: PathBuf) -> Result<Self, ConfigError> {
331        // The checked-in asset is the authoritative default surface; parse it once
332        // and use it as the merge base so every non-legacy key is present.
333        let mut base: toml::Value = toml::Value::Table(
334            DEFAULT_CONFIG_ASSET
335                .parse::<toml::Table>()
336                .expect("checked-in assets/config.toml must be valid TOML"),
337        );
338
339        // Track `enabled` across layers with AND semantics: any explicit false
340        // from any layer disables inference; true cannot re-enable after false.
341        let mut enabled = true;
342
343        if let Ok(system_path) = system_config_path()
344            && system_path.is_file()
345        {
346            match read_toml_value(&system_path) {
347                Ok(Some(system)) => {
348                    if system.get("enabled").and_then(toml::Value::as_bool) == Some(false) {
349                        enabled = false;
350                    }
351                    merge_toml(&mut base, system);
352                }
353                Ok(None) | Err(ConfigError::Read { .. }) => {}
354                Err(error) => return Err(error),
355            }
356        }
357
358        if let Some(project) = read_toml_value(&project_path)? {
359            if project.get("enabled").and_then(toml::Value::as_bool) == Some(false) {
360                enabled = false;
361            }
362            merge_toml(&mut base, project);
363        }
364
365        let env_enabled = parse_env_enabled()?;
366        let final_enabled = enabled && env_enabled.unwrap_or(true);
367        if let Some(table) = base.as_table_mut() {
368            table.insert("enabled".to_owned(), toml::Value::Boolean(final_enabled));
369        }
370
371        let mut config: Self = base.try_into().map_err(|source| ConfigError::Parse {
372            path: project_path.clone(),
373            source,
374        })?;
375        config.normalize();
376        config.validate()?;
377        Ok(config)
378    }
379    pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
380        let path = path.into();
381        match fs::read_to_string(&path) {
382            Ok(contents) => Self::from_toml_str(&path, &contents),
383            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
384            Err(source) => Err(ConfigError::Read { path, source }),
385        }
386    }
387
388    pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
389        let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
390            path: path.to_path_buf(),
391            source,
392        })?;
393        config.normalize();
394        config.validate()?;
395        Ok(config)
396    }
397
398    /// Resolve the effective `pairs`: explicit `[pairs]` win; a legacy `[review]`
399    /// block folds in as an OVERRIDE for its writer (on top of defaults so other
400    /// writers still resolve); with neither, all writers get default pairs.
401    fn normalize(&mut self) {
402        // Lowercase explicit pair keys FIRST so every comparison below is
403        // case-insensitive (matching `pair_for`). Otherwise a `[pairs.CODEX]` could
404        // be clobbered by a legacy `[review]` folded under `codex`.
405        let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
406            .into_iter()
407            .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
408            .collect();
409        self.pairs = lowered;
410
411        let had_explicit_pairs = !self.pairs.is_empty();
412        let review = self.review.take();
413
414        if !had_explicit_pairs && review.is_some() {
415            // Legacy-only config: seed defaults so non-legacy writers still resolve.
416            self.pairs = default_pairs();
417        }
418
419        if let Some(review) = review {
420            let writer = review.watched.harness.trim().to_ascii_lowercase();
421            let pair = AdversarialPair {
422                reviewer: HarnessSelection::new(
423                    review.reviewer.harness,
424                    review.reviewer.model,
425                    Effort::highest(),
426                ),
427                arbiter: None,
428            };
429            if had_explicit_pairs {
430                // Explicit `[pairs]` win: legacy only fills a writer they omit.
431                self.pairs.entry(writer).or_insert(pair);
432            } else {
433                // Legacy-only config: override the seeded default for this writer.
434                self.pairs.insert(writer, pair);
435            }
436        }
437
438        if self.pairs.is_empty() {
439            self.pairs = default_pairs();
440        }
441        if self.history.transcript_path.as_deref() == Some("") {
442            self.history.transcript_path = None;
443        }
444    }
445
446    fn validate(&self) -> Result<(), ConfigError> {
447        for (writer, pair) in &self.pairs {
448            if let Some(arbiter) = &pair.arbiter
449                && normalized_model(&arbiter.model) == normalized_model(&pair.reviewer.model)
450            {
451                return Err(ConfigError::ArbiterNotDistinct {
452                    writer: writer.clone(),
453                });
454            }
455        }
456        if !self.memory_skill.scan.entropy_threshold.is_finite()
457            || self.memory_skill.scan.entropy_threshold <= 0.0
458        {
459            return Err(ConfigError::InvalidMemorySkillEntropyThreshold {
460                value: self.memory_skill.scan.entropy_threshold,
461            });
462        }
463        if !self.memory_skill.signals.similarity_threshold.is_finite()
464            || !(0.0..=1.0).contains(&self.memory_skill.signals.similarity_threshold)
465        {
466            return Err(ConfigError::InvalidMemorySkillSimilarityThreshold {
467                value: self.memory_skill.signals.similarity_threshold,
468            });
469        }
470        if !(1..=MAX_PETITION_BATCH_SIZE).contains(&self.reviewer.max_petition_batch_size) {
471            return Err(ConfigError::InvalidPetitionBatchSize {
472                value: self.reviewer.max_petition_batch_size,
473            });
474        }
475        if !(1..=MAX_CONCURRENT_REVIEW_RUNS).contains(&self.reviewer.max_concurrent_runs) {
476            return Err(ConfigError::InvalidConcurrentReviewRuns {
477                value: self.reviewer.max_concurrent_runs,
478            });
479        }
480
481        if self.memory_skill.max_skill_bytes == 0 {
482            return Err(ConfigError::InvalidMemorySkillMaxSkillBytes);
483        }
484        if self.memory_skill.scan.secret_detector_timeout_seconds == 0 {
485            return Err(ConfigError::InvalidMemorySkillSecretDetectorTimeout);
486        }
487        Ok(())
488    }
489
490    /// The adversarial pair for a writer harness (case-insensitive).
491    pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
492        self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
493    }
494
495    pub fn default_path(state_dir: &Path) -> PathBuf {
496        state_dir.join("config.toml")
497    }
498    #[cfg(test)]
499    fn from_test_sources(
500        system: Option<&str>,
501        project: &str,
502        env_enabled: Option<bool>,
503    ) -> Result<Self, ConfigError> {
504        let mut base: toml::Value = toml::Value::Table(
505            DEFAULT_CONFIG_ASSET
506                .parse::<toml::Table>()
507                .expect("checked-in asset must be valid TOML"),
508        );
509        let mut enabled = true;
510        if let Some(system) = system {
511            let value: toml::Value =
512                toml::from_str(system).map_err(|source| ConfigError::Parse {
513                    path: PathBuf::from("system.toml"),
514                    source,
515                })?;
516            if value.get("enabled").and_then(toml::Value::as_bool) == Some(false) {
517                enabled = false;
518            }
519            merge_toml(&mut base, value);
520        }
521        let project_value: toml::Value =
522            toml::from_str(project).map_err(|source| ConfigError::Parse {
523                path: PathBuf::from("project.toml"),
524                source,
525            })?;
526        if project_value.get("enabled").and_then(toml::Value::as_bool) == Some(false) {
527            enabled = false;
528        }
529        merge_toml(&mut base, project_value);
530
531        let final_enabled = enabled && env_enabled.unwrap_or(true);
532        if let Some(table) = base.as_table_mut() {
533            table.insert("enabled".to_owned(), toml::Value::Boolean(final_enabled));
534        }
535
536        let mut config: Self = base.try_into().map_err(|source| ConfigError::Parse {
537            path: PathBuf::from("project.toml"),
538            source,
539        })?;
540        config.normalize();
541        config.validate()?;
542        Ok(config)
543    }
544}
545
546/// Default per-review wall-clock timeout: 20 minutes. Two days of field use
547/// showed wedged reviewer subprocesses running 1–7 hours with the queue frozen
548/// behind them; a healthy adversarial review takes minutes, so 20 minutes is
549/// comfortably past legitimate work and short enough to unstick the queue.
550pub const DEFAULT_REVIEWER_TIMEOUT_SECS: u64 = 20 * 60;
551
552/// Reviewer queue policies, subprocess settings, and concurrent scheduling.
553#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
554#[serde(default)]
555pub struct ReviewerPolicyConfig {
556    /// Opt-in compatibility boundary: false retains one reviewer invocation per petition.
557    pub batch_petitions: bool,
558    pub max_petition_batch_size: usize,
559    /// Per-review wall-clock timeout in seconds. A reviewer subprocess that
560    /// outlives this is killed and the run recorded as failed with a timeout
561    /// reason, so one wedged reviewer never freezes the queue. `0` disables
562    /// the timeout.
563    pub timeout_secs: u64,
564    /// Maximum number of independent reviewer processes in one drain wave.
565    /// `1` preserves the historical serial scheduler.
566    pub max_concurrent_runs: usize,
567}
568
569impl Default for ReviewerPolicyConfig {
570    fn default() -> Self {
571        Self {
572            batch_petitions: false,
573            max_petition_batch_size: MAX_PETITION_BATCH_SIZE,
574            timeout_secs: DEFAULT_REVIEWER_TIMEOUT_SECS,
575            max_concurrent_runs: 1,
576        }
577    }
578}
579
580impl ReviewerPolicyConfig {
581    /// The configured timeout as a duration, or `None` when disabled (`0`).
582    pub fn timeout(&self) -> Option<std::time::Duration> {
583        (self.timeout_secs > 0).then(|| std::time::Duration::from_secs(self.timeout_secs))
584    }
585}
586
587/// Legacy single watched/reviewer pair (pre-adversarial-pairs config).
588#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
589pub struct LegacyReview {
590    pub watched: LegacyModel,
591    pub reviewer: LegacyModel,
592}
593
594#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
595pub struct LegacyModel {
596    pub harness: String,
597    pub model: String,
598}
599
600/// Strict goal-loop thresholds. `N == 0` disables that stop condition.
601#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
602#[serde(default)]
603pub struct StrictConfig {
604    pub stop_after_lies: u32,
605    pub stop_after_fuckups: u32,
606    pub max_passes: u32,
607}
608
609impl Default for StrictConfig {
610    fn default() -> Self {
611        Self {
612            stop_after_lies: 1,
613            stop_after_fuckups: 3,
614            max_passes: 3,
615        }
616    }
617}
618
619impl StrictConfig {
620    pub fn goal_policy(
621        &self,
622        lies_override: Option<u32>,
623        fuckups_override: Option<u32>,
624    ) -> crate::reviewer::StrictGoalPolicy {
625        crate::reviewer::StrictGoalPolicy {
626            stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
627            stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
628        }
629    }
630}
631
632/// Deterministic-gate configuration: banned diff sentinels, evidence patterns,
633/// and diff paths excluded from marker scanning.
634#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
635#[serde(default)]
636pub struct GatesConfig {
637    pub fake_markers: Vec<String>,
638    pub evidence_patterns: Vec<String>,
639    pub marker_ignore_paths: Vec<String>,
640}
641
642impl Default for GatesConfig {
643    fn default() -> Self {
644        Self {
645            fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
646            evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
647            marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
648        }
649    }
650}
651
652impl GatesConfig {
653    /// The resolved gate policy: config values plus built-in defaults for any
654    /// list left empty, so an empty config never silently disables a gate.
655    pub fn to_policy(&self) -> crate::claim::GatePolicy {
656        crate::claim::GatePolicy {
657            fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
658            evidence_patterns: union_defaults(
659                &self.evidence_patterns,
660                crate::claim::DEFAULT_EVIDENCE_PATTERNS,
661            ),
662            marker_ignore_paths: union_defaults(
663                &self.marker_ignore_paths,
664                crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
665            ),
666        }
667    }
668}
669
670fn strs(values: &[&str]) -> Vec<String> {
671    values.iter().map(|value| (*value).to_owned()).collect()
672}
673
674/// Built-in defaults ALWAYS apply; configured values are additive. A repo can add
675/// its own banned tokens or evidence patterns but cannot silently disable the
676/// deterministic anti-lie defaults by setting a shorter list.
677fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
678    let mut out: Vec<String> = strs(defaults);
679    for value in values {
680        if !out.iter().any(|existing| existing == value) {
681            out.push(value.clone());
682        }
683    }
684    out
685}
686
687/// Ground-truth constraint loading.
688#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
689#[serde(default)]
690pub struct GroundTruthConfig {
691    pub enabled: bool,
692    pub file_names: Vec<String>,
693    pub include_openspec_specs: bool,
694    pub max_bytes: usize,
695}
696
697impl Default for GroundTruthConfig {
698    fn default() -> Self {
699        Self {
700            enabled: true,
701            file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md", ".truth/TRUTH.md"]
702                .iter()
703                .map(|name| (*name).to_owned())
704                .collect(),
705            include_openspec_specs: true,
706            max_bytes: 20_000,
707        }
708    }
709}
710
711/// Conversation-trajectory window.
712#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
713#[serde(default)]
714pub struct HistoryConfig {
715    pub window_user: usize,
716    pub window_agent: usize,
717    pub max_bytes: usize,
718    /// Optional repo-relative JSONL transcript (`{role,text}` per line). When
719    /// unset or missing, recent commits are used as the trajectory proxy.
720    pub transcript_path: Option<String>,
721}
722
723impl Default for HistoryConfig {
724    fn default() -> Self {
725        Self {
726            window_user: 3,
727            window_agent: 10,
728            max_bytes: 12_000,
729            transcript_path: None,
730        }
731    }
732}
733
734/// Enforcement escalation thresholds. `0` disables a condition.
735#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
736#[serde(default)]
737pub struct EnforcementConfig {
738    pub block_tools_after_unresolved: u32,
739    pub block_tools_after_secs: u64,
740}
741
742impl EnforcementConfig {
743    pub fn is_enabled(&self) -> bool {
744        self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
745    }
746}
747
748#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
749#[serde(default)]
750pub struct SkillsConfig {
751    pub enabled: bool,
752}
753
754impl Default for SkillsConfig {
755    fn default() -> Self {
756        Self { enabled: true }
757    }
758}
759
760#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
761#[serde(rename_all = "lowercase")]
762pub enum MemorySkillMode {
763    Off,
764    Suggest,
765    #[default]
766    Stage,
767}
768
769#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
770#[serde(rename_all = "snake_case")]
771pub enum MemorySkillCandidateKind {
772    HowToSkill,
773    AntiPatternSkill,
774    RemediationSkill,
775}
776
777impl MemorySkillCandidateKind {
778    pub fn as_str(self) -> &'static str {
779        match self {
780            Self::HowToSkill => "how_to_skill",
781            Self::AntiPatternSkill => "anti_pattern_skill",
782            Self::RemediationSkill => "remediation_skill",
783        }
784    }
785}
786
787impl std::fmt::Display for MemorySkillCandidateKind {
788    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        formatter.write_str(self.as_str())
790    }
791}
792
793#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
794#[serde(default)]
795pub struct MemorySkillConfig {
796    pub enabled: bool,
797    pub mode: MemorySkillMode,
798    pub candidate_dir: String,
799    pub approved_dir: String,
800    pub approved_slug_prefix: String,
801    pub toolbox_delivery_registry: String,
802    pub allow_global_writes: bool,
803    pub require_claim_evidence: bool,
804    pub max_candidates_per_commit: usize,
805    pub max_skill_bytes: usize,
806    pub pre_push_blocks_pending: bool,
807    /// Historical config key: when true, `memory_skill::scan_text` rejects the
808    /// whole candidate on secret matches; it does not redact matched text in
809    /// place. Scanning is fully disabled only when this is false and
810    /// `memory_skill.scan.secret_detection = "off"`.
811    pub redact_secrets: bool,
812    pub signals: MemorySkillSignalsConfig,
813    pub review: MemorySkillReviewConfig,
814    pub scan: MemorySkillScanConfig,
815}
816
817impl Default for MemorySkillConfig {
818    fn default() -> Self {
819        Self {
820            enabled: true,
821            mode: MemorySkillMode::Stage,
822            candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
823            approved_dir: ".agents/skills".to_owned(),
824            approved_slug_prefix: "generated-".to_owned(),
825            toolbox_delivery_registry: String::new(),
826            allow_global_writes: false,
827            require_claim_evidence: true,
828            max_candidates_per_commit: 1,
829            max_skill_bytes: 12_000,
830            pre_push_blocks_pending: false,
831            redact_secrets: true,
832            signals: MemorySkillSignalsConfig::default(),
833            review: MemorySkillReviewConfig::default(),
834            scan: MemorySkillScanConfig::default(),
835        }
836    }
837}
838
839impl MemorySkillConfig {
840    pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
841        skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
842    }
843}
844
845#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
846#[serde(default)]
847pub struct MemorySkillSignalsConfig {
848    pub similarity_threshold: f64,
849    pub min_occurrences: usize,
850    pub require_reusable_procedure: bool,
851    pub capture_passes_as_how_to: bool,
852    pub capture_rejections_as_remediation: bool,
853    pub capture_rejections_as_antipattern: bool,
854    pub rejection_precedence: Vec<MemorySkillCandidateKind>,
855}
856
857impl Default for MemorySkillSignalsConfig {
858    fn default() -> Self {
859        Self {
860            similarity_threshold: 0.55,
861            min_occurrences: 2,
862            require_reusable_procedure: true,
863            capture_passes_as_how_to: true,
864            capture_rejections_as_remediation: true,
865            capture_rejections_as_antipattern: true,
866            rejection_precedence: vec![
867                MemorySkillCandidateKind::AntiPatternSkill,
868                MemorySkillCandidateKind::RemediationSkill,
869            ],
870        }
871    }
872}
873
874#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
875#[serde(default)]
876pub struct MemorySkillReviewConfig {
877    pub require_adversarial_review: bool,
878    pub require_reviewed_ledger_entry: bool,
879    pub require_pass_for_how_to: bool,
880    pub require_structured_findings_checked: bool,
881    pub reject_without_ledger_entry: bool,
882}
883
884impl Default for MemorySkillReviewConfig {
885    fn default() -> Self {
886        Self {
887            require_adversarial_review: true,
888            require_reviewed_ledger_entry: true,
889            require_pass_for_how_to: true,
890            require_structured_findings_checked: true,
891            reject_without_ledger_entry: true,
892        }
893    }
894}
895
896#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
897#[serde(default)]
898pub struct MemorySkillScanConfig {
899    pub secret_detection: SecretDetectionMode,
900    /// Optional scanner command. A non-zero exit is treated as a detected
901    /// finding; best-effort mode only downgrades operational failures.
902    pub secret_detector: String,
903    pub allow_secret_detector_command: bool,
904    pub secret_detector_timeout_seconds: u64,
905    pub entropy_threshold: f64,
906    pub blocked_patterns: Vec<String>,
907    pub blocked_patterns_case_insensitive: bool,
908}
909
910impl Default for MemorySkillScanConfig {
911    fn default() -> Self {
912        Self {
913            secret_detection: SecretDetectionMode::Required,
914            secret_detector: String::new(),
915            allow_secret_detector_command: false,
916            secret_detector_timeout_seconds: 10,
917            entropy_threshold: 4.5,
918            blocked_patterns: Vec::new(),
919            blocked_patterns_case_insensitive: true,
920        }
921    }
922}
923
924impl MemorySkillScanConfig {
925    pub fn effective_blocked_patterns(&self) -> Vec<String> {
926        let mut patterns = Vec::new();
927        for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
928            push_unique_pattern(
929                &mut patterns,
930                pattern,
931                self.blocked_patterns_case_insensitive,
932            );
933        }
934        for pattern in &self.blocked_patterns {
935            push_unique_pattern(
936                &mut patterns,
937                pattern,
938                self.blocked_patterns_case_insensitive,
939            );
940        }
941        patterns
942    }
943}
944
945fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
946    let pattern = pattern.trim();
947    if pattern.is_empty()
948        || patterns.iter().any(|existing| {
949            if case_insensitive {
950                existing.eq_ignore_ascii_case(pattern)
951            } else {
952                existing == pattern
953            }
954        })
955    {
956        return;
957    }
958    patterns.push(pattern.to_owned());
959}
960
961#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
962#[serde(rename_all = "kebab-case")]
963pub enum SecretDetectionMode {
964    #[default]
965    Required,
966    BestEffort,
967    Off,
968}
969
970#[derive(Debug, Error)]
971pub enum ConfigError {
972    #[error("failed to read config {path}: {source}")]
973    Read {
974        path: PathBuf,
975        #[source]
976        source: io::Error,
977    },
978    #[error("failed to parse config {path}: {source}")]
979    Parse {
980        path: PathBuf,
981        #[source]
982        source: toml::de::Error,
983    },
984    #[error("failed to create system config {path}: {source}")]
985    CreateSystemConfig {
986        path: PathBuf,
987        #[source]
988        source: io::Error,
989    },
990    #[error("TRUTH_MIRROR_ENABLED must be 'true', 'false', '1', or '0', got '{value}'")]
991    InvalidEnabledEnv { value: String },
992    #[error("could not resolve system config directory; set XDG_CONFIG_HOME or HOME")]
993    NoSystemConfigDir,
994    #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
995    ArbiterNotDistinct { writer: String },
996    #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
997    InvalidMemorySkillEntropyThreshold { value: f64 },
998    #[error(
999        "memory_skill.signals.similarity_threshold must be finite and between 0.0 and 1.0, got {value}"
1000    )]
1001    InvalidMemorySkillSimilarityThreshold { value: f64 },
1002    #[error(
1003        "reviewer.max_petition_batch_size must be between 1 and {MAX_PETITION_BATCH_SIZE}, got {value}"
1004    )]
1005    InvalidPetitionBatchSize { value: usize },
1006    #[error(
1007        "reviewer.max_concurrent_runs must be between 1 and {MAX_CONCURRENT_REVIEW_RUNS}, got {value}"
1008    )]
1009    InvalidConcurrentReviewRuns { value: usize },
1010    #[error("memory_skill.max_skill_bytes must be greater than 0")]
1011    InvalidMemorySkillMaxSkillBytes,
1012    #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
1013    InvalidMemorySkillSecretDetectorTimeout,
1014}
1015
1016fn default_enabled() -> bool {
1017    true
1018}
1019
1020fn default_announce_when_disabled() -> bool {
1021    true
1022}
1023
1024fn default_ledger_dir() -> String {
1025    DEFAULT_STATE_DIR.to_owned()
1026}
1027
1028fn default_writer() -> String {
1029    "codex".to_owned()
1030}
1031
1032/// Sensible opposed pairs so a repo with no `[pairs]` still reviews adversarially.
1033fn default_pairs() -> BTreeMap<String, AdversarialPair> {
1034    let mut pairs = BTreeMap::new();
1035    pairs.insert(
1036        "codex".to_owned(),
1037        AdversarialPair {
1038            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
1039            arbiter: Some(HarnessSelection::new(
1040                "pi",
1041                "openai-codex/gpt-5.5",
1042                Effort::highest(),
1043            )),
1044        },
1045    );
1046    pairs.insert(
1047        "claude".to_owned(),
1048        AdversarialPair {
1049            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
1050            // Arbiter must be a third distinct model. The reviewer is codex/gpt-5.5;
1051            // using pi/openai-codex/gpt-5.5 as arbiter would be the SAME underlying
1052            // model after provider-prefix stripping, violating the distinct-model rule.
1053            // Gemini (gemini-2.5-pro) is a genuinely different family and provider,
1054            // and InvocationPlan::for_harness supports ReviewerHarness::Gemini.
1055            arbiter: Some(HarnessSelection::new(
1056                "gemini",
1057                "gemini-2.5-pro",
1058                Effort::highest(),
1059            )),
1060        },
1061    );
1062    pairs.insert(
1063        "pi".to_owned(),
1064        AdversarialPair {
1065            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
1066            arbiter: Some(HarnessSelection::new(
1067                "claude",
1068                "claude-opus-4-8",
1069                Effort::highest(),
1070            )),
1071        },
1072    );
1073    // Grok is a watched agent surface, not a reviewer harness. Oppose with Claude
1074    // review + Codex arbiter so model/provider families stay distinct by default.
1075    pairs.insert(
1076        "grok".to_owned(),
1077        AdversarialPair {
1078            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
1079            arbiter: Some(HarnessSelection::new("codex", "gpt-5.5", Effort::highest())),
1080        },
1081    );
1082    pairs
1083}
1084
1085/// Normalise a model identifier for opposition comparisons.
1086///
1087/// Strips the provider prefix (everything up to and including the last `/`)
1088/// so `openai-codex/gpt-5.5` compares equal to `gpt-5.5`. Keeps config
1089/// validation consistent with the runtime check in `reviewer::normalized_model`.
1090/// The prefix is only stripped when it leaves a non-empty suffix.
1091pub(crate) fn normalized_model(model: &str) -> String {
1092    let model = model.trim();
1093    let short = model
1094        .rsplit_once('/')
1095        .and_then(|(_, suffix)| {
1096            let s = suffix.trim();
1097            if s.is_empty() { None } else { Some(s) }
1098        })
1099        .unwrap_or(model);
1100    short.to_ascii_lowercase()
1101}
1102
1103#[cfg(test)]
1104fn parse_env_enabled_for_test(value: &str) -> Result<Option<bool>, ConfigError> {
1105    if value.is_empty() {
1106        return Ok(None);
1107    }
1108    match value.trim().to_ascii_lowercase().as_str() {
1109        "true" | "1" => Ok(Some(true)),
1110        "false" | "0" => Ok(Some(false)),
1111        _ => Err(ConfigError::InvalidEnabledEnv {
1112            value: value.to_owned(),
1113        }),
1114    }
1115}
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119    use std::path::Path;
1120
1121    #[test]
1122    fn default_config_has_four_opposed_pairs() {
1123        let config = TruthMirrorConfig::default();
1124
1125        assert_eq!(config.pairs.len(), 4);
1126        assert_eq!(config.ledger_dir, ".truth");
1127        assert!(config.skills.enabled);
1128        assert!(config.memory_skill.enabled);
1129        assert!(config.memory_skill.effective_enabled(&config.skills));
1130        assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
1131        assert_eq!(config.memory_skill.signals.similarity_threshold, 0.55);
1132        let codex = config.pair_for("codex").unwrap();
1133        assert_eq!(codex.reviewer.harness, "claude");
1134        assert_eq!(codex.reviewer.model, "claude-opus-4-8");
1135        assert_eq!(codex.reviewer.effort, Effort::Xhigh);
1136        let grok = config.pair_for("grok").unwrap();
1137        assert_eq!(grok.reviewer.harness, "claude");
1138        assert_eq!(grok.reviewer.model, "claude-opus-4-8");
1139    }
1140
1141    #[test]
1142    fn reviewer_batching_defaults_to_legacy_serial_petitions() {
1143        let config = TruthMirrorConfig::default();
1144
1145        assert!(!config.reviewer.batch_petitions);
1146        assert_eq!(
1147            config.reviewer.max_petition_batch_size,
1148            super::MAX_PETITION_BATCH_SIZE
1149        );
1150        assert_eq!(config.reviewer.max_concurrent_runs, 1);
1151    }
1152
1153    #[test]
1154    fn reviewer_batching_policy_parses_when_explicitly_enabled() {
1155        let contents = r#"
1156[reviewer]
1157batch_petitions = true
1158max_petition_batch_size = 7
1159max_concurrent_runs = 2
1160"#;
1161
1162        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1163
1164        assert!(config.reviewer.batch_petitions);
1165        assert_eq!(config.reviewer.max_petition_batch_size, 7);
1166        assert_eq!(config.reviewer.max_concurrent_runs, 2);
1167    }
1168
1169    #[test]
1170    fn reviewer_concurrency_rejects_zero_and_values_above_the_bound() {
1171        for value in [0, super::MAX_CONCURRENT_REVIEW_RUNS + 1] {
1172            let contents = format!("[reviewer]\nmax_concurrent_runs = {value}\n");
1173
1174            let error =
1175                TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
1176
1177            assert!(matches!(
1178                error,
1179                super::ConfigError::InvalidConcurrentReviewRuns { value: invalid }
1180                    if invalid == value
1181            ));
1182        }
1183    }
1184
1185    #[test]
1186    fn reviewer_batch_size_rejects_zero_and_values_above_the_bound() {
1187        for value in [0, super::MAX_PETITION_BATCH_SIZE + 1] {
1188            let contents =
1189                format!("[reviewer]\nbatch_petitions = true\nmax_petition_batch_size = {value}\n");
1190
1191            let error =
1192                TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
1193
1194            assert!(matches!(
1195                error,
1196                super::ConfigError::InvalidPetitionBatchSize { value: invalid }
1197                    if invalid == value
1198            ));
1199        }
1200    }
1201
1202    #[test]
1203    fn reviewer_timeout_defaults_to_twenty_minutes() {
1204        let config = TruthMirrorConfig::default();
1205
1206        assert_eq!(
1207            config.reviewer.timeout_secs,
1208            super::DEFAULT_REVIEWER_TIMEOUT_SECS
1209        );
1210        assert_eq!(
1211            config.reviewer.timeout(),
1212            Some(std::time::Duration::from_secs(20 * 60))
1213        );
1214    }
1215
1216    #[test]
1217    fn reviewer_timeout_parses_and_zero_disables() {
1218        // A wedged reviewer froze the ONYXIO queue for hours (0.9.2 field
1219        // report): the timeout is configurable, and 0 opts out explicitly.
1220        let contents = "[reviewer]\ntimeout_secs = 90\n";
1221        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1222        assert_eq!(
1223            config.reviewer.timeout(),
1224            Some(std::time::Duration::from_secs(90))
1225        );
1226
1227        let disabled = "[reviewer]\ntimeout_secs = 0\n";
1228        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), disabled).unwrap();
1229        assert_eq!(config.reviewer.timeout(), None);
1230    }
1231
1232    #[test]
1233    fn effort_serializes_lowercase() {
1234        assert_eq!(Effort::Xhigh.as_str(), "xhigh");
1235        assert_eq!(Effort::highest(), Effort::Xhigh);
1236    }
1237
1238    #[test]
1239    fn pairs_config_parses_and_resolves_by_writer() {
1240        // Arbiter must be distinct from reviewer after provider-prefix stripping:
1241        // gemini/gemini-2.5-pro normalizes to "gemini-2.5-pro", which != "gpt-5.5".
1242        let contents = r#"
1243default_writer = "claude"
1244
1245[pairs.claude]
1246reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
1247arbiter  = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
1248
1249[pairs.codex]
1250reviewer = { harness = "claude", model = "claude-opus-4-8" }
1251"#;
1252        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1253
1254        let claude_pair = config.pair_for("claude").unwrap();
1255        assert_eq!(claude_pair.reviewer.harness, "codex");
1256        assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
1257        assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
1258
1259        // Omitted effort defaults to highest.
1260        let codex_pair = config.pair_for("codex").unwrap();
1261        assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
1262    }
1263
1264    #[test]
1265    fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
1266        // openai-codex/gpt-5.5 normalizes to gpt-5.5, same as the reviewer model.
1267        // This was the bug in the original claude default pair (B1/B2 remediation).
1268        let contents = r#"
1269[pairs.claude]
1270reviewer = { harness = "codex", model = "gpt-5.5" }
1271arbiter  = { harness = "pi", model = "openai-codex/gpt-5.5" }
1272"#;
1273        let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
1274        assert!(result.is_err());
1275        let err = result.unwrap_err().to_string();
1276        assert!(
1277            err.contains("arbiter"),
1278            "expected arbiter error, got: {err}"
1279        );
1280    }
1281
1282    #[test]
1283    fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
1284        // B1 fix: the claude default pair used to have arbiter=pi/openai-codex/gpt-5.5
1285        // and reviewer=codex/gpt-5.5. After provider-prefix stripping both normalize
1286        // to "gpt-5.5", which is the same model. The fix changed the arbiter to
1287        // gemini/gemini-2.5-pro. This test ensures the default config loads without
1288        // ArbiterNotDistinct and has a genuinely distinct arbiter.
1289        let config = TruthMirrorConfig::default();
1290        let pair = config.pair_for("claude").expect("claude pair must exist");
1291        let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
1292        assert_eq!(
1293            arbiter.harness, "gemini",
1294            "claude arbiter harness must be gemini"
1295        );
1296        assert_ne!(
1297            super::normalized_model(&arbiter.model),
1298            super::normalized_model(&pair.reviewer.model),
1299            "claude arbiter must be distinct from reviewer after normalization"
1300        );
1301    }
1302
1303    #[test]
1304    fn normalized_strips_provider_prefix_for_comparison() {
1305        // B2: openai-codex/gpt-5.5 and gpt-5.5 should normalize to the same token.
1306        assert_eq!(
1307            super::normalized_model("openai-codex/gpt-5.5"),
1308            super::normalized_model("gpt-5.5")
1309        );
1310        // Prefix-only values (bare "/") keep their identity to avoid false collisions.
1311        assert_ne!(super::normalized_model("/"), super::normalized_model(""));
1312    }
1313
1314    #[test]
1315    fn legacy_review_block_overrides_default_pair() {
1316        // Reviewer distinct from the built-in codex default (claude/opus-4-8) so
1317        // the test proves the legacy block actually overrides the default rather
1318        // than coincidentally matching it.
1319        let contents = r#"
1320ledger_dir = ".truth-mirror"
1321
1322[review.watched]
1323harness = "codex"
1324model = "gpt-5.5"
1325
1326[review.reviewer]
1327harness = "gemini"
1328model = "gemini-3-pro"
1329"#;
1330        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1331
1332        let pair = config.pair_for("codex").unwrap();
1333        assert_eq!(pair.reviewer.harness, "gemini");
1334        assert_eq!(pair.reviewer.model, "gemini-3-pro");
1335        // Other writers still resolve from defaults.
1336        assert!(config.pair_for("claude").is_some());
1337        assert!(config.pair_for("pi").is_some());
1338        assert!(config.pair_for("grok").is_some());
1339    }
1340
1341    #[test]
1342    fn explicit_pairs_win_over_legacy_review() {
1343        // A mixed migration config must not let legacy [review] clobber a modern
1344        // explicit [pairs.<writer>] entry.
1345        let contents = r#"
1346[pairs.codex]
1347reviewer = { harness = "claude", model = "explicit-model" }
1348
1349[review.watched]
1350harness = "codex"
1351model = "gpt-5.5"
1352
1353[review.reviewer]
1354harness = "gemini"
1355model = "legacy-model"
1356"#;
1357        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1358
1359        let pair = config.pair_for("codex").unwrap();
1360        assert_eq!(pair.reviewer.harness, "claude");
1361        assert_eq!(pair.reviewer.model, "explicit-model");
1362    }
1363
1364    #[test]
1365    fn explicit_pairs_win_case_insensitively_over_legacy() {
1366        // `[pairs.CODEX]` (uppercase) must not be clobbered by a legacy [review]
1367        // folded under lowercase `codex`.
1368        let contents = r#"
1369[pairs.CODEX]
1370reviewer = { harness = "claude", model = "explicit-model" }
1371
1372[review.watched]
1373harness = "codex"
1374model = "gpt-5.5"
1375
1376[review.reviewer]
1377harness = "gemini"
1378model = "legacy-model"
1379"#;
1380        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1381
1382        let pair = config.pair_for("codex").unwrap();
1383        assert_eq!(pair.reviewer.model, "explicit-model");
1384        // No duplicate CODEX/codex entries.
1385        assert_eq!(config.pairs.len(), 1);
1386    }
1387
1388    #[test]
1389    fn arbiter_equal_to_reviewer_model_is_rejected() {
1390        let contents = r#"
1391[pairs.codex]
1392reviewer = { harness = "claude", model = "same-model" }
1393arbiter  = { harness = "pi", model = "same-model" }
1394"#;
1395        let error =
1396            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1397
1398        assert!(matches!(
1399            error,
1400            super::ConfigError::ArbiterNotDistinct { .. }
1401        ));
1402    }
1403
1404    #[test]
1405    fn missing_config_loads_default() {
1406        let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
1407
1408        assert_eq!(config.pairs.len(), 4);
1409        assert!(config.ground_truth.enabled);
1410        assert_eq!(config.history.window_user, 3);
1411        assert!(!config.enforcement.is_enabled());
1412    }
1413
1414    #[test]
1415    fn gates_config_parses_and_builds_policy() {
1416        let contents = r#"
1417[pairs.codex]
1418reviewer = { harness = "claude", model = "claude-opus-4-8" }
1419
1420[gates]
1421fake_markers = ["pretend-pass"]
1422evidence_patterns = ["jira:"]
1423marker_ignore_paths = [".md", "vendor/"]
1424"#;
1425        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1426
1427        // Config values are ADDITIVE: built-in defaults always apply, plus the
1428        // repo's custom entries. A repo can add but not silently disable defaults.
1429        // The default marker literal is built at runtime so it never appears as an
1430        // added line in this file (truth-mirror's own gate would flag it).
1431        let default_marker = ["mock", "as", "real"].join("-");
1432        let policy = config.gates.to_policy();
1433        assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
1434        assert!(policy.fake_markers.contains(&default_marker));
1435        assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
1436        assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
1437        assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
1438        assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
1439    }
1440
1441    #[test]
1442    fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
1443        let skills_enabled = super::SkillsConfig { enabled: true };
1444        let skills_disabled = super::SkillsConfig { enabled: false };
1445        let mut memory = super::MemorySkillConfig {
1446            enabled: true,
1447            mode: super::MemorySkillMode::Stage,
1448            ..super::MemorySkillConfig::default()
1449        };
1450
1451        assert!(memory.effective_enabled(&skills_enabled));
1452        assert!(!memory.effective_enabled(&skills_disabled));
1453
1454        memory.enabled = false;
1455        assert!(!memory.effective_enabled(&skills_enabled));
1456
1457        memory.enabled = true;
1458        memory.mode = super::MemorySkillMode::Off;
1459        assert!(!memory.effective_enabled(&skills_enabled));
1460    }
1461
1462    #[test]
1463    fn missing_config_file_enables_memory_skill() {
1464        let temp = tempfile::tempdir().unwrap();
1465        let config = TruthMirrorConfig::load_or_default(temp.path().join("missing.toml")).unwrap();
1466
1467        assert!(config.skills.enabled);
1468        assert!(config.memory_skill.enabled);
1469        assert!(config.memory_skill.effective_enabled(&config.skills));
1470    }
1471
1472    #[test]
1473    fn empty_config_enables_memory_skill() {
1474        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), "").unwrap();
1475
1476        assert!(config.skills.enabled);
1477        assert!(config.memory_skill.enabled);
1478        assert!(config.memory_skill.effective_enabled(&config.skills));
1479    }
1480
1481    #[test]
1482    fn config_without_skill_sections_enables_memory_skill() {
1483        let contents = r#"
1484[history]
1485window_user = 5
1486
1487"#;
1488        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1489
1490        assert!(config.skills.enabled);
1491        assert!(config.memory_skill.enabled);
1492        assert!(config.memory_skill.effective_enabled(&config.skills));
1493    }
1494
1495    #[test]
1496    fn memory_skill_rejection_precedence_is_legacy_compatible() {
1497        let contents = r#"
1498[memory_skill.signals]
1499rejection_precedence = ["how_to_skill"]
1500"#;
1501
1502        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1503
1504        assert_eq!(
1505            config.memory_skill.signals.rejection_precedence,
1506            vec![super::MemorySkillCandidateKind::HowToSkill]
1507        );
1508    }
1509
1510    #[test]
1511    fn memory_skill_entropy_threshold_must_be_finite() {
1512        let contents = r#"
1513[memory_skill.scan]
1514entropy_threshold = inf
1515"#;
1516
1517        let error =
1518            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1519
1520        assert!(matches!(
1521            error,
1522            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1523        ));
1524    }
1525    #[test]
1526    fn runtime_enable_defaults_to_true_and_parses_top_level_false() {
1527        assert!(TruthMirrorConfig::default().enabled);
1528        let config =
1529            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), "enabled = false\n")
1530                .unwrap();
1531        assert!(!config.enabled);
1532    }
1533
1534    #[test]
1535    fn announce_when_disabled_defaults_true_and_uses_last_layer() {
1536        assert!(TruthMirrorConfig::default().announce_when_disabled);
1537
1538        let system_disabled = super::TruthMirrorConfig::from_test_sources(
1539            Some("announce_when_disabled = false\n"),
1540            "",
1541            None,
1542        )
1543        .unwrap();
1544        assert!(!system_disabled.announce_when_disabled);
1545
1546        let project_override = super::TruthMirrorConfig::from_test_sources(
1547            Some("announce_when_disabled = false\n"),
1548            "announce_when_disabled = true\n",
1549            None,
1550        )
1551        .unwrap();
1552        assert!(project_override.announce_when_disabled);
1553    }
1554
1555    #[test]
1556    fn memory_skill_entropy_threshold_must_be_positive() {
1557        let contents = r#"
1558[memory_skill.scan]
1559entropy_threshold = 0.0
1560"#;
1561
1562        let error =
1563            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1564
1565        assert!(matches!(
1566            error,
1567            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1568        ));
1569    }
1570
1571    #[test]
1572    fn memory_skill_max_skill_bytes_must_be_positive() {
1573        let contents = r#"
1574[memory_skill]
1575max_skill_bytes = 0
1576"#;
1577
1578        let error =
1579            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1580
1581        assert!(matches!(
1582            error,
1583            super::ConfigError::InvalidMemorySkillMaxSkillBytes
1584        ));
1585    }
1586
1587    #[test]
1588    fn memory_skill_secret_detector_timeout_must_be_positive() {
1589        let contents = r#"
1590[memory_skill.scan]
1591secret_detector_timeout_seconds = 0
1592"#;
1593
1594        let error =
1595            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1596
1597        assert!(matches!(
1598            error,
1599            super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1600        ));
1601    }
1602
1603    #[test]
1604    fn memory_skill_similarity_threshold_must_be_finite_and_bounded() {
1605        for value in ["-0.1", "1.1", "nan", "inf"] {
1606            let contents = format!("[memory_skill.signals]\nsimilarity_threshold = {value}\n");
1607            let error =
1608                TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
1609            assert!(matches!(
1610                error,
1611                super::ConfigError::InvalidMemorySkillSimilarityThreshold { .. }
1612            ));
1613        }
1614    }
1615
1616    #[test]
1617    fn empty_gate_lists_fall_back_to_defaults() {
1618        // An explicitly empty list must not silently disable a gate.
1619        let policy = super::GatesConfig {
1620            fake_markers: Vec::new(),
1621            evidence_patterns: Vec::new(),
1622            marker_ignore_paths: Vec::new(),
1623        }
1624        .to_policy();
1625
1626        assert!(!policy.fake_markers.is_empty());
1627        assert!(!policy.evidence_patterns.is_empty());
1628        assert!(!policy.marker_ignore_paths.is_empty());
1629    }
1630
1631    #[test]
1632    fn memory_skill_blocked_patterns_trim_and_dedupe() {
1633        let config = super::MemorySkillScanConfig {
1634            blocked_patterns: vec![
1635                " API_KEY ".to_owned(),
1636                " custom ".to_owned(),
1637                " ".to_owned(),
1638                "custom".to_owned(),
1639            ],
1640            ..super::MemorySkillScanConfig::default()
1641        };
1642
1643        let patterns = config.effective_blocked_patterns();
1644
1645        assert_eq!(
1646            patterns
1647                .iter()
1648                .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1649                .count(),
1650            1
1651        );
1652        assert_eq!(
1653            patterns
1654                .iter()
1655                .filter(|pattern| *pattern == "custom")
1656                .count(),
1657            1
1658        );
1659        assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1660    }
1661
1662    #[test]
1663    fn pair_keys_are_lowercased() {
1664        let contents = r#"
1665[pairs.CODEX]
1666reviewer = { harness = "claude", model = "claude-opus-4-8" }
1667"#;
1668        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1669
1670        assert!(config.pair_for("codex").is_some());
1671        assert!(config.pair_for("CoDeX").is_some());
1672    }
1673
1674    #[test]
1675    fn checked_in_config_asset_parses_to_defaults() {
1676        let parsed = TruthMirrorConfig::from_toml_str(
1677            Path::new("assets/config.toml"),
1678            super::DEFAULT_CONFIG_ASSET,
1679        )
1680        .expect("checked-in asset must be valid and pass validation");
1681        let expected = TruthMirrorConfig::default();
1682        assert_eq!(parsed, expected);
1683    }
1684    #[test]
1685    fn system_false_project_true_is_disabled() {
1686        let config = super::TruthMirrorConfig::from_test_sources(
1687            Some("enabled = false\n"),
1688            "enabled = true\n",
1689            None,
1690        )
1691        .unwrap();
1692        assert!(!config.enabled);
1693    }
1694
1695    #[test]
1696    fn project_false_env_true_is_disabled() {
1697        let config =
1698            super::TruthMirrorConfig::from_test_sources(None, "enabled = false\n", Some(true))
1699                .unwrap();
1700        assert!(!config.enabled);
1701    }
1702
1703    #[test]
1704    fn env_false_overrides_project_true() {
1705        let config =
1706            super::TruthMirrorConfig::from_test_sources(None, "enabled = true\n", Some(false))
1707                .unwrap();
1708        assert!(!config.enabled);
1709    }
1710
1711    #[test]
1712    fn default_asset_is_enabled() {
1713        let config = super::TruthMirrorConfig::from_test_sources(None, "", None).unwrap();
1714        assert!(config.enabled);
1715    }
1716
1717    #[test]
1718    fn ensure_project_config_creates_and_preserves_existing_bytes() {
1719        let temp = tempfile::tempdir().unwrap();
1720        let state_dir = temp.path().join(".truth");
1721        super::ensure_project_config(&state_dir).unwrap();
1722        let path = state_dir.join("config.toml");
1723        assert!(path.is_file());
1724        assert_eq!(
1725            std::fs::read_to_string(&path).unwrap(),
1726            super::DEFAULT_CONFIG_ASSET
1727        );
1728        std::fs::write(&path, "custom").unwrap();
1729        super::ensure_project_config(&state_dir).unwrap();
1730        assert_eq!(std::fs::read_to_string(&path).unwrap(), "custom");
1731    }
1732
1733    #[test]
1734    fn invalid_env_value_is_a_hard_error() {
1735        let result = super::parse_env_enabled_for_test("maybe");
1736        assert!(matches!(
1737            result,
1738            Err(super::ConfigError::InvalidEnabledEnv { .. })
1739        ));
1740    }
1741
1742    #[test]
1743    fn valid_env_values_are_accepted() {
1744        assert_eq!(
1745            super::parse_env_enabled_for_test("true").unwrap(),
1746            Some(true)
1747        );
1748        assert_eq!(
1749            super::parse_env_enabled_for_test("false").unwrap(),
1750            Some(false)
1751        );
1752        assert_eq!(super::parse_env_enabled_for_test("1").unwrap(), Some(true));
1753        assert_eq!(super::parse_env_enabled_for_test("0").unwrap(), Some(false));
1754        assert_eq!(super::parse_env_enabled_for_test("").unwrap(), None);
1755    }
1756}