1use 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
18pub const TRUTH_MIRROR_ENABLED_ENV: &str = "TRUTH_MIRROR_ENABLED";
20
21static CONFIG_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
22
23pub const DEFAULT_CONFIG_ASSET: &str = include_str!("../assets/config.toml");
25
26pub 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#[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 pub fn highest() -> Self {
71 Effort::Xhigh
72 }
73
74 pub fn claude_value(self) -> &'static str {
78 match self {
79 Effort::Minimal => "low",
80 other => other.as_str(),
81 }
82 }
83}
84
85#[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#[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 #[serde(default = "default_writer")]
124 pub default_writer: String,
125 #[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 #[serde(default)]
145 pub reviewer: ReviewerPolicyConfig,
146 #[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}
172pub 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
183pub fn system_config_path() -> Result<PathBuf, ConfigError> {
185 Ok(system_config_dir()?.join("config.toml"))
186}
187
188pub(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
236pub 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}
245pub 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
256fn 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 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 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 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 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 fn normalize(&mut self) {
402 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 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 self.pairs.entry(writer).or_insert(pair);
432 } else {
433 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 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
546pub const DEFAULT_REVIEWER_TIMEOUT_SECS: u64 = 20 * 60;
551
552#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
554#[serde(default)]
555pub struct ReviewerPolicyConfig {
556 pub batch_petitions: bool,
558 pub max_petition_batch_size: usize,
559 pub timeout_secs: u64,
564 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 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#[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#[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#[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 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
674fn 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#[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#[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 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#[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 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 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
1032fn 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: 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 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
1085pub(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 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 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 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 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 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 assert_eq!(
1307 super::normalized_model("openai-codex/gpt-5.5"),
1308 super::normalized_model("gpt-5.5")
1309 );
1310 assert_ne!(super::normalized_model("/"), super::normalized_model(""));
1312 }
1313
1314 #[test]
1315 fn legacy_review_block_overrides_default_pair() {
1316 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 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 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 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 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 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 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}