use std::{
io::{self, IsTerminal, Write},
path::Path,
process::ExitCode,
sync::atomic::{AtomicI32, Ordering},
};
use anyhow::Result;
use crate::{
cli::ResolveArgs,
config::TruthMirrorConfig,
ledger::{LedgerStore, ResolutionKind, Verdict},
};
pub const MAX_PETITION_ATTEMPTS: u32 = 2;
pub fn run(args: ResolveArgs, state_dir: &Path, _config: &TruthMirrorConfig) -> Result<ExitCode> {
let store = LedgerStore::new(state_dir);
if args.waive {
return run_waive(args, &store);
}
if let Some(fix_sha) = args.fixed_by {
return run_petition(args.rejection_sha, fix_sha, &store, state_dir);
}
anyhow::bail!(
"resolve requires either --fixed-by <fix-sha> (agent petition) or --waive --reason <text> (human-only)"
)
}
fn run_waive(args: ResolveArgs, store: &LedgerStore) -> Result<ExitCode> {
let reason = args
.reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("--waive requires a non-empty --reason"))?;
let entry = waive_human(store, &args.rejection_sha, reason)?;
println!(
"truth-mirror: waived {} via human lane ({:?})",
entry.commit_sha,
ResolutionKind::Waived
);
Ok(ExitCode::SUCCESS)
}
pub fn waive_human(
store: &LedgerStore,
rejection_sha: &str,
reason: &str,
) -> Result<crate::ledger::LedgerEntry> {
let reason = reason.trim();
if reason.is_empty() {
anyhow::bail!("waive requires a non-empty reason");
}
if !stdin_is_tty() {
anyhow::bail!(
"--waive requires an interactive terminal (TTY); refuse when stdin is not a tty so agents structurally cannot invoke it"
);
}
store
.waive(rejection_sha, reason)
.map_err(|error| anyhow::anyhow!(error.to_string()))
}
fn run_petition(
rejection_sha: String,
fix_sha: String,
store: &LedgerStore,
state_dir: &Path,
) -> Result<ExitCode> {
let message = petition_fix(store, state_dir, &rejection_sha, &fix_sha)?;
println!("truth-mirror: {message}");
Ok(ExitCode::SUCCESS)
}
pub fn petition_fix(
store: &LedgerStore,
state_dir: &Path,
rejection_sha: &str,
fix_sha: &str,
) -> Result<String> {
let original = store.show(rejection_sha).map_err(|error| {
anyhow::anyhow!("rejection {rejection_sha} not found in ledger: {error}")
})?;
if !original.is_unresolved_rejection() {
let disposition = original.disposition;
let verdict = original.verdict;
let petition_attempts = store.petition_attempts_for(rejection_sha).unwrap_or(0);
anyhow::bail!(
"rejection {rejection_sha} is not open (verdict={verdict}, disposition={disposition}, petition_attempts={petition_attempts}); no petition review is needed"
);
}
let fix_sha = &resolve_fix_ref_to_full_sha(fix_sha)?;
let in_flight = crate::reviewer::ReviewQueue::new(state_dir)
.pending()
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.into_iter()
.any(|item| item.petition_for.as_deref() == Some(rejection_sha));
if in_flight {
anyhow::bail!(
"refusing petition without spending an attempt: a petition for {rejection_sha} is already queued; let the watcher drain it first"
);
}
let prior_attempts = store.petition_attempts_for(rejection_sha).unwrap_or(0);
if prior_attempts >= MAX_PETITION_ATTEMPTS {
let reason = format!(
"petition refused: {MAX_PETITION_ATTEMPTS} attempts already recorded for {rejection_sha}; escalating to needs-human"
);
store
.escalate_to_needs_human(rejection_sha, &reason)
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
return Ok(format!(
"refused 3rd petition attempt for {rejection_sha}; escalation to needs-human recorded"
));
}
let next_attempts = prior_attempts.saturating_add(1);
let reason = format!(
"petition review enqueued: fix={fix_sha}, attempts={next_attempts}/{MAX_PETITION_ATTEMPTS}"
);
store
.append_petition_transition(
rejection_sha,
crate::ledger::Disposition::Open,
ResolutionKind::Resolved,
&reason,
next_attempts,
)
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
crate::reviewer::ReviewQueue::new(state_dir)
.enqueue_petition(fix_sha, rejection_sha)
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
if let Err(error) = crate::watcher::ensure_watcher(state_dir) {
eprintln!("truth-mirror: warning: failed to ensure a review watcher: {error}");
}
Ok(format!(
"enqueued petition review for fix={fix_sha} against rejection={rejection_sha} (attempt {next_attempts}/{MAX_PETITION_ATTEMPTS}); the watcher will drain and transition the rejection based on the verdict"
))
}
fn resolve_fix_ref_to_full_sha(fix_ref: &str) -> Result<String> {
let in_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if !in_repo {
return Ok(fix_ref.to_owned());
}
let output = std::process::Command::new("git")
.args(["rev-parse", "--verify", &format!("{fix_ref}^{{commit}}")])
.output()
.map_err(|error| {
anyhow::anyhow!("failed to run git rev-parse for --fixed-by {fix_ref:?}: {error}")
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"refusing petition without spending an attempt: cannot resolve --fixed-by {fix_ref:?} to a commit in this repository ({})",
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub fn stdin_is_tty() -> bool {
if let Some(value) = stdin_tty_override() {
return value;
}
io::stdin().is_terminal()
}
const STDIN_TTY_OVERRIDE_NONE: i32 = i32::MIN;
static STDIN_TTY_OVERRIDE: AtomicI32 = AtomicI32::new(STDIN_TTY_OVERRIDE_NONE);
fn stdin_tty_override() -> Option<bool> {
let value = STDIN_TTY_OVERRIDE.load(Ordering::SeqCst);
if value == STDIN_TTY_OVERRIDE_NONE {
None
} else {
Some(value != 0)
}
}
#[cfg(any(test, debug_assertions))]
#[doc(hidden)]
pub fn set_stdin_tty_override_for_testing(value: Option<bool>) {
match value {
Some(flag) => {
STDIN_TTY_OVERRIDE.store(flag as i32, Ordering::SeqCst);
}
None => {
STDIN_TTY_OVERRIDE.store(STDIN_TTY_OVERRIDE_NONE, Ordering::SeqCst);
}
}
}
pub fn petition_accepts(verdict: Verdict) -> bool {
matches!(verdict, Verdict::Pass | Verdict::Flag)
}
#[doc(hidden)]
pub fn prompt_waiver_reason_to_writer(writer: &mut dyn Write, default: &str) -> io::Result<()> {
write!(writer, "waiver reason [{default}]: ")?;
writer.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stdin_tty_override_round_trip() {
set_stdin_tty_override_for_testing(Some(true));
assert!(stdin_is_tty());
set_stdin_tty_override_for_testing(Some(false));
assert!(!stdin_is_tty());
set_stdin_tty_override_for_testing(None);
}
#[test]
fn petition_accepts_pass_and_flag_but_not_reject() {
assert!(petition_accepts(Verdict::Pass));
assert!(petition_accepts(Verdict::Flag));
assert!(!petition_accepts(Verdict::Reject));
}
#[test]
fn max_petition_attempts_is_two() {
assert_eq!(MAX_PETITION_ATTEMPTS, 2);
}
#[test]
fn prompt_helper_writes_default_text() {
let mut buffer = Vec::new();
prompt_waiver_reason_to_writer(&mut buffer, "approved exception").unwrap();
let text = String::from_utf8(buffer).unwrap();
assert_eq!(text, "waiver reason [approved exception]: ");
}
}