1use std::{
2 fs, io,
3 path::{Path, PathBuf},
4};
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
10pub struct TruthMirrorConfig {
11 pub ledger_dir: String,
12 pub review: ReviewPair,
13 #[serde(default)]
14 pub strict: StrictConfig,
15}
16
17impl Default for TruthMirrorConfig {
18 fn default() -> Self {
19 Self {
20 ledger_dir: ".truth-mirror".to_owned(),
21 review: ReviewPair::default(),
22 strict: StrictConfig::default(),
23 }
24 }
25}
26
27#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(default)]
32pub struct StrictConfig {
33 pub stop_after_lies: u32,
34 pub stop_after_fuckups: u32,
35 pub max_passes: u32,
36}
37
38impl Default for StrictConfig {
39 fn default() -> Self {
40 Self {
41 stop_after_lies: 1,
42 stop_after_fuckups: 3,
43 max_passes: 3,
44 }
45 }
46}
47
48impl StrictConfig {
49 pub fn goal_policy(
50 &self,
51 lies_override: Option<u32>,
52 fuckups_override: Option<u32>,
53 ) -> crate::reviewer::StrictGoalPolicy {
54 crate::reviewer::StrictGoalPolicy {
55 stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
56 stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
57 }
58 }
59}
60
61impl TruthMirrorConfig {
62 pub fn load_for_cli(
63 explicit_path: Option<&Path>,
64 state_dir: &Path,
65 ) -> Result<Self, ConfigError> {
66 let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
67 Self::load_or_default(path)
68 }
69
70 pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
71 let path = path.into();
72 match fs::read_to_string(&path) {
73 Ok(contents) => Self::from_toml_str(&path, &contents),
74 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
75 Err(source) => Err(ConfigError::Read { path, source }),
76 }
77 }
78
79 pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
80 let config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
81 path: path.to_path_buf(),
82 source,
83 })?;
84 config.review.validate_model_opposition()?;
85 Ok(config)
86 }
87
88 pub fn default_path(state_dir: &Path) -> PathBuf {
89 state_dir.join("config.toml")
90 }
91}
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94pub struct ReviewPair {
95 pub watched: ModelSelection,
96 pub reviewer: ModelSelection,
97 pub allow_same_model: bool,
98}
99
100impl ReviewPair {
101 pub fn validate_model_opposition(&self) -> Result<(), ConfigError> {
102 if !self.allow_same_model
103 && normalized(&self.watched.model) == normalized(&self.reviewer.model)
104 {
105 return Err(ConfigError::SameModelWithoutWaiver {
106 watched_model: self.watched.model.clone(),
107 reviewer_model: self.reviewer.model.clone(),
108 });
109 }
110
111 Ok(())
112 }
113}
114
115impl Default for ReviewPair {
116 fn default() -> Self {
117 Self {
118 watched: ModelSelection {
119 harness: "codex".to_owned(),
120 model: "gpt-5.5".to_owned(),
121 },
122 reviewer: ModelSelection {
123 harness: "claude".to_owned(),
124 model: "claude-opus-4-1".to_owned(),
125 },
126 allow_same_model: false,
127 }
128 }
129}
130
131#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
132pub struct ModelSelection {
133 pub harness: String,
134 pub model: String,
135}
136
137#[derive(Debug, Error)]
138pub enum ConfigError {
139 #[error("failed to read config {path}: {source}")]
140 Read {
141 path: PathBuf,
142 #[source]
143 source: io::Error,
144 },
145 #[error("failed to parse config {path}: {source}")]
146 Parse {
147 path: PathBuf,
148 #[source]
149 source: toml::de::Error,
150 },
151 #[error(
152 "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
153 )]
154 SameModelWithoutWaiver {
155 watched_model: String,
156 reviewer_model: String,
157 },
158}
159
160fn normalized(model: &str) -> String {
161 model.trim().to_ascii_lowercase()
162}
163
164#[cfg(test)]
165mod tests {
166 use std::path::Path;
167
168 use super::{ConfigError, ModelSelection, ReviewPair, TruthMirrorConfig};
169
170 #[test]
171 fn default_config_uses_different_models() {
172 let pair = ReviewPair::default();
173
174 pair.validate_model_opposition().unwrap();
175 }
176
177 #[test]
178 fn same_model_is_rejected_without_explicit_waiver() {
179 let pair = ReviewPair {
180 watched: ModelSelection {
181 harness: "codex".to_owned(),
182 model: "gpt-5.5".to_owned(),
183 },
184 reviewer: ModelSelection {
185 harness: "codex".to_owned(),
186 model: " GPT-5.5 ".to_owned(),
187 },
188 allow_same_model: false,
189 };
190
191 assert!(pair.validate_model_opposition().is_err());
192 }
193
194 #[test]
195 fn same_model_can_be_allowed_explicitly() {
196 let pair = ReviewPair {
197 watched: ModelSelection {
198 harness: "codex".to_owned(),
199 model: "gpt-5.5".to_owned(),
200 },
201 reviewer: ModelSelection {
202 harness: "codex".to_owned(),
203 model: "gpt-5.5".to_owned(),
204 },
205 allow_same_model: true,
206 };
207
208 pair.validate_model_opposition().unwrap();
209 }
210
211 #[test]
212 fn missing_config_loads_default() {
213 let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
214
215 assert_eq!(config, TruthMirrorConfig::default());
216 }
217
218 #[test]
219 fn config_load_validates_model_opposition() {
220 let contents = r#"
221ledger_dir = ".truth-mirror"
222
223[review]
224allow_same_model = false
225
226[review.watched]
227harness = "codex"
228model = "gpt-5.5"
229
230[review.reviewer]
231harness = "claude"
232model = "gpt-5.5"
233"#;
234
235 let error =
236 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
237
238 assert!(matches!(error, ConfigError::SameModelWithoutWaiver { .. }));
239 }
240}