truth-mirror 0.13.1

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::{ledger::LedgerStore, resolve, reviewer::ReviewQueue};

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

/// Drop a queue item by run_id (preferred) or SHA.
pub fn remove_queue_item(state_dir: &Path, run_id: &str, sha: &str) -> Result<()> {
    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(())
}

/// Human-only waive via the shared resolve path (TTY gate enforced).
pub fn waive_rejection(state_dir: &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, rejection_sha: &str, fix_sha: &str) -> Result<String> {
    let store = LedgerStore::new(state_dir);
    resolve::petition_fix(&store, state_dir, rejection_sha, fix_sha)
}

/// 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,
    tx: mpsc::Sender<DrainEvent>,
) -> Result<thread::JoinHandle<()>> {
    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 _ = tx.send(DrainEvent::Line(format!(
        "$ {binary_display} --state-dir {state_display} --config {config_display} watch --once"
    )));

    let handle = thread::spawn(move || {
        let mut cmd = Command::new(&binary);
        cmd.arg("--state-dir")
            .arg(&state_dir)
            .arg("--config")
            .arg(&config_path)
            .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)]
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(())
}