use std::{collections::BTreeMap, fs, path::Path, process::Command};
use anyhow::{Context, Result};
use crate::{
config::TruthMirrorConfig,
ledger::{LedgerEntry, LedgerStats, LedgerStore},
reviewer::{QueuedReview, ReviewQueue, ReviewRunStatusCounts, ReviewRunStore},
watcher::{self, WatcherLock},
};
use super::model::{
ConfigRow, DashboardVm, DebtClassGroup, LedgerRow, QueueRow, config_rows, dashboard_vm,
debt_groups, ledger_rows, queue_rows,
};
#[derive(Clone, Debug)]
pub struct Snapshot {
pub dashboard: DashboardVm,
pub queue: Vec<QueueRow>,
pub ledger: Vec<LedgerRow>,
pub ledger_entries: Vec<LedgerEntry>,
pub debt: Vec<DebtClassGroup>,
pub config_rows: Vec<ConfigRow>,
pub config_raw: String,
pub stats: LedgerStats,
pub warnings: Vec<String>,
}
pub fn load_snapshot(
state_dir: &Path,
config_path: &Path,
version: &str,
subject_cache: &mut BTreeMap<String, String>,
) -> Result<Snapshot> {
let mut warnings = Vec::new();
let now = crate::time::unix_now();
let queue_store = ReviewQueue::new(state_dir);
let queue_raw = match queue_store.pending() {
Ok(items) => items,
Err(error) => {
warnings.push(format!("queue: {error}"));
Vec::new()
}
};
let subjects = load_subjects(&queue_raw, subject_cache);
let queue = queue_rows(&queue_raw, &subjects, now);
let oldest = queue_raw.iter().map(|item| item.enqueued_at_unix).min();
let oldest_age = oldest.map(|ts| now.saturating_sub(ts));
let runs = match ReviewRunStore::new(state_dir).status_counts() {
Ok(counts) => counts,
Err(error) => {
warnings.push(format!("runs: {error}"));
ReviewRunStatusCounts::default()
}
};
let store = LedgerStore::new(state_dir);
let (ledger_entries, stats) = match (store.latest_entries(), store.stats()) {
(Ok(entries), Ok(stats)) => (entries, stats),
(Err(error), _) => {
warnings.push(format!("ledger: {error}"));
(Vec::new(), LedgerStats::default())
}
(_, Err(error)) => {
warnings.push(format!("ledger stats: {error}"));
(
store.latest_entries().unwrap_or_default(),
LedgerStats::default(),
)
}
};
let ledger = ledger_rows(&ledger_entries, now);
let flagged = store.flagged_entries().unwrap_or_else(|error| {
warnings.push(format!("debt: {error}"));
Vec::new()
});
let debt = debt_groups(&flagged, now);
let (watcher_alive, watcher_pid) = watcher_status(state_dir);
let (config, config_raw, present_paths) = load_config(config_path, &mut warnings);
let config_rows = config_rows(&config, &present_paths);
let config_path_str = config_path.display().to_string();
let state_dir_str = state_dir.display().to_string();
let dashboard = dashboard_vm(super::model::DashboardInputs {
version,
config_path: &config_path_str,
state_dir: &state_dir_str,
queue_pending: queue_raw.len(),
oldest_age_secs: oldest_age,
runs: &runs,
stats: &stats,
watcher_alive,
watcher_pid,
});
let _ = (config, present_paths);
Ok(Snapshot {
dashboard,
queue,
ledger,
ledger_entries,
debt,
config_rows,
config_raw,
stats,
warnings,
})
}
fn watcher_status(state_dir: &Path) -> (bool, Option<u32>) {
let alive = watcher::watcher_is_alive(state_dir);
let pid = read_watcher_lock(state_dir).map(|lock| lock.identity.pid);
(alive, if alive { pid } else { None })
}
fn read_watcher_lock(state_dir: &Path) -> Option<WatcherLock> {
let path = state_dir.join(watcher::WATCHER_LOCK_FILE);
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
fn load_subjects(
items: &[QueuedReview],
subject_cache: &mut BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut subjects = BTreeMap::new();
for item in items {
if subjects.contains_key(&item.commit_sha) {
continue;
}
if let Some(subject) = subject_cache.get(&item.commit_sha) {
subjects.insert(item.commit_sha.clone(), subject.clone());
continue;
}
if let Some(subject) = git_subject(&item.commit_sha) {
subject_cache.insert(item.commit_sha.clone(), subject.clone());
subjects.insert(item.commit_sha.clone(), subject);
}
}
subjects
}
fn git_subject(sha: &str) -> Option<String> {
let output = Command::new("git")
.args(["log", "-1", "--format=%s", sha])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let subject = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if subject.is_empty() {
None
} else {
Some(subject)
}
}
fn load_config(
path: &Path,
warnings: &mut Vec<String>,
) -> (TruthMirrorConfig, String, BTreeMap<String, bool>) {
let raw = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
warnings.push(format!("config read: {error}"));
String::new()
}
};
let present = present_paths_from_toml(&raw);
let config = if raw.trim().is_empty() {
TruthMirrorConfig::default()
} else {
match TruthMirrorConfig::from_toml_str(path, &raw) {
Ok(config) => config,
Err(error) => {
warnings.push(format!("config parse: {error}"));
TruthMirrorConfig::default()
}
}
};
(config, raw, present)
}
pub fn present_paths_from_toml(raw: &str) -> BTreeMap<String, bool> {
let mut present = BTreeMap::new();
if raw.trim().is_empty() {
return present;
}
let Ok(doc) = raw.parse::<toml_edit::DocumentMut>() else {
return present;
};
mark_key(&mut present, &doc, "ledger_dir");
mark_key(&mut present, &doc, "allow_same_model");
mark_key(&mut present, &doc, "default_writer");
if let Some(table) = doc.get("strict").and_then(|item| item.as_table()) {
for key in ["stop_after_lies", "stop_after_fuckups", "max_passes"] {
if table.contains_key(key) {
present.insert(format!("strict.{key}"), true);
}
}
}
if let Some(table) = doc.get("gates").and_then(|item| item.as_table()) {
for key in ["fake_markers", "evidence_patterns", "marker_ignore_paths"] {
if table.contains_key(key) {
present.insert(format!("gates.{key}"), true);
}
}
}
if let Some(table) = doc.get("ground_truth").and_then(|item| item.as_table()) {
for key in [
"enabled",
"max_bytes",
"include_openspec_specs",
"file_names",
] {
if table.contains_key(key) {
present.insert(format!("ground_truth.{key}"), true);
}
}
}
if let Some(table) = doc.get("history").and_then(|item| item.as_table()) {
for key in [
"window_user",
"window_agent",
"max_bytes",
"transcript_path",
] {
if table.contains_key(key) {
present.insert(format!("history.{key}"), true);
}
}
}
if let Some(table) = doc.get("enforcement").and_then(|item| item.as_table()) {
for key in ["block_tools_after_unresolved", "block_tools_after_secs"] {
if table.contains_key(key) {
present.insert(format!("enforcement.{key}"), true);
}
}
}
if let Some(table) = doc.get("skills").and_then(|item| item.as_table())
&& table.contains_key("enabled")
{
present.insert("skills.enabled".into(), true);
}
if let Some(pairs) = doc.get("pairs").and_then(|item| item.as_table()) {
for (writer, value) in pairs.iter() {
let Some(pair) = value.as_table() else {
continue;
};
if let Some(reviewer) = pair.get("reviewer").and_then(|item| item.as_inline_table()) {
for key in ["harness", "model", "effort"] {
if reviewer.contains_key(key) {
present.insert(format!("pairs.{writer}.reviewer.{key}"), true);
}
}
}
if let Some(reviewer) = pair.get("reviewer").and_then(|item| item.as_table()) {
for key in ["harness", "model", "effort"] {
if reviewer.contains_key(key) {
present.insert(format!("pairs.{writer}.reviewer.{key}"), true);
}
}
}
}
}
present
}
fn mark_key(present: &mut BTreeMap<String, bool>, doc: &toml_edit::DocumentMut, key: &str) {
if doc.contains_key(key) {
present.insert(key.to_owned(), true);
}
}
pub fn apply_config_edit(raw: &str, path: &str, value: &str) -> Result<String> {
let mut doc = if raw.trim().is_empty() {
toml_edit::DocumentMut::new()
} else {
raw.parse::<toml_edit::DocumentMut>()
.context("parse config for edit")?
};
set_dotted(&mut doc, path, value)?;
Ok(doc.to_string())
}
fn set_dotted(doc: &mut toml_edit::DocumentMut, path: &str, value: &str) -> Result<()> {
let parts: Vec<&str> = path.split('.').collect();
match parts.as_slice() {
["ledger_dir"] => {
doc["ledger_dir"] = toml_edit::value(value);
}
["allow_same_model"] => {
doc["allow_same_model"] = toml_edit::value(parse_bool_value(value)?);
}
["default_writer"] => {
doc["default_writer"] = toml_edit::value(value);
}
["strict", key] => {
ensure_table(doc, "strict");
doc["strict"][key] = toml_edit::value(parse_i64(value)?);
}
[
"gates",
key @ ("fake_markers" | "evidence_patterns" | "marker_ignore_paths"),
] => {
ensure_table(doc, "gates");
doc["gates"][key] = array_from_csv(value);
}
["ground_truth", "enabled" | "include_openspec_specs"] => {
ensure_table(doc, "ground_truth");
doc["ground_truth"][parts[1]] = toml_edit::value(parse_bool_value(value)?);
}
["ground_truth", "max_bytes"] => {
ensure_table(doc, "ground_truth");
doc["ground_truth"]["max_bytes"] = toml_edit::value(parse_i64(value)?);
}
[
"history",
key @ ("window_user" | "window_agent" | "max_bytes"),
] => {
ensure_table(doc, "history");
doc["history"][key] = toml_edit::value(parse_i64(value)?);
}
["enforcement", key] => {
ensure_table(doc, "enforcement");
doc["enforcement"][key] = toml_edit::value(parse_i64(value)?);
}
["skills", "enabled"] => {
ensure_table(doc, "skills");
doc["skills"]["enabled"] = toml_edit::value(parse_bool_value(value)?);
}
["pairs", writer, "reviewer", field] => {
ensure_table(doc, "pairs");
if doc["pairs"].get(writer).is_none() {
doc["pairs"][writer] = toml_edit::Item::Table(toml_edit::Table::new());
}
if doc["pairs"][writer].get("reviewer").is_none() {
doc["pairs"][writer]["reviewer"] = toml_edit::Item::Table(toml_edit::Table::new());
}
match *field {
"effort" | "harness" | "model" => {
doc["pairs"][writer]["reviewer"][field] = toml_edit::value(value);
}
_ => anyhow::bail!("unsupported pair field {field}"),
}
}
_ => anyhow::bail!("unsupported config path: {path}"),
}
Ok(())
}
fn ensure_table(doc: &mut toml_edit::DocumentMut, name: &str) {
if doc.get(name).and_then(|item| item.as_table()).is_none() {
doc[name] = toml_edit::Item::Table(toml_edit::Table::new());
}
}
fn parse_bool_value(raw: &str) -> Result<bool> {
match raw.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => anyhow::bail!("expected boolean"),
}
}
fn parse_i64(raw: &str) -> Result<i64> {
raw.parse::<i64>().context("expected integer")
}
fn array_from_csv(raw: &str) -> toml_edit::Item {
let mut arr = toml_edit::Array::new();
for part in raw.split(',') {
let trimmed = part.trim();
if !trimmed.is_empty() {
arr.push(trimmed);
}
}
toml_edit::value(arr)
}
pub fn line_diff(before: &str, after: &str) -> String {
let before_lines: Vec<&str> = before.lines().collect();
let after_lines: Vec<&str> = after.lines().collect();
let mut out = String::new();
let max = before_lines.len().max(after_lines.len());
let mut i = 0;
let mut j = 0;
while i < before_lines.len() || j < after_lines.len() {
if i < before_lines.len() && j < after_lines.len() && before_lines[i] == after_lines[j] {
out.push_str(" ");
out.push_str(before_lines[i]);
out.push('\n');
i += 1;
j += 1;
} else if j < after_lines.len()
&& (i >= before_lines.len()
|| !after_lines[j..].contains(&before_lines.get(i).copied().unwrap_or("")))
{
if i < before_lines.len()
&& j < after_lines.len()
&& !before_lines[i..].contains(&after_lines[j])
{
out.push_str("- ");
out.push_str(before_lines[i]);
out.push('\n');
out.push_str("+ ");
out.push_str(after_lines[j]);
out.push('\n');
i += 1;
j += 1;
} else if i < before_lines.len() && before_lines[i..].contains(&after_lines[j]) {
out.push_str("- ");
out.push_str(before_lines[i]);
out.push('\n');
i += 1;
} else {
out.push_str("+ ");
out.push_str(after_lines[j]);
out.push('\n');
j += 1;
}
} else if i < before_lines.len() {
out.push_str("- ");
out.push_str(before_lines[i]);
out.push('\n');
i += 1;
} else if j < after_lines.len() {
out.push_str("+ ");
out.push_str(after_lines[j]);
out.push('\n');
j += 1;
} else {
break;
}
if out.lines().count() > 200 {
out.push_str("… (diff truncated)\n");
break;
}
let _ = max;
}
if out.is_empty() {
out.push_str(" (no changes)\n");
}
out
}
pub fn write_config(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
TruthMirrorConfig::from_toml_str(path, contents).context("written config failed validation")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn present_paths_detects_keys() {
let raw = r#"
default_writer = "codex"
[strict]
max_passes = 2
"#;
let present = present_paths_from_toml(raw);
assert!(present.get("default_writer").copied().unwrap_or(false));
assert!(present.get("strict.max_passes").copied().unwrap_or(false));
assert!(!present.get("allow_same_model").copied().unwrap_or(false));
}
#[test]
fn apply_config_edit_preserves_comments() {
let raw = "# header comment\ndefault_writer = \"codex\"\n";
let next = apply_config_edit(raw, "default_writer", "claude").unwrap();
assert!(next.contains("# header comment"));
assert!(next.contains("default_writer"));
assert!(next.contains("claude"));
}
#[test]
fn apply_config_edit_bool_and_int() {
let raw = "";
let next = apply_config_edit(raw, "allow_same_model", "true").unwrap();
assert!(next.contains("true"));
let next = apply_config_edit(&next, "strict.max_passes", "4").unwrap();
assert!(next.contains("max_passes") && next.contains('4'));
}
#[test]
fn line_diff_marks_changes() {
let diff = line_diff("a = 1\n", "a = 2\n");
assert!(diff.contains("- "));
assert!(diff.contains("+ "));
}
#[test]
fn write_config_round_trips() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
write_config(&path, "default_writer = \"codex\"\n").unwrap();
let loaded = fs::read_to_string(&path).unwrap();
assert!(loaded.contains("codex"));
assert!(write_config(&path, "this is not = valid toml [[[").is_err());
}
fn queued(sha: &str) -> QueuedReview {
QueuedReview {
run_id: format!("run-{sha}"),
commit_sha: sha.to_owned(),
enqueued_at_unix: 1,
petition_for: None,
batch_id: None,
}
}
#[test]
fn load_subjects_reuses_the_cache_instead_of_reshelling_to_git() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let items = vec![queued(sha)];
let mut cache = BTreeMap::new();
cache.insert(
sha.to_owned(),
"sentinel-subject-no-real-git-log-would-ever-produce".to_owned(),
);
let subjects = load_subjects(&items, &mut cache);
assert_eq!(
subjects.get(sha).map(String::as_str),
Some("sentinel-subject-no-real-git-log-would-ever-produce"),
"a cached subject must be reused, not re-resolved via git"
);
assert_eq!(
cache.len(),
1,
"the cache must not grow when the entry was already present"
);
}
#[test]
fn load_subjects_populates_the_cache_for_a_real_commit() {
let sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.expect("git rev-parse HEAD");
String::from_utf8(out.stdout).unwrap().trim().to_owned()
};
let items = vec![queued(&sha)];
let mut cache = BTreeMap::new();
let subjects = load_subjects(&items, &mut cache);
assert!(
subjects.contains_key(&sha),
"a resolvable commit must appear in the returned subjects"
);
assert_eq!(
cache.get(&sha),
subjects.get(&sha),
"a freshly resolved subject must be recorded in the cache"
);
}
}