use std::{collections::BTreeMap, path::Path, process::ExitCode};
use anyhow::Result;
use crate::{cli::DebtArgs, ledger::LedgerStore};
pub fn run(_args: DebtArgs, state_dir: &Path) -> Result<ExitCode> {
let store = LedgerStore::new(state_dir);
let entries = store.flagged_entries()?;
if entries.is_empty() {
println!("No FLAG-class debt.");
return Ok(ExitCode::SUCCESS);
}
let mut sorted = entries;
sorted.sort_by_key(|entry| std::cmp::Reverse(entry.created_at_unix));
let by_class = group_by_finding_class(&sorted);
println!(
"truth-mirror debt (FLAG-class findings; classes alphabetical, entries newest first):\n"
);
for (class, group) in &by_class {
println!("== {} ({} entries) ==", class, group.len());
for entry in group {
println!(
" commit: {} ({} ago) reviewer={}/{}",
entry.commit_sha,
age_label(crate::time::unix_now().saturating_sub(entry.updated_at_unix)),
entry.reviewer.harness,
entry.reviewer.model
);
if !entry.summary.trim().is_empty() {
println!(" summary: {}", entry.summary);
}
for finding in &entry.structured_findings {
println!(" - {}", finding.display_line());
}
if entry.structured_findings.is_empty() {
for finding in &entry.findings {
println!(" - {finding}");
}
}
}
println!();
}
Ok(ExitCode::SUCCESS)
}
fn group_by_finding_class(
sorted: &[crate::ledger::LedgerEntry],
) -> BTreeMap<String, Vec<&crate::ledger::LedgerEntry>> {
let mut by_class: BTreeMap<String, Vec<&crate::ledger::LedgerEntry>> = BTreeMap::new();
for entry in sorted {
let mut classes: std::collections::BTreeSet<String> = entry
.structured_findings
.iter()
.map(|finding| format!("{}:{}", finding.severity, finding.title))
.collect();
if classes.is_empty() {
classes.insert("unclassified".to_owned());
}
for class in classes {
by_class.entry(class).or_default().push(entry);
}
}
by_class
}
fn age_label(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 60 * 60 {
format!("{}m", secs / 60)
} else if secs < 60 * 60 * 24 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ledger::{
FindingSeverity, LedgerEntry, LedgerStore, ReviewerConfig, StructuredFinding, Verdict,
};
fn reviewer() -> ReviewerConfig {
ReviewerConfig::new("claude", "claude-opus-4-1", false)
}
fn flagged(
sha: &str,
severity: FindingSeverity,
title: &str,
summary: &str,
timestamp: u64,
) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Flag,
"CLAIM: holds | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
vec![title.to_owned()],
timestamp,
)
.with_structured_review(
summary,
vec![StructuredFinding {
severity,
title: title.to_owned(),
body: summary.to_owned(),
file: "src/lib.rs".to_owned(),
line_start: 1,
line_end: 1,
confidence: 70,
recommendation: "Tighten evidence.".to_owned(),
}],
vec![],
r#"{"verdict":"FLAG"}"#,
)
}
#[test]
fn debt_is_quiet_with_no_flags() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store
.append_entry(&LedgerEntry::new_at(
"pass1",
Verdict::Pass,
"CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
Vec::new(),
100,
))
.unwrap();
assert!(store.flagged_entries().unwrap().is_empty());
let _ = temp;
}
#[test]
fn flagged_entries_returns_only_flag_records() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store
.append_entry(&flagged(
"flag1",
FindingSeverity::Medium,
"thin evidence",
"Claim holds but evidence is thin.",
100,
))
.unwrap();
store
.append_entry(&LedgerEntry::new_at(
"rej1",
Verdict::Reject,
"CLAIM: rejected | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
vec!["claim unsupported".to_owned()],
110,
))
.unwrap();
let flagged = store.flagged_entries().unwrap();
assert_eq!(flagged.len(), 1);
assert_eq!(flagged[0].commit_sha, "flag1");
assert_eq!(flagged[0].verdict, Verdict::Flag);
}
#[test]
fn age_label_renders_minutes_hours_days() {
assert_eq!(age_label(0), "0s");
assert_eq!(age_label(45), "45s");
assert_eq!(age_label(60), "1m");
assert_eq!(age_label(60 * 30), "30m");
assert_eq!(age_label(60 * 60 * 5), "5h");
assert_eq!(age_label(60 * 60 * 24 * 3), "3d");
}
#[test]
fn grouping_dedupes_non_adjacent_repeated_classes() {
use super::group_by_finding_class;
use crate::ledger::FindingSeverity;
let mut entry = flagged(
"flag1",
FindingSeverity::Medium,
"thin evidence",
"Claim holds but evidence is thin.",
100,
);
let mut findings = entry.structured_findings.clone();
let mut other = findings[0].clone();
other.title = "different class".to_owned();
findings.push(other);
findings.push(findings[0].clone());
entry.structured_findings = findings;
let entries = vec![entry];
let grouped = group_by_finding_class(&entries);
assert_eq!(grouped.len(), 2);
for (_, group) in grouped {
assert_eq!(group.len(), 1);
}
}
}