truth-mirror 0.15.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
mod common;
use std::{fs, path::Path};

use common::truth_mirror_bin;
use predicates::prelude::*;

fn write(path: &Path, contents: &str) {
    fs::write(path, contents).unwrap();
}

#[test]
fn commit_msg_gate_accepts_evidenced_claim() {
    let temp = tempfile::tempdir().unwrap();
    let commit_msg = temp.path().join("COMMIT_EDITMSG");
    write(
        &commit_msg,
        "feat: claim parser\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test\n",
    );

    truth_mirror_bin("truth-mirror")
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .assert()
        .success();
}

fn valid_commit_message(temp: &tempfile::TempDir) -> std::path::PathBuf {
    let commit_msg = temp.path().join("COMMIT_EDITMSG");
    write(
        &commit_msg,
        "feat: hermetic config loading\n\nCLAIM: load config | verified: cargo test | evidence: tests:gate-cli\n",
    );
    commit_msg
}

#[test]
fn commit_msg_gate_loads_without_home_or_xdg_config() {
    let temp = tempfile::tempdir().unwrap();
    let commit_msg = valid_commit_message(&temp);

    truth_mirror_bin("truth-mirror")
        .env_remove("XDG_CONFIG_HOME")
        .env_remove("HOME")
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .assert()
        .success();
}

#[test]
fn commit_msg_gate_does_not_materialize_the_system_config() {
    let temp = tempfile::tempdir().unwrap();
    let config_home = temp.path().join("xdg-config");
    let commit_msg = valid_commit_message(&temp);
    fs::create_dir(&config_home).unwrap();

    truth_mirror_bin("truth-mirror")
        .env("XDG_CONFIG_HOME", &config_home)
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .assert()
        .success();

    assert!(!config_home.join("truth-mirror/config.toml").exists());
}

#[cfg(unix)]
#[test]
fn commit_msg_gate_ignores_an_unwritable_system_config_directory() {
    use std::os::unix::fs::PermissionsExt;

    let temp = tempfile::tempdir().unwrap();
    let config_home = temp.path().join("read-only-xdg-config");
    let commit_msg = valid_commit_message(&temp);
    fs::create_dir(&config_home).unwrap();
    fs::set_permissions(&config_home, fs::Permissions::from_mode(0o500)).unwrap();

    let output = truth_mirror_bin("truth-mirror")
        .env("XDG_CONFIG_HOME", &config_home)
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .output()
        .unwrap();

    fs::set_permissions(&config_home, fs::Permissions::from_mode(0o700)).unwrap();
    assert!(
        output.status.success(),
        "gate should ignore an unavailable system config directory:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn commit_msg_gate_rejects_a_malformed_existing_system_config() {
    let temp = tempfile::tempdir().unwrap();
    let config_home = temp.path().join("xdg-config");
    let commit_msg = valid_commit_message(&temp);
    let system_dir = config_home.join("truth-mirror");
    fs::create_dir_all(&system_dir).unwrap();
    write(&system_dir.join("config.toml"), "enabled = [\n");

    truth_mirror_bin("truth-mirror")
        .env("XDG_CONFIG_HOME", &config_home)
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("failed to parse config"));
}

#[test]
fn disabled_config_still_runs_commit_gate() {
    let temp = tempfile::tempdir().unwrap();
    let state = temp.path().join("state");
    let commit_msg = temp.path().join("COMMIT_EDITMSG");
    fs::create_dir_all(&state).unwrap();
    write(&state.join("config.toml"), "enabled = false\n");
    write(
        &commit_msg,
        "feat: claim parser\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test\n",
    );

    truth_mirror_bin("truth-mirror")
        .args([
            "--state-dir",
            state.to_str().unwrap(),
            "gate",
            "--commit-msg",
            commit_msg.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stdout(predicate::str::is_empty())
        .stderr(predicate::str::is_empty());
}

#[test]
fn commit_msg_gate_accepts_duplicate_empty_verification_aliases_in_any_order() {
    let temp = tempfile::tempdir().unwrap();
    let first = temp.path().join("FIRST_MSG");
    let second = temp.path().join("SECOND_MSG");
    write(
        &first,
        "feat: claim parser\n\nCLAIM: fix parser | verified: cargo test | verification: | evidence: tests:x\n",
    );
    write(
        &second,
        "feat: claim parser\n\nCLAIM: fix parser | verification: | verified: cargo test | evidence: tests:x\n",
    );

    for commit_msg in [first, second] {
        truth_mirror_bin("truth-mirror")
            .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
            .assert()
            .success();
    }
}

#[test]
fn commit_msg_gate_rejects_missing_claim() {
    let temp = tempfile::tempdir().unwrap();
    let commit_msg = temp.path().join("COMMIT_EDITMSG");
    write(&commit_msg, "feat: claim parser\n");

    truth_mirror_bin("truth-mirror")
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("missing CLAIM"));
}

// Finding 2: when a CLAIM line is present but evidence is missing/invalid, the
// gate must report the underlying ClaimError (naming the exact problem) rather
// than the generic "completion wording lacks evidence" hint.
#[test]
fn commit_msg_gate_rejects_claim_with_missing_evidence_directly() {
    let temp = tempfile::tempdir().unwrap();
    let commit_msg = temp.path().join("COMMIT_EDITMSG");
    write(
        &commit_msg,
        "feat: done parser\n\nCLAIM: complete parser | verified: cargo test\n",
    );

    truth_mirror_bin("truth-mirror")
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("missing evidence pointer"));
}

// CompletionWithoutEvidence is still raised when there is NO CLAIM line at all
// and body prose contains a completion word.
#[test]
fn commit_msg_gate_rejects_completion_without_any_claim() {
    let temp = tempfile::tempdir().unwrap();
    let commit_msg = temp.path().join("COMMIT_EDITMSG");
    write(&commit_msg, "feat: done — this is complete\n");

    truth_mirror_bin("truth-mirror")
        .args(["gate", "--commit-msg", commit_msg.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "completion wording lacks evidence",
        ))
        .stderr(predicate::str::contains("example: CLAIM: change behavior"));
}

#[test]
fn pre_push_warns_about_queued_unreviewed_claims_without_blocking() {
    let temp = tempfile::tempdir().unwrap();
    let state = temp.path().join("state");
    fs::create_dir(&state).unwrap();
    fs::write(
        state.join("review-queue.jsonl"),
        "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":1}\n",
    )
    .unwrap();

    truth_mirror_bin("truth-mirror")
        .args([
            "--state-dir",
            state.to_str().unwrap(),
            "gate",
            "--pre-push",
            "abc123",
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains("1 unreviewed claim(s) queued"))
        .stderr(predicate::str::contains("truth-mirror --state-dir"))
        .stderr(predicate::str::contains(state.to_string_lossy().as_ref()))
        .stderr(predicate::str::contains("watch --once"));
}

#[test]
fn truth_alias_pre_push_warning_uses_alias_prefix_and_drain_command() {
    let temp = tempfile::tempdir().unwrap();
    let state = temp.path().join("state");
    fs::create_dir(&state).unwrap();
    fs::write(
        state.join("review-queue.jsonl"),
        "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":1}\n",
    )
    .unwrap();

    truth_mirror_bin("truth")
        .args([
            "--state-dir",
            state.to_str().unwrap(),
            "gate",
            "--pre-push",
            "abc123",
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains("truth: warning"))
        .stderr(predicate::str::contains("truth --state-dir"))
        .stderr(predicate::str::contains("truth-mirror: warning").not());
}

#[test]
fn pre_push_warning_preserves_config_in_drain_guidance() {
    let temp = tempfile::tempdir().unwrap();
    let state = temp.path().join("state");
    let config = temp.path().join("truth.toml");
    fs::create_dir(&state).unwrap();
    fs::write(&config, "").unwrap();
    fs::write(
        state.join("review-queue.jsonl"),
        "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":1}\n",
    )
    .unwrap();

    truth_mirror_bin("truth-mirror")
        .args([
            "--config",
            config.to_str().unwrap(),
            "--state-dir",
            state.to_str().unwrap(),
            "gate",
            "--pre-push",
            "abc123",
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains("truth-mirror --config"))
        .stderr(predicate::str::contains(config.to_string_lossy().as_ref()))
        .stderr(predicate::str::contains("watch --once"));
}

#[test]
fn pre_push_queue_warning_parse_error_does_not_block_push() {
    let temp = tempfile::tempdir().unwrap();
    let state = temp.path().join("state");
    fs::create_dir(&state).unwrap();
    fs::write(state.join("review-queue.jsonl"), "not json\n").unwrap();

    truth_mirror_bin("truth-mirror")
        .args([
            "--state-dir",
            state.to_str().unwrap(),
            "gate",
            "--pre-push",
            "abc123",
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains(
            "could not read async review queue",
        ));
}

#[test]
fn commit_msg_gate_rejects_fake_marker_in_diff_file() {
    let temp = tempfile::tempdir().unwrap();
    let commit_msg = temp.path().join("COMMIT_EDITMSG");
    let diff = temp.path().join("staged.diff");
    write(
        &commit_msg,
        "feat: claim parser\n\nCLAIM: add parser | verified: cargo test | evidence: tests:cargo-test\n",
    );
    write(
        &diff,
        "diff --git a/src/lib.rs b/src/lib.rs\n+ TODO-as-done\n",
    );

    truth_mirror_bin("truth-mirror")
        .args([
            "gate",
            "--commit-msg",
            commit_msg.to_str().unwrap(),
            "--diff-file",
            diff.to_str().unwrap(),
        ])
        .assert()
        .failure()
        .stderr(predicate::str::contains("TODO-as-done"));
}