use std::{
fs, io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TruthMirrorConfig {
pub ledger_dir: String,
pub review: ReviewPair,
#[serde(default)]
pub strict: StrictConfig,
}
impl Default for TruthMirrorConfig {
fn default() -> Self {
Self {
ledger_dir: ".truth-mirror".to_owned(),
review: ReviewPair::default(),
strict: StrictConfig::default(),
}
}
}
#[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),
}
}
}
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 config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
config.review.validate_model_opposition()?;
Ok(config)
}
pub fn default_path(state_dir: &Path) -> PathBuf {
state_dir.join("config.toml")
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewPair {
pub watched: ModelSelection,
pub reviewer: ModelSelection,
pub allow_same_model: bool,
}
impl ReviewPair {
pub fn validate_model_opposition(&self) -> Result<(), ConfigError> {
if !self.allow_same_model
&& normalized(&self.watched.model) == normalized(&self.reviewer.model)
{
return Err(ConfigError::SameModelWithoutWaiver {
watched_model: self.watched.model.clone(),
reviewer_model: self.reviewer.model.clone(),
});
}
Ok(())
}
}
impl Default for ReviewPair {
fn default() -> Self {
Self {
watched: ModelSelection {
harness: "codex".to_owned(),
model: "gpt-5.5".to_owned(),
},
reviewer: ModelSelection {
harness: "claude".to_owned(),
model: "claude-opus-4-1".to_owned(),
},
allow_same_model: false,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ModelSelection {
pub harness: String,
pub model: String,
}
#[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(
"same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
)]
SameModelWithoutWaiver {
watched_model: String,
reviewer_model: String,
},
}
fn normalized(model: &str) -> String {
model.trim().to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{ConfigError, ModelSelection, ReviewPair, TruthMirrorConfig};
#[test]
fn default_config_uses_different_models() {
let pair = ReviewPair::default();
pair.validate_model_opposition().unwrap();
}
#[test]
fn same_model_is_rejected_without_explicit_waiver() {
let pair = ReviewPair {
watched: ModelSelection {
harness: "codex".to_owned(),
model: "gpt-5.5".to_owned(),
},
reviewer: ModelSelection {
harness: "codex".to_owned(),
model: " GPT-5.5 ".to_owned(),
},
allow_same_model: false,
};
assert!(pair.validate_model_opposition().is_err());
}
#[test]
fn same_model_can_be_allowed_explicitly() {
let pair = ReviewPair {
watched: ModelSelection {
harness: "codex".to_owned(),
model: "gpt-5.5".to_owned(),
},
reviewer: ModelSelection {
harness: "codex".to_owned(),
model: "gpt-5.5".to_owned(),
},
allow_same_model: true,
};
pair.validate_model_opposition().unwrap();
}
#[test]
fn missing_config_loads_default() {
let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
assert_eq!(config, TruthMirrorConfig::default());
}
#[test]
fn config_load_validates_model_opposition() {
let contents = r#"
ledger_dir = ".truth-mirror"
[review]
allow_same_model = false
[review.watched]
harness = "codex"
model = "gpt-5.5"
[review.reviewer]
harness = "claude"
model = "gpt-5.5"
"#;
let error =
TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
assert!(matches!(error, ConfigError::SameModelWithoutWaiver { .. }));
}
}