use std::io::Write;
use anyhow::Result;
use serde_json::json;
use super::candidates::{short_id, Census};
use super::log_index::ScanStats;
const EXCERPT_WIDTH: usize = 88;
const EXCERPT_INDENT: &str = " ";
pub fn render_text(
out: &mut dyn Write,
census: &Census,
stats: &ScanStats,
limit: usize,
) -> Result<()> {
writeln!(out, "ADR-0028 backfill classification report")?;
writeln!(
out,
"READ-ONLY. This command never writes a drawer, never sets expires_at, and never"
)?;
writeln!(
out,
"retires anything. ADR-0028 does not migrate the drawers already on disk — they"
)?;
writeln!(
out,
"keep competing in L1 exactly as before — so acting on a row below is a human"
)?;
writeln!(out, "decision, taken one drawer at a time.")?;
writeln!(out)?;
render_coverage(out, census, stats)?;
writeln!(out)?;
if census.rows.is_empty() {
writeln!(
out,
"No drawer met the filter. {}",
if stats.files_scanned == 0 {
"No hook log was found, so every injection count would be 0 — \
this is missing data, not an absence of stale drawers."
} else {
"Lower --min-injections to widen the list."
}
)?;
return Ok(());
}
for (i, row) in census.rows.iter().take(limit).enumerate() {
writeln!(
out,
"#{rank} {id} {palace} / {room}",
rank = i + 1,
id = short_id(&row.drawer_id),
palace = row.palace,
room = row.room
)?;
if let Some(peers) = row.collision_peers {
writeln!(
out,
" ⚠ SHARED this excerpt is identical to {peers} other drawer(s) in {palace}; \
the count below is for all {total} together, not this drawer alone. \
Content digest {digest} distinguishes them.",
peers = peers,
palace = row.palace,
total = peers + 1,
digest = row.content_digest
)?;
}
writeln!(
out,
" injections {n} ({pct:.1}% of {palace} turns)",
n = row.injections,
pct = row.share_of_turns * 100.0,
palace = row.palace
)?;
writeln!(
out,
" age {age:.1} days importance {imp:.2} -> {eff:.2} effective (90-day half-life)",
age = row.age_days,
imp = row.importance,
eff = row.effective_importance
)?;
writeln!(
out,
" expires_at {}",
if row.has_expiry {
"set — already triaged"
} else {
"not set"
}
)?;
let signals = if row.signals.is_empty() {
"none".to_string()
} else {
row.signals
.iter()
.map(|s| s.label())
.collect::<Vec<_>>()
.join(", ")
};
writeln!(out, " signals {signals}")?;
writeln!(out, " excerpt {}", wrap_excerpt(&row.excerpt))?;
writeln!(out)?;
}
if census.rows.len() > limit {
writeln!(
out,
"({shown} of {total} matching drawers shown — raise --limit for more)",
shown = limit,
total = census.rows.len()
)?;
}
Ok(())
}
fn render_coverage(out: &mut dyn Write, census: &Census, stats: &ScanStats) -> Result<()> {
let window = match (stats.earliest, stats.latest) {
(Some(a), Some(b)) => format!("{} to {}", a.format("%Y-%m-%d"), b.format("%Y-%m-%d")),
_ => "no entries".to_string(),
};
writeln!(
out,
"Hook logs: {files} file(s), {inj} prompt-context injections across all palaces, {window}",
files = stats.files_scanned,
inj = stats.injections_counted,
)?;
if stats.files_failed > 0 {
writeln!(
out,
" {} log file(s) unreadable and skipped",
stats.files_failed
)?;
}
let ok = census.outcomes.iter().filter(|o| o.error.is_none()).count();
writeln!(
out,
"Palaces: {ok} read, {drawers} drawers",
drawers = census.drawers_total
)?;
for o in census.outcomes.iter().filter(|o| o.error.is_some()) {
writeln!(
out,
" {} could not be read: {}",
o.palace,
o.error.as_deref().unwrap_or("unknown")
)?;
}
let colliding = census
.rows
.iter()
.filter(|r| r.collision_peers.is_some())
.count();
if colliding > 0 {
writeln!(
out,
" {colliding} row(s) share an excerpt with another drawer — their counts are \
combined, not per-drawer. Marked ⚠ SHARED below."
)?;
}
Ok(())
}
fn wrap_excerpt(excerpt: &str) -> String {
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in excerpt.split(' ') {
if !current.is_empty() && current.chars().count() + 1 + word.chars().count() > EXCERPT_WIDTH
{
lines.push(std::mem::take(&mut current));
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
lines.push(current);
}
lines.join(&format!("\n{EXCERPT_INDENT}"))
}
pub fn render_json(
out: &mut dyn Write,
census: &Census,
stats: &ScanStats,
limit: usize,
) -> Result<()> {
let rows: Vec<_> = census
.rows
.iter()
.take(limit)
.map(|r| {
json!({
"palace": r.palace,
"drawer_id": r.drawer_id.to_string(),
"room": r.room,
"excerpt": r.excerpt,
"age_days": r.age_days,
"injections": r.injections,
"share_of_turns": r.share_of_turns,
"importance": r.importance,
"effective_importance": r.effective_importance,
"has_expiry": r.has_expiry,
"signals": r.signals.iter().map(|s| s.label()).collect::<Vec<_>>(),
"excerpt_collision_peers": r.collision_peers,
"content_digest": r.content_digest,
})
})
.collect();
let doc = json!({
"read_only": true,
"coverage": {
"log_files_scanned": stats.files_scanned,
"log_files_failed": stats.files_failed,
"injections_counted": stats.injections_counted,
"window_start": stats.earliest.map(|t| t.to_rfc3339()),
"window_end": stats.latest.map(|t| t.to_rfc3339()),
"drawers_read": census.drawers_total,
"palaces_failed": census
.outcomes
.iter()
.filter(|o| o.error.is_some())
.map(|o| json!({ "palace": o.palace, "error": o.error }))
.collect::<Vec<_>>(),
},
"matching_drawers": census.rows.len(),
"candidates": rows,
});
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
Ok(())
}