mod common;
use std::{
fs,
path::Path,
process::Command as StdCommand,
time::{Duration, Instant},
};
use common::{git, git_stdout, make_executable, truth_mirror};
use predicates::prelude::*;
fn mock_reviewer_path(bin_dir: &Path) -> String {
let fake_codex = bin_dir.join("codex");
fs::write(
&fake_codex,
r#"#!/usr/bin/env python3
import json
print(json.dumps({
"verdict": "REJECT",
"summary": "The claim is not substantiated.",
"findings": [{
"severity": "high",
"title": "claim not substantiated",
"body": "The cited evidence does not prove the claim.",
"file": "file.txt",
"line_start": 1,
"line_end": 1,
"confidence": 95,
"recommendation": "Add evidence that directly proves the claim."
}],
"next_steps": ["Re-run the verification command."],
"memory_skill": {
"kind": "anti_pattern_skill",
"learning_source": "claim not substantiated",
"reasoning": "The rejection identifies a reusable unsupported-claim pattern."
}
}))
"#,
)
.unwrap();
make_executable(&fake_codex);
format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
)
}
fn repo_with_one_queued_commit(repo: &Path, state_dir_name: &str) -> String {
git(repo, &["init"]);
git(repo, &["config", "user.email", "truth@example.invalid"]);
git(repo, &["config", "user.name", "Truth Mirror Test"]);
fs::write(repo.join("file.txt"), "hello\n").unwrap();
git(repo, &["add", "file.txt"]);
git(
repo,
&[
"commit",
"-m",
"feat: add file",
"-m",
"CLAIM: add file | verified: cargo test | evidence: tests:watcher-e2e",
],
);
let sha = git_stdout(repo, &["rev-parse", "HEAD"]);
let state = repo.join(state_dir_name);
fs::create_dir_all(&state).unwrap();
fs::write(
state.join("config.toml"),
r#"default_writer = "claude"
[pairs.claude]
reviewer = { harness = "codex", model = "gpt-5.5" }
arbiter = { harness = "gemini", model = "gemini-2.5-pro" }
"#,
)
.unwrap();
fs::write(
state.join("review-queue.jsonl"),
format!("{{\"commit_sha\":\"{sha}\",\"enqueued_at_unix\":100}}\n"),
)
.unwrap();
sha
}
fn queue_path(state: &Path) -> std::path::PathBuf {
state.join("review-queue.jsonl")
}
fn queue_is_empty(state: &Path) -> bool {
fs::read_to_string(queue_path(state))
.unwrap_or_default()
.trim()
.is_empty()
}
fn ledger_contains_reject(state: &Path) -> bool {
fs::read_to_string(state.join("ledger.jsonl"))
.unwrap_or_default()
.contains("\"verdict\":\"REJECT\"")
}
fn wait_until(mut predicate: impl FnMut() -> bool, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if predicate() {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
predicate()
}
#[test]
fn watch_until_empty_drains_queued_commit_and_exits_after_grace() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let bin = temp.path().join("bin");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&bin).unwrap();
let sha = repo_with_one_queued_commit(&repo, ".truth-mirror");
let state = repo.join(".truth-mirror");
let path = mock_reviewer_path(&bin);
let started = Instant::now();
truth_mirror(&repo)
.env("PATH", path)
.args([
"--state-dir",
".truth-mirror",
"watch",
"--until-empty",
"--grace",
"1",
"--poll-secs",
"1",
"--watched-agent",
"claude",
"--watched-model",
"model-a",
"--reviewer-harness",
"codex",
"--reviewer-model",
"model-b",
])
.assert()
.success()
.stdout(predicate::str::contains("reviewed 1 commit"));
assert!(
started.elapsed() >= Duration::from_millis(900),
"watcher should linger through the configured grace window"
);
assert!(ledger_contains_reject(&state), "verdict should be recorded");
assert!(
fs::read_to_string(state.join("ledger.jsonl"))
.unwrap()
.contains(&sha)
);
assert!(queue_is_empty(&state), "queue should be drained on exit");
assert!(
!state.join("watcher.lock").exists(),
"lock should be released after until-empty exit"
);
}
#[test]
fn watch_until_empty_picks_up_late_enqueue_during_grace_window() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let bin = temp.path().join("bin");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&bin).unwrap();
let _first = repo_with_one_queued_commit(&repo, ".truth-mirror");
let state = repo.join(".truth-mirror");
let path = mock_reviewer_path(&bin);
fs::write(repo.join("file.txt"), "second\n").unwrap();
git(&repo, &["add", "file.txt"]);
git(
&repo,
&[
"commit",
"-m",
"feat: second change",
"-m",
"CLAIM: second change | verified: cargo test | evidence: tests:watcher-late",
],
);
let second_sha = git_stdout(&repo, &["rev-parse", "HEAD"]);
let exe = assert_cmd::cargo::cargo_bin("truth-mirror");
let mut child = StdCommand::new(exe)
.current_dir(&repo)
.env("PATH", path)
.args([
"--state-dir",
".truth-mirror",
"watch",
"--until-empty",
"--grace",
"2",
"--poll-secs",
"1",
"--watched-agent",
"claude",
"--watched-model",
"model-a",
"--reviewer-harness",
"codex",
"--reviewer-model",
"model-b",
])
.spawn()
.unwrap();
assert!(
wait_until(|| queue_is_empty(&state), Duration::from_secs(10)),
"first commit should drain and empty the queue"
);
fs::write(
queue_path(&state),
format!("{{\"commit_sha\":\"{second_sha}\",\"enqueued_at_unix\":200}}\n"),
)
.unwrap();
let status = wait_child(&mut child, Duration::from_secs(30));
assert!(
status.success(),
"watcher should exit 0 after draining late arrival"
);
let ledger = fs::read_to_string(state.join("ledger.jsonl")).unwrap();
assert!(
ledger.contains(&second_sha),
"late-enqueued commit should have been reviewed; ledger:\n{ledger}"
);
assert!(queue_is_empty(&state), "queue should be empty on exit");
}
#[test]
fn ensure_watcher_spawns_when_none_then_is_a_noop_when_alive() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let bin = temp.path().join("bin");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&bin).unwrap();
let _sha = repo_with_one_queued_commit(&repo, ".truth-mirror");
let state = repo.join(".truth-mirror");
let path = mock_reviewer_path(&bin);
truth_mirror(&repo)
.env("PATH", &path)
.args(["--state-dir", ".truth-mirror", "ensure-watcher"])
.assert()
.success()
.stdout(predicate::str::contains("watcher started"));
assert!(
wait_until(
|| state.join("watcher.lock").exists(),
Duration::from_secs(5)
),
"a watcher lock should appear"
);
assert!(
wait_until(|| queue_is_empty(&state), Duration::from_secs(20)),
"detached watcher should drain the queued commit"
);
assert!(ledger_contains_reject(&state));
truth_mirror(&repo)
.env("PATH", &path)
.args(["--state-dir", ".truth-mirror", "ensure-watcher"])
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn ensure_watcher_recovers_from_a_stale_lock() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let bin = temp.path().join("bin");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&bin).unwrap();
let _sha = repo_with_one_queued_commit(&repo, ".truth-mirror");
let state = repo.join(".truth-mirror");
let path = mock_reviewer_path(&bin);
fs::write(
state.join("watcher.lock"),
r#"{"identity":{"pid":4000000000,"start_token":"definitely-dead"},"created_at_unix":1}"#,
)
.unwrap();
truth_mirror(&repo)
.env("PATH", &path)
.args(["--state-dir", ".truth-mirror", "ensure-watcher"])
.assert()
.success()
.stdout(predicate::str::contains("watcher started"));
assert!(
wait_until(|| queue_is_empty(&state), Duration::from_secs(20)),
"recovered watcher should drain the queued commit"
);
assert!(ledger_contains_reject(&state));
}
#[test]
fn two_racing_ensure_watchers_yield_exactly_one_watcher() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let bin = temp.path().join("bin");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&bin).unwrap();
let _sha = repo_with_one_queued_commit(&repo, ".truth-mirror");
let state = repo.join(".truth-mirror");
let path = mock_reviewer_path(&bin);
let exe = assert_cmd::cargo::cargo_bin("truth-mirror");
let spawn_one = || {
let exe = exe.clone();
let repo = repo.clone();
let path = path.clone();
std::thread::spawn(move || {
let output = StdCommand::new(exe)
.current_dir(&repo)
.env("PATH", path)
.args(["--state-dir", ".truth-mirror", "ensure-watcher"])
.output()
.unwrap();
assert!(output.status.success());
String::from_utf8_lossy(&output.stdout).contains("watcher started")
})
};
let a = spawn_one();
let b = spawn_one();
let a_started = a.join().unwrap();
let b_started = b.join().unwrap();
assert!(
a_started ^ b_started,
"exactly one ensure-watcher should start a watcher (a={a_started}, b={b_started})"
);
assert!(
wait_until(|| queue_is_empty(&state), Duration::from_secs(20)),
"the single watcher should drain the queued commit"
);
}
fn wait_child(child: &mut std::process::Child, timeout: Duration) -> std::process::ExitStatus {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait().unwrap() {
Some(status) => return status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
panic!("watcher did not exit within {timeout:?}");
}
None => std::thread::sleep(Duration::from_millis(100)),
}
}
}