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"))?;
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"
);
}
let entry = store
.waive(&args.rejection_sha, reason)
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
println!(
"truth-mirror: waived {} via human lane ({:?})",
entry.commit_sha,
ResolutionKind::Waived
);
Ok(ExitCode::SUCCESS)
}
fn run_petition(
rejection_sha: String,
fix_sha: String,
store: &LedgerStore,
state_dir: &Path,
) -> Result<ExitCode> {
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"
);
}
if let Some(reason) = fix_commit_unreviewable(&fix_sha) {
anyhow::bail!(
"refusing petition without spending an attempt: fix commit {fix_sha} cannot be reviewed ({reason})"
);
}
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.as_str()));
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()))?;
println!(
"truth-mirror: refused 3rd petition attempt for {rejection_sha}; escalation to needs-human recorded"
);
return Ok(ExitCode::SUCCESS);
}
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}");
}
println!(
"truth-mirror: 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"
);
Ok(ExitCode::SUCCESS)
}
fn fix_commit_unreviewable(fix_sha: &str) -> Option<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 None;
}
let exists = std::process::Command::new("git")
.args(["cat-file", "-e", &format!("{fix_sha}^{{commit}}")])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if exists {
None
} else {
Some("no such commit object in this repository".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]: ");
}
}