use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_STATE_DIR: &str = ".truth";
pub const LEGACY_STATE_DIR: &str = ".truth-mirror";
pub const DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS: &[&str] = &[
"BEGIN SYSTEM PROMPT",
"ignore previous instructions",
"api_key",
"secret_key",
"secret:",
"secret=",
"token:",
"token=",
"password:",
"password=",
"credential:",
"credential=",
"private_key",
"aws_secret_access_key",
];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[value(rename_all = "lowercase")]
pub enum Effort {
Minimal,
Low,
Medium,
High,
#[default]
Xhigh,
}
impl Effort {
pub fn as_str(self) -> &'static str {
match self {
Effort::Minimal => "minimal",
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High => "high",
Effort::Xhigh => "xhigh",
}
}
pub fn highest() -> Self {
Effort::Xhigh
}
pub fn claude_value(self) -> &'static str {
match self {
Effort::Minimal => "low",
other => other.as_str(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct HarnessSelection {
pub harness: String,
pub model: String,
#[serde(default)]
pub effort: Effort,
}
impl HarnessSelection {
pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
Self {
harness: harness.into(),
model: model.into(),
effort,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AdversarialPair {
pub reviewer: HarnessSelection,
#[serde(default)]
pub arbiter: Option<HarnessSelection>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct TruthMirrorConfig {
#[serde(default = "default_ledger_dir")]
pub ledger_dir: String,
#[serde(default)]
pub allow_same_model: bool,
#[serde(default = "default_writer")]
pub default_writer: String,
#[serde(default)]
pub pairs: BTreeMap<String, AdversarialPair>,
#[serde(default)]
pub strict: StrictConfig,
#[serde(default)]
pub gates: GatesConfig,
#[serde(default)]
pub ground_truth: GroundTruthConfig,
#[serde(default)]
pub history: HistoryConfig,
#[serde(default)]
pub enforcement: EnforcementConfig,
#[serde(default)]
pub skills: SkillsConfig,
#[serde(default)]
pub memory_skill: MemorySkillConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review: Option<LegacyReview>,
}
impl Default for TruthMirrorConfig {
fn default() -> Self {
Self {
ledger_dir: default_ledger_dir(),
allow_same_model: false,
default_writer: default_writer(),
pairs: default_pairs(),
strict: StrictConfig::default(),
gates: GatesConfig::default(),
ground_truth: GroundTruthConfig::default(),
history: HistoryConfig::default(),
enforcement: EnforcementConfig::default(),
skills: SkillsConfig::default(),
memory_skill: MemorySkillConfig::default(),
review: None,
}
}
}
impl TruthMirrorConfig {
pub fn load_for_cli(
explicit_path: Option<&Path>,
state_dir: &Path,
) -> Result<Self, ConfigError> {
let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
Self::load_or_default(path)
}
pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
let path = path.into();
match fs::read_to_string(&path) {
Ok(contents) => Self::from_toml_str(&path, &contents),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(ConfigError::Read { path, source }),
}
}
pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
config.normalize();
config.validate()?;
Ok(config)
}
fn normalize(&mut self) {
let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
.into_iter()
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
.collect();
self.pairs = lowered;
let had_explicit_pairs = !self.pairs.is_empty();
let review = self.review.take();
if !had_explicit_pairs && review.is_some() {
self.pairs = default_pairs();
}
if let Some(review) = review {
let writer = review.watched.harness.trim().to_ascii_lowercase();
let pair = AdversarialPair {
reviewer: HarnessSelection::new(
review.reviewer.harness,
review.reviewer.model,
Effort::highest(),
),
arbiter: None,
};
if had_explicit_pairs {
self.pairs.entry(writer).or_insert(pair);
} else {
self.pairs.insert(writer, pair);
}
}
if self.pairs.is_empty() {
self.pairs = default_pairs();
}
}
fn validate(&self) -> Result<(), ConfigError> {
for (writer, pair) in &self.pairs {
if let Some(arbiter) = &pair.arbiter
&& normalized_model(&arbiter.model) == normalized_model(&pair.reviewer.model)
{
return Err(ConfigError::ArbiterNotDistinct {
writer: writer.clone(),
});
}
}
if !self.memory_skill.scan.entropy_threshold.is_finite()
|| self.memory_skill.scan.entropy_threshold <= 0.0
{
return Err(ConfigError::InvalidMemorySkillEntropyThreshold {
value: self.memory_skill.scan.entropy_threshold,
});
}
if self.memory_skill.max_skill_bytes == 0 {
return Err(ConfigError::InvalidMemorySkillMaxSkillBytes);
}
if self.memory_skill.scan.secret_detector_timeout_seconds == 0 {
return Err(ConfigError::InvalidMemorySkillSecretDetectorTimeout);
}
Ok(())
}
pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
}
pub fn default_path(state_dir: &Path) -> PathBuf {
state_dir.join("config.toml")
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct LegacyReview {
pub watched: LegacyModel,
pub reviewer: LegacyModel,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct LegacyModel {
pub harness: String,
pub model: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct StrictConfig {
pub stop_after_lies: u32,
pub stop_after_fuckups: u32,
pub max_passes: u32,
}
impl Default for StrictConfig {
fn default() -> Self {
Self {
stop_after_lies: 1,
stop_after_fuckups: 3,
max_passes: 3,
}
}
}
impl StrictConfig {
pub fn goal_policy(
&self,
lies_override: Option<u32>,
fuckups_override: Option<u32>,
) -> crate::reviewer::StrictGoalPolicy {
crate::reviewer::StrictGoalPolicy {
stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct GatesConfig {
pub fake_markers: Vec<String>,
pub evidence_patterns: Vec<String>,
pub marker_ignore_paths: Vec<String>,
}
impl Default for GatesConfig {
fn default() -> Self {
Self {
fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
}
}
}
impl GatesConfig {
pub fn to_policy(&self) -> crate::claim::GatePolicy {
crate::claim::GatePolicy {
fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
evidence_patterns: union_defaults(
&self.evidence_patterns,
crate::claim::DEFAULT_EVIDENCE_PATTERNS,
),
marker_ignore_paths: union_defaults(
&self.marker_ignore_paths,
crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
),
}
}
}
fn strs(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
let mut out: Vec<String> = strs(defaults);
for value in values {
if !out.iter().any(|existing| existing == value) {
out.push(value.clone());
}
}
out
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct GroundTruthConfig {
pub enabled: bool,
pub file_names: Vec<String>,
pub include_openspec_specs: bool,
pub max_bytes: usize,
}
impl Default for GroundTruthConfig {
fn default() -> Self {
Self {
enabled: true,
file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
.iter()
.map(|name| (*name).to_owned())
.collect(),
include_openspec_specs: true,
max_bytes: 20_000,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct HistoryConfig {
pub window_user: usize,
pub window_agent: usize,
pub max_bytes: usize,
pub transcript_path: Option<String>,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
window_user: 3,
window_agent: 10,
max_bytes: 12_000,
transcript_path: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct EnforcementConfig {
pub block_tools_after_unresolved: u32,
pub block_tools_after_secs: u64,
}
impl EnforcementConfig {
pub fn is_enabled(&self) -> bool {
self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct SkillsConfig {
pub enabled: bool,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MemorySkillMode {
Off,
Suggest,
#[default]
Stage,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MemorySkillCandidateKind {
HowToSkill,
AntiPatternSkill,
RemediationSkill,
}
impl MemorySkillCandidateKind {
pub fn as_str(self) -> &'static str {
match self {
Self::HowToSkill => "how_to_skill",
Self::AntiPatternSkill => "anti_pattern_skill",
Self::RemediationSkill => "remediation_skill",
}
}
}
impl std::fmt::Display for MemorySkillCandidateKind {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default)]
pub struct MemorySkillConfig {
pub enabled: bool,
pub mode: MemorySkillMode,
pub candidate_dir: String,
pub approved_dir: String,
pub approved_slug_prefix: String,
pub toolbox_delivery_registry: String,
pub allow_global_writes: bool,
pub require_claim_evidence: bool,
pub max_candidates_per_commit: usize,
pub max_skill_bytes: usize,
pub pre_push_blocks_pending: bool,
pub redact_secrets: bool,
pub signals: MemorySkillSignalsConfig,
pub review: MemorySkillReviewConfig,
pub scan: MemorySkillScanConfig,
}
impl Default for MemorySkillConfig {
fn default() -> Self {
Self {
enabled: false,
mode: MemorySkillMode::Stage,
candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
approved_dir: ".agents/skills".to_owned(),
approved_slug_prefix: "generated-".to_owned(),
toolbox_delivery_registry: String::new(),
allow_global_writes: false,
require_claim_evidence: true,
max_candidates_per_commit: 1,
max_skill_bytes: 12_000,
pre_push_blocks_pending: false,
redact_secrets: true,
signals: MemorySkillSignalsConfig::default(),
review: MemorySkillReviewConfig::default(),
scan: MemorySkillScanConfig::default(),
}
}
}
impl MemorySkillConfig {
pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct MemorySkillSignalsConfig {
pub min_occurrences: usize,
pub require_reusable_procedure: bool,
pub capture_passes_as_how_to: bool,
pub capture_rejections_as_remediation: bool,
pub capture_rejections_as_antipattern: bool,
pub rejection_precedence: Vec<MemorySkillCandidateKind>,
}
impl Default for MemorySkillSignalsConfig {
fn default() -> Self {
Self {
min_occurrences: 2,
require_reusable_procedure: true,
capture_passes_as_how_to: true,
capture_rejections_as_remediation: true,
capture_rejections_as_antipattern: true,
rejection_precedence: vec![
MemorySkillCandidateKind::AntiPatternSkill,
MemorySkillCandidateKind::RemediationSkill,
],
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct MemorySkillReviewConfig {
pub require_adversarial_review: bool,
pub require_reviewed_ledger_entry: bool,
pub require_pass_for_how_to: bool,
pub require_structured_findings_checked: bool,
pub reject_without_ledger_entry: bool,
}
impl Default for MemorySkillReviewConfig {
fn default() -> Self {
Self {
require_adversarial_review: true,
require_reviewed_ledger_entry: true,
require_pass_for_how_to: true,
require_structured_findings_checked: true,
reject_without_ledger_entry: true,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default)]
pub struct MemorySkillScanConfig {
pub secret_detection: SecretDetectionMode,
pub secret_detector: String,
pub allow_secret_detector_command: bool,
pub secret_detector_timeout_seconds: u64,
pub entropy_threshold: f64,
pub blocked_patterns: Vec<String>,
pub blocked_patterns_case_insensitive: bool,
}
impl Default for MemorySkillScanConfig {
fn default() -> Self {
Self {
secret_detection: SecretDetectionMode::Required,
secret_detector: String::new(),
allow_secret_detector_command: false,
secret_detector_timeout_seconds: 10,
entropy_threshold: 4.5,
blocked_patterns: Vec::new(),
blocked_patterns_case_insensitive: true,
}
}
}
impl MemorySkillScanConfig {
pub fn effective_blocked_patterns(&self) -> Vec<String> {
let mut patterns = Vec::new();
for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
push_unique_pattern(
&mut patterns,
pattern,
self.blocked_patterns_case_insensitive,
);
}
for pattern in &self.blocked_patterns {
push_unique_pattern(
&mut patterns,
pattern,
self.blocked_patterns_case_insensitive,
);
}
patterns
}
}
fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
let pattern = pattern.trim();
if pattern.is_empty()
|| patterns.iter().any(|existing| {
if case_insensitive {
existing.eq_ignore_ascii_case(pattern)
} else {
existing == pattern
}
})
{
return;
}
patterns.push(pattern.to_owned());
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SecretDetectionMode {
#[default]
Required,
BestEffort,
Off,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to parse config {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
ArbiterNotDistinct { writer: String },
#[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
InvalidMemorySkillEntropyThreshold { value: f64 },
#[error("memory_skill.max_skill_bytes must be greater than 0")]
InvalidMemorySkillMaxSkillBytes,
#[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
InvalidMemorySkillSecretDetectorTimeout,
}
fn default_ledger_dir() -> String {
DEFAULT_STATE_DIR.to_owned()
}
fn default_writer() -> String {
"codex".to_owned()
}
fn default_pairs() -> BTreeMap<String, AdversarialPair> {
let mut pairs = BTreeMap::new();
pairs.insert(
"codex".to_owned(),
AdversarialPair {
reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
arbiter: Some(HarnessSelection::new(
"pi",
"openai-codex/gpt-5.5",
Effort::highest(),
)),
},
);
pairs.insert(
"claude".to_owned(),
AdversarialPair {
reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
arbiter: Some(HarnessSelection::new(
"gemini",
"gemini-2.5-pro",
Effort::highest(),
)),
},
);
pairs.insert(
"pi".to_owned(),
AdversarialPair {
reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
arbiter: Some(HarnessSelection::new(
"claude",
"claude-opus-4-8",
Effort::highest(),
)),
},
);
pairs
}
pub(crate) fn normalized_model(model: &str) -> String {
let model = model.trim();
let short = model
.rsplit_once('/')
.and_then(|(_, suffix)| {
let s = suffix.trim();
if s.is_empty() { None } else { Some(s) }
})
.unwrap_or(model);
short.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{Effort, TruthMirrorConfig};
#[test]
fn default_config_has_three_opposed_pairs() {
let config = TruthMirrorConfig::default();
assert_eq!(config.pairs.len(), 3);
assert_eq!(config.ledger_dir, ".truth");
assert!(!config.skills.enabled);
assert!(!config.memory_skill.enabled);
assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
let codex = config.pair_for("codex").unwrap();
assert_eq!(codex.reviewer.harness, "claude");
assert_eq!(codex.reviewer.model, "claude-opus-4-8");
assert_eq!(codex.reviewer.effort, Effort::Xhigh);
}
#[test]
fn effort_serializes_lowercase() {
assert_eq!(Effort::Xhigh.as_str(), "xhigh");
assert_eq!(Effort::highest(), Effort::Xhigh);
}
#[test]
fn pairs_config_parses_and_resolves_by_writer() {
let contents = r#"
default_writer = "claude"
[pairs.claude]
reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
arbiter = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
[pairs.codex]
reviewer = { harness = "claude", model = "claude-opus-4-8" }
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let claude_pair = config.pair_for("claude").unwrap();
assert_eq!(claude_pair.reviewer.harness, "codex");
assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
let codex_pair = config.pair_for("codex").unwrap();
assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
}
#[test]
fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
let contents = r#"
[pairs.claude]
reviewer = { harness = "codex", model = "gpt-5.5" }
arbiter = { harness = "pi", model = "openai-codex/gpt-5.5" }
"#;
let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("arbiter"),
"expected arbiter error, got: {err}"
);
}
#[test]
fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
let config = TruthMirrorConfig::default();
let pair = config.pair_for("claude").expect("claude pair must exist");
let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
assert_eq!(
arbiter.harness, "gemini",
"claude arbiter harness must be gemini"
);
assert_ne!(
super::normalized_model(&arbiter.model),
super::normalized_model(&pair.reviewer.model),
"claude arbiter must be distinct from reviewer after normalization"
);
}
#[test]
fn normalized_strips_provider_prefix_for_comparison() {
assert_eq!(
super::normalized_model("openai-codex/gpt-5.5"),
super::normalized_model("gpt-5.5")
);
assert_ne!(super::normalized_model("/"), super::normalized_model(""));
}
#[test]
fn legacy_review_block_overrides_default_pair() {
let contents = r#"
ledger_dir = ".truth-mirror"
[review.watched]
harness = "codex"
model = "gpt-5.5"
[review.reviewer]
harness = "gemini"
model = "gemini-3-pro"
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let pair = config.pair_for("codex").unwrap();
assert_eq!(pair.reviewer.harness, "gemini");
assert_eq!(pair.reviewer.model, "gemini-3-pro");
assert!(config.pair_for("claude").is_some());
assert!(config.pair_for("pi").is_some());
}
#[test]
fn explicit_pairs_win_over_legacy_review() {
let contents = r#"
[pairs.codex]
reviewer = { harness = "claude", model = "explicit-model" }
[review.watched]
harness = "codex"
model = "gpt-5.5"
[review.reviewer]
harness = "gemini"
model = "legacy-model"
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let pair = config.pair_for("codex").unwrap();
assert_eq!(pair.reviewer.harness, "claude");
assert_eq!(pair.reviewer.model, "explicit-model");
}
#[test]
fn explicit_pairs_win_case_insensitively_over_legacy() {
let contents = r#"
[pairs.CODEX]
reviewer = { harness = "claude", model = "explicit-model" }
[review.watched]
harness = "codex"
model = "gpt-5.5"
[review.reviewer]
harness = "gemini"
model = "legacy-model"
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let pair = config.pair_for("codex").unwrap();
assert_eq!(pair.reviewer.model, "explicit-model");
assert_eq!(config.pairs.len(), 1);
}
#[test]
fn arbiter_equal_to_reviewer_model_is_rejected() {
let contents = r#"
[pairs.codex]
reviewer = { harness = "claude", model = "same-model" }
arbiter = { harness = "pi", model = "same-model" }
"#;
let error =
TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
assert!(matches!(
error,
super::ConfigError::ArbiterNotDistinct { .. }
));
}
#[test]
fn missing_config_loads_default() {
let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
assert_eq!(config.pairs.len(), 3);
assert!(config.ground_truth.enabled);
assert_eq!(config.history.window_user, 3);
assert!(!config.enforcement.is_enabled());
}
#[test]
fn gates_config_parses_and_builds_policy() {
let contents = r#"
[pairs.codex]
reviewer = { harness = "claude", model = "claude-opus-4-8" }
[gates]
fake_markers = ["pretend-pass"]
evidence_patterns = ["jira:"]
marker_ignore_paths = [".md", "vendor/"]
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let default_marker = ["mock", "as", "real"].join("-");
let policy = config.gates.to_policy();
assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
assert!(policy.fake_markers.contains(&default_marker));
assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
}
#[test]
fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
let skills_enabled = super::SkillsConfig { enabled: true };
let skills_disabled = super::SkillsConfig { enabled: false };
let mut memory = super::MemorySkillConfig {
enabled: true,
mode: super::MemorySkillMode::Stage,
..super::MemorySkillConfig::default()
};
assert!(memory.effective_enabled(&skills_enabled));
assert!(!memory.effective_enabled(&skills_disabled));
memory.enabled = false;
assert!(!memory.effective_enabled(&skills_enabled));
memory.enabled = true;
memory.mode = super::MemorySkillMode::Off;
assert!(!memory.effective_enabled(&skills_enabled));
}
#[test]
fn memory_skill_rejection_precedence_is_legacy_compatible() {
let contents = r#"
[memory_skill.signals]
rejection_precedence = ["how_to_skill"]
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
assert_eq!(
config.memory_skill.signals.rejection_precedence,
vec![super::MemorySkillCandidateKind::HowToSkill]
);
}
#[test]
fn memory_skill_entropy_threshold_must_be_finite() {
let contents = r#"
[memory_skill.scan]
entropy_threshold = inf
"#;
let error =
TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
assert!(matches!(
error,
super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
));
}
#[test]
fn memory_skill_entropy_threshold_must_be_positive() {
let contents = r#"
[memory_skill.scan]
entropy_threshold = 0.0
"#;
let error =
TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
assert!(matches!(
error,
super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
));
}
#[test]
fn memory_skill_max_skill_bytes_must_be_positive() {
let contents = r#"
[memory_skill]
max_skill_bytes = 0
"#;
let error =
TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
assert!(matches!(
error,
super::ConfigError::InvalidMemorySkillMaxSkillBytes
));
}
#[test]
fn memory_skill_secret_detector_timeout_must_be_positive() {
let contents = r#"
[memory_skill.scan]
secret_detector_timeout_seconds = 0
"#;
let error =
TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
assert!(matches!(
error,
super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
));
}
#[test]
fn empty_gate_lists_fall_back_to_defaults() {
let policy = super::GatesConfig {
fake_markers: Vec::new(),
evidence_patterns: Vec::new(),
marker_ignore_paths: Vec::new(),
}
.to_policy();
assert!(!policy.fake_markers.is_empty());
assert!(!policy.evidence_patterns.is_empty());
assert!(!policy.marker_ignore_paths.is_empty());
}
#[test]
fn memory_skill_blocked_patterns_trim_and_dedupe() {
let config = super::MemorySkillScanConfig {
blocked_patterns: vec![
" API_KEY ".to_owned(),
" custom ".to_owned(),
" ".to_owned(),
"custom".to_owned(),
],
..super::MemorySkillScanConfig::default()
};
let patterns = config.effective_blocked_patterns();
assert_eq!(
patterns
.iter()
.filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
.count(),
1
);
assert_eq!(
patterns
.iter()
.filter(|pattern| *pattern == "custom")
.count(),
1
);
assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
}
#[test]
fn pair_keys_are_lowercased() {
let contents = r#"
[pairs.CODEX]
reviewer = { harness = "claude", model = "claude-opus-4-8" }
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
assert!(config.pair_for("codex").is_some());
assert!(config.pair_for("CoDeX").is_some());
}
}