truth-mirror 0.1.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Hook installation, uninstallation, and dry-run planning.

use std::{
    fs,
    io::{self, Read},
    path::{Path, PathBuf},
    process::{Command, ExitCode},
};

use anyhow::{Context, Result, bail};

use crate::{
    claim,
    cli::{self, Agent, HookName},
    gate,
    reviewer::ReviewQueue,
    surface::{self, SurfacePlan},
};

const INSTALLED_HOOKS: &[HookName] =
    &[HookName::CommitMsg, HookName::PostCommit, HookName::PrePush];

pub fn run(args: cli::InstallHooksArgs, state_dir: &Path) -> Result<ExitCode> {
    let repo_root = git_root()?;
    let hooks_path = repo_root.join(state_dir).join("hooks");
    let plan = HookInstallPlan::new(&repo_root, &hooks_path, args.uninstall);
    let agents = surface_agents(&args);

    if args.dry_run {
        println!("{}", plan.render());
        for agent in &agents {
            println!(
                "surface: {} -> {}",
                surface::agent_slug(*agent),
                surface::surface_relative_path(*agent)
            );
        }
        return Ok(ExitCode::SUCCESS);
    }

    if args.uninstall {
        uninstall(&plan)?;
        for agent in &agents {
            SurfacePlan::for_agent(&repo_root, *agent).uninstall()?;
        }
    } else {
        install(&plan)?;
        for agent in &agents {
            SurfacePlan::for_agent(&repo_root, *agent).install()?;
        }
    }

    Ok(ExitCode::SUCCESS)
}

/// Agents whose reinjection surface this invocation touches. Install acts only on
/// explicitly selected agents; uninstall with no selection clears all surfaces so
/// a bare `install-hooks --uninstall` fully reverses a prior per-agent install.
fn surface_agents(args: &cli::InstallHooksArgs) -> Vec<Agent> {
    let mut agents = Vec::new();
    if args.claude {
        agents.push(Agent::Claude);
    }
    if args.codex {
        agents.push(Agent::Codex);
    }
    if args.pi {
        agents.push(Agent::Pi);
    }

    if agents.is_empty() && args.uninstall {
        return surface::ALL_AGENTS.to_vec();
    }
    agents
}

pub fn dispatch(args: cli::HookDispatchArgs, state_dir: &Path) -> Result<ExitCode> {
    run_chained_hook(state_dir, args.hook, &args.args)?;

    match args.hook {
        HookName::CommitMsg => dispatch_commit_msg(state_dir, &args.args)?,
        HookName::PostCommit => dispatch_post_commit(state_dir)?,
        HookName::PrePush => dispatch_pre_push(state_dir)?,
    }

    Ok(ExitCode::SUCCESS)
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HookInstallPlan {
    pub repo_root: PathBuf,
    pub hooks_path: PathBuf,
    pub uninstall: bool,
}

impl HookInstallPlan {
    pub fn new(repo_root: &Path, hooks_path: &Path, uninstall: bool) -> Self {
        Self {
            repo_root: repo_root.to_path_buf(),
            hooks_path: hooks_path.to_path_buf(),
            uninstall,
        }
    }

    pub fn render(&self) -> String {
        let action = if self.uninstall {
            "uninstall"
        } else {
            "install"
        };
        let hooks = INSTALLED_HOOKS
            .iter()
            .map(|hook| hook.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        format!(
            "truth-mirror hook plan\nrepo={}\naction={action}\nhooksPath={}\nhooks={hooks}",
            self.repo_root.display(),
            self.hooks_path.display()
        )
    }
}

pub fn render_shim(hook: HookName) -> String {
    format!(
        "#!/bin/sh\nexec truth-mirror hook-dispatch {} \"$@\"\n",
        hook.as_str()
    )
}

fn install(plan: &HookInstallPlan) -> Result<()> {
    fs::create_dir_all(&plan.hooks_path)?;
    fs::create_dir_all(plan.hooks_path.join("chained"))?;
    let git_hooks = plan.repo_root.join(".git/hooks");

    for hook in INSTALLED_HOOKS {
        let existing = git_hooks.join(hook.as_str());
        if existing.is_file() {
            let chained = plan.hooks_path.join("chained").join(hook.as_str());
            fs::copy(&existing, chained)?;
        }

        let hook_path = plan.hooks_path.join(hook.as_str());
        fs::write(&hook_path, render_shim(*hook))?;
        make_executable(&hook_path)?;
    }

    git_config(&["config", "core.hooksPath", &path_for_git(&plan.hooks_path)])?;
    Ok(())
}

fn uninstall(plan: &HookInstallPlan) -> Result<()> {
    let _ = Command::new("git")
        .args(["config", "--unset", "core.hooksPath"])
        .current_dir(&plan.repo_root)
        .status();

    if plan.hooks_path.exists() {
        fs::remove_dir_all(&plan.hooks_path)?;
    }
    Ok(())
}

fn dispatch_commit_msg(state_dir: &Path, args: &[String]) -> Result<()> {
    let commit_msg_path = args
        .first()
        .context("commit-msg hook requires COMMIT_EDITMSG path")?;
    let commit_message = fs::read_to_string(commit_msg_path)?;
    let diff = git_stdout(&["diff", "--cached"])?;
    let claim_file = fs::read_to_string(state_dir.join("claim.txt")).ok();
    claim::evaluate_commit_message(&commit_message, claim_file.as_deref(), Some(&diff), &[])?;
    Ok(())
}

fn dispatch_post_commit(state_dir: &Path) -> Result<()> {
    let sha = git_stdout(&["rev-parse", "HEAD"])?;
    ReviewQueue::new(state_dir).enqueue(sha.trim())?;
    Ok(())
}

fn dispatch_pre_push(state_dir: &Path) -> Result<()> {
    let mut stdin = String::new();
    io::stdin().read_to_string(&mut stdin)?;
    for line in stdin.lines() {
        if let Some(range) = pre_push_range_from_line(line) {
            gate::run(
                cli::GateArgs {
                    pre_push: Some(range),
                    commit_msg: None,
                    claim_file: None,
                    diff_file: None,
                    fake_markers: Vec::new(),
                },
                state_dir,
            )?;
        }
    }
    Ok(())
}

fn pre_push_range_from_line(line: &str) -> Option<String> {
    let parts = line.split_whitespace().collect::<Vec<_>>();
    let local_sha = parts.get(1)?;
    let remote_sha = parts.get(3)?;
    if is_zero_sha(local_sha) {
        return None;
    }

    if is_zero_sha(remote_sha) {
        Some((*local_sha).to_owned())
    } else {
        Some(format!("{remote_sha}..{local_sha}"))
    }
}

fn is_zero_sha(value: &str) -> bool {
    value.chars().all(|character| character == '0')
}

fn run_chained_hook(state_dir: &Path, hook: HookName, args: &[String]) -> Result<()> {
    let chained = state_dir.join("hooks/chained").join(hook.as_str());
    if !chained.is_file() {
        return Ok(());
    }

    let status = Command::new(&chained).args(args).status()?;
    if !status.success() {
        bail!(
            "chained hook {} failed with status {status}",
            chained.display()
        );
    }
    Ok(())
}

fn git_root() -> Result<PathBuf> {
    Ok(PathBuf::from(
        git_stdout(&["rev-parse", "--show-toplevel"])?.trim(),
    ))
}

fn git_stdout(args: &[&str]) -> Result<String> {
    let output = Command::new("git").args(args).output()?;
    if !output.status.success() {
        bail!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn git_config(args: &[&str]) -> Result<()> {
    let status = Command::new("git").args(args).status()?;
    if !status.success() {
        bail!("git {} failed with status {status}", args.join(" "));
    }
    Ok(())
}

fn path_for_git(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}

#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    let mut permissions = fs::metadata(path)?.permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions)?;
    Ok(())
}

#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    use super::{HookInstallPlan, pre_push_range_from_line, render_shim};
    use crate::cli::HookName;

    #[test]
    fn hook_shim_is_only_exec_delegation() {
        let shim = render_shim(HookName::CommitMsg);
        let lines = shim.lines().collect::<Vec<_>>();

        assert_eq!(lines.len(), 2);
        assert_eq!(lines[0], "#!/bin/sh");
        assert_eq!(
            lines[1],
            "exec truth-mirror hook-dispatch commit-msg \"$@\""
        );
    }

    #[test]
    fn dry_run_plan_names_hooks_and_hooks_path() {
        let plan = HookInstallPlan::new(
            std::path::Path::new("/repo"),
            std::path::Path::new("/repo/.truth-mirror/hooks"),
            false,
        );
        let rendered = plan.render();

        assert!(rendered.contains("commit-msg"));
        assert!(rendered.contains("post-commit"));
        assert!(rendered.contains("pre-push"));
        assert!(rendered.contains("hooksPath=/repo/.truth-mirror/hooks"));
    }

    #[test]
    fn pre_push_line_maps_to_git_range() {
        let line = "refs/heads/main abc123 refs/heads/main def456";

        assert_eq!(
            pre_push_range_from_line(line),
            Some("def456..abc123".to_owned())
        );
    }

    proptest! {
        #[test]
        fn hook_shim_rendering_stays_tiny_exec_only(index in 0usize..3) {
            let hook = [HookName::CommitMsg, HookName::PostCommit, HookName::PrePush][index];
            let shim = render_shim(hook);
            let lines = shim.lines().collect::<Vec<_>>();

            prop_assert_eq!(lines.len(), 2);
            prop_assert_eq!(lines[0], "#!/bin/sh");
            prop_assert!(lines[1].starts_with("exec truth-mirror hook-dispatch "));
            prop_assert!(lines[1].contains(hook.as_str()));
        }
    }
}