truth-mirror 0.15.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Side-effecting actions for the TUI — same code paths as the CLI.

use std::{
    io::{BufRead, BufReader},
    path::{Path, PathBuf},
    process::{Command, Stdio},
    sync::mpsc,
    thread,
};

use anyhow::Result;

use crate::{config::TruthMirrorConfig, ledger::LedgerStore, resolve, reviewer::ReviewQueue};

use super::data::{self, Snapshot};

fn ensure_enabled(config_path: &Path, state_dir: &Path) -> Result<bool> {
    let config = TruthMirrorConfig::load_for_cli(Some(config_path), state_dir)
        .map_err(|error| anyhow::anyhow!("failed to load runtime config: {error}"))?;
    Ok(config.enabled)
}

/// Drop a queue item by run_id (preferred) or SHA.
pub fn remove_queue_item(
    state_dir: &Path,
    config_path: &Path,
    run_id: &str,
    sha: &str,
) -> Result<String> {
    if !ensure_enabled(config_path, state_dir)? {
        return Ok(
            "truth-mirror: disabled by runtime configuration; skipped queue removal".to_owned(),
        );
    }
    let queue = ReviewQueue::new(state_dir);
    if !run_id.is_empty() {
        queue
            .remove_run_id(run_id)
            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
    } else {
        queue
            .remove_sha(sha)
            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
    }
    Ok(format!("removed queue item {sha}"))
}

/// Human-only waive via the shared resolve path (TTY gate enforced).
///
/// This is a deterministic human action and remains available while disabled;
/// it does not invoke a reviewer or provider.
pub fn waive_rejection(
    state_dir: &Path,
    _config_path: &Path,
    sha: &str,
    reason: &str,
) -> Result<String> {
    let store = LedgerStore::new(state_dir);
    let entry = resolve::waive_human(&store, sha, reason)?;
    Ok(format!("waived {} (human lane)", entry.commit_sha))
}

/// Petition a rejection with a fix SHA via the shared resolve path.
pub fn petition_rejection(
    state_dir: &Path,
    config_path: &Path,
    rejection_sha: &str,
    fix_sha: &str,
) -> Result<String> {
    if !ensure_enabled(config_path, state_dir)? {
        return Ok("truth-mirror: disabled by runtime configuration; skipped petition".to_owned());
    }
    let store = LedgerStore::new(state_dir);
    resolve::petition_fix_with_config(&store, state_dir, rejection_sha, fix_sha, Some(config_path))
}

/// Preview a config edit as a unified-style diff without writing.
pub fn preview_config_edit(snapshot: &Snapshot, path: &str, value: &str) -> Result<String> {
    let next = data::apply_config_edit(&snapshot.config_raw, path, value)?;
    Ok(data::line_diff(&snapshot.config_raw, &next))
}

/// Apply a validated config edit and write the file (comments preserved where feasible).
pub fn save_config_edit(
    config_path: &Path,
    snapshot: &Snapshot,
    path: &str,
    value: &str,
) -> Result<String> {
    let next = data::apply_config_edit(&snapshot.config_raw, path, value)?;
    data::write_config(config_path, &next)?;
    Ok(format!("wrote {}", config_path.display()))
}

/// Spawn `truth-mirror watch --once` and stream stdout/stderr lines into `tx`.
pub fn spawn_drain_once(
    state_dir: &Path,
    config_path: &Path,
    explicit_config: bool,
    tx: mpsc::Sender<DrainEvent>,
) -> Result<thread::JoinHandle<()>> {
    if !ensure_enabled(config_path, state_dir)? {
        let handle = thread::spawn(move || {
            let _ = tx.send(DrainEvent::Done(0));
        });
        return Ok(handle);
    }
    let binary = drain_binary();
    let state_dir = state_dir.to_path_buf();
    let config_path = config_path.to_path_buf();
    let binary_display = binary.display().to_string();
    let state_display = state_dir.display().to_string();
    let config_display = config_path.display().to_string();
    let command_display = if explicit_config {
        format!(" --config {config_display}")
    } else {
        String::new()
    };

    let _ = tx.send(DrainEvent::Line(format!(
        "$ {binary_display} --state-dir {state_display}{command_display} watch --once"
    )));

    let handle = thread::spawn(move || {
        let mut cmd = Command::new(&binary);
        cmd.arg("--state-dir").arg(&state_dir);
        if explicit_config {
            cmd.arg("--config").arg(&config_path);
        }
        cmd.arg("watch")
            .arg("--once")
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());

        let mut child = match cmd.spawn() {
            Ok(child) => child,
            Err(error) => {
                let _ = tx.send(DrainEvent::Line(format!("failed to spawn drain: {error}")));
                let _ = tx.send(DrainEvent::Done(1));
                return;
            }
        };

        let stdout = child.stdout.take();
        let stderr = child.stderr.take();

        let tx_out = tx.clone();
        let out_handle = thread::spawn(move || {
            if let Some(out) = stdout {
                for line in BufReader::new(out).lines().map_while(Result::ok) {
                    if tx_out.send(DrainEvent::Line(line)).is_err() {
                        break;
                    }
                }
            }
        });
        let tx_err = tx.clone();
        let err_handle = thread::spawn(move || {
            if let Some(err) = stderr {
                for line in BufReader::new(err).lines().map_while(Result::ok) {
                    if tx_err
                        .send(DrainEvent::Line(format!("[err] {line}")))
                        .is_err()
                    {
                        break;
                    }
                }
            }
        });

        let code = match child.wait() {
            Ok(status) => status.code().unwrap_or(1),
            Err(error) => {
                let _ = tx.send(DrainEvent::Line(format!("wait failed: {error}")));
                1
            }
        };
        let _ = out_handle.join();
        let _ = err_handle.join();
        let _ = tx.send(DrainEvent::Done(code));
    });

    Ok(handle)
}

fn drain_binary() -> PathBuf {
    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("truth-mirror"))
}

#[derive(Debug, PartialEq, Eq)]
pub enum DrainEvent {
    Line(String),
    Done(i32),
}

/// Resolve the effective config path for the TUI (explicit or state-dir default).
pub fn resolve_config_path(explicit: Option<&Path>, state_dir: &Path) -> PathBuf {
    explicit.map_or_else(
        || crate::config::TruthMirrorConfig::default_path(state_dir),
        Path::to_path_buf,
    )
}

/// Validate that a config path/value pair is legal before preview/save.
pub fn validate_and_normalize_edit(path: &str, raw: &str) -> Result<String> {
    super::model::validate_config_edit(path, raw).map_err(anyhow::Error::msg)
}

/// Ensure a non-empty waiver body has more than the template placeholder text.
pub fn validate_waiver_reason(raw: &str) -> Result<()> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        anyhow::bail!("waiver reason must not be empty");
    }
    if trimmed.contains("<why this rejection") || trimmed.contains("<what we are choosing") {
        anyhow::bail!("replace the template placeholders before submitting the waiver");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::{fs, sync::mpsc};

    use super::*;

    #[test]
    fn disabled_queue_mutation_is_a_successful_noop() {
        let temp = tempfile::tempdir().unwrap();
        let config_path = temp.path().join("config.toml");
        fs::write(&config_path, "enabled = false\n").unwrap();
        let queue = ReviewQueue::new(temp.path());
        queue.enqueue("queued").unwrap();

        let outcome = remove_queue_item(temp.path(), &config_path, "", "queued").unwrap();

        assert_eq!(
            outcome,
            "truth-mirror: disabled by runtime configuration; skipped queue removal"
        );
        assert_eq!(queue.pending().unwrap().len(), 1);
    }

    #[test]
    fn disabled_provider_spawn_is_a_successful_noop() {
        let temp = tempfile::tempdir().unwrap();
        let config_path = temp.path().join("config.toml");
        fs::write(&config_path, "enabled = false\n").unwrap();
        let (tx, rx) = mpsc::channel();

        let handle = spawn_drain_once(temp.path(), &config_path, false, tx).unwrap();
        handle.join().unwrap();

        assert!(matches!(rx.try_recv().unwrap(), DrainEvent::Done(0)));
    }

    #[test]
    fn disabled_petition_is_a_successful_noop_and_waiver_still_works() {
        use crate::ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict};

        let temp = tempfile::tempdir().unwrap();
        let config_path = temp.path().join("config.toml");
        fs::write(&config_path, "enabled = false\n").unwrap();

        let petition = petition_rejection(temp.path(), &config_path, "rejection", "fix").unwrap();
        assert_eq!(
            petition,
            "truth-mirror: disabled by runtime configuration; skipped petition"
        );

        let store = LedgerStore::new(temp.path());
        store
            .append_entry(&LedgerEntry::new(
                "rejection",
                Verdict::Reject,
                "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
                vec!["tests:cargo-test".into()],
                ReviewerConfig::new("claude", "claude-opus-4", false),
                vec!["finding".into()],
            ))
            .unwrap();

        resolve::set_stdin_tty_override_for_testing(Some(true));
        let waiver = waive_rejection(temp.path(), &config_path, "rejection", "reason").unwrap();
        resolve::set_stdin_tty_override_for_testing(None);

        assert_eq!(waiver, "waived rejection (human lane)");
    }

    #[test]
    fn disabled_drain_does_not_emit_a_command_line() {
        let temp = tempfile::tempdir().unwrap();
        let config_path = temp.path().join("config.toml");
        fs::write(&config_path, "enabled = false\n").unwrap();
        let (tx, rx) = mpsc::channel();

        let handle = spawn_drain_once(temp.path(), &config_path, false, tx).unwrap();
        handle.join().unwrap();

        let events: Vec<_> = rx.try_iter().collect();
        assert_eq!(
            events,
            vec![DrainEvent::Done(0)],
            "disabled drain must not spawn a child or emit the command line"
        );
    }
}