use crate::model::Change;
use super::index::{AiConfidence, TraceIndex};
pub fn build_report(changes: &[Change], index: &TraceIndex, generated_at: &str) -> String {
let summary = index.summarize(changes);
let mut out = String::new();
out.push_str("# Agent Trace Report\n\n");
out.push_str(&format!("- Generated: {}\n", generated_at));
out.push_str(&format!(
"- Scope: {} loaded changes (jj --limit range)\n\n",
summary.total
));
out.push_str("## Summary\n\n");
out.push_str(&format!(
"AI {}/{} ({}%) · [AI] {} · [AI?] {}\n\n",
summary.ai_total,
summary.total,
summary.ai_percent(),
summary.ai_confirmed,
summary.ai_heuristic
));
out.push_str("### By model\n");
if summary.by_model.is_empty() {
out.push_str("- (none)\n");
} else {
let mut entries: Vec<(&String, &usize)> = summary.by_model.iter().collect();
entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
for (name, n) in entries {
out.push_str(&format!("- {}: {} changes\n", name, n));
}
}
out.push('\n');
out.push_str("## AI-attributed changes\n\n");
let rows = detail_rows(changes, index);
if rows.is_empty() {
out.push_str("No AI-attributed changes in the loaded set.\n");
} else {
out.push_str("| change | conf | description | model | files | session |\n");
out.push_str("|--------|------|-------------|-------|-------|---------|\n");
for row in rows {
out.push_str(&format!(
"| {} | {} | {} | {} | {} | {} |\n",
row.change, row.conf, row.description, row.model, row.files, row.session
));
}
}
let orphans = index.orphaned_anchors(changes);
if !orphans.is_empty() {
out.push('\n');
out.push_str("## Orphaned traces\n\n");
out.push_str(
"These traces reference a revision not among the loaded changes \
(out of the --limit window, rebased, or abandoned):\n\n",
);
out.push_str("| anchor | revision | files | session |\n");
out.push_str("|--------|----------|-------|---------|\n");
for o in orphans {
let anchor = match o.vcs_type {
crate::trace::TraceVcsType::Jj => "jj",
crate::trace::TraceVcsType::Git => "git",
crate::trace::TraceVcsType::Other => "other",
};
let files = if o.files.is_empty() {
String::new()
} else {
escape_cell(&o.files.join("; "))
};
let session = o.url.as_deref().map(escape_cell).unwrap_or_default();
out.push_str(&format!(
"| {} | {} | {} | {} |\n",
anchor,
escape_cell(&o.revision),
files,
session
));
}
}
out
}
struct DetailRow {
change: String,
conf: &'static str,
description: String,
model: String,
files: String,
session: String,
}
fn detail_rows(changes: &[Change], index: &TraceIndex) -> Vec<DetailRow> {
let mut rows = Vec::new();
for change in changes {
if change.is_graph_only {
continue;
}
let cid = change.change_id.as_str();
let coid = change.commit_id.as_str();
let conf = match index.ai_status(cid, coid) {
Some(AiConfidence::Confirmed) => "[AI]",
Some(AiConfidence::Heuristic) => "[AI?]",
None => continue,
};
let records = index.records_for(cid, coid);
let mut models: Vec<String> = Vec::new();
for record in &records {
for m in record.model_ids() {
if !models.iter().any(|x| x == m) {
models.push(m.to_string());
}
}
}
let model = if models.is_empty() {
"—".to_string()
} else {
escape_cell(&models.join(", "))
};
let session = records
.iter()
.flat_map(|r| r.all_urls())
.map(|(_, url)| url)
.next()
.map(|u| escape_cell(&u))
.unwrap_or_default();
rows.push(DetailRow {
change: escape_cell(cid),
conf,
description: escape_cell(change.display_description()),
model,
files: escape_cell(&format_files(index, cid, coid, &records)),
session,
});
}
rows
}
const MAX_FILES: usize = 3;
fn format_files(
index: &TraceIndex,
change_id: &str,
commit_id: &str,
records: &[&super::TraceRecord],
) -> String {
let ranges = index.ai_ranges_for(change_id, commit_id);
let mut entries: Vec<String> = if ranges.is_empty() {
let mut paths: Vec<String> = records
.iter()
.flat_map(|r| r.code_files())
.map(|f| f.path.clone())
.collect();
paths.sort();
paths.dedup();
paths
} else {
let mut by_file: Vec<(&String, &Vec<(usize, usize)>)> = ranges.iter().collect();
by_file.sort_by(|a, b| a.0.cmp(b.0));
by_file
.into_iter()
.map(|(path, rs)| {
let mut rs = rs.clone();
rs.sort();
let range_str = rs
.iter()
.map(|(s, e)| format!("L{}-{}", s, e))
.collect::<Vec<_>>()
.join(" ");
format!("{} {}", path, range_str)
})
.collect()
};
if entries.is_empty() {
return String::new();
}
let elided = entries.len() > MAX_FILES;
entries.truncate(MAX_FILES);
let mut joined = entries.join("; ");
if elided {
joined.push_str("; …");
}
joined
}
fn escape_cell(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('|', "\\|")
.replace(['\n', '\r'], " ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Change;
use crate::trace::model::{
ContributorKind, TraceContributor, TraceConversation, TraceFile, TraceRange, TraceRecord,
TraceVcs, TraceVcsType,
};
fn change(change_id: &str, commit_id: &str, desc: &str) -> Change {
Change {
change_id: change_id.into(),
commit_id: commit_id.into(),
description: desc.to_string(),
..Default::default()
}
}
fn ai_record(
revision: &str,
path: &str,
model: Option<&str>,
url: Option<&str>,
) -> TraceRecord {
TraceRecord {
timestamp: String::new(),
vcs: Some(TraceVcs {
vcs_type: TraceVcsType::Jj,
revision: revision.to_string(),
}),
tool_name: Some("claude-code".to_string()),
tool_version: None,
files: vec![TraceFile {
path: path.to_string(),
conversations: vec![TraceConversation {
url: url.map(|u| u.to_string()),
contributor: Some(TraceContributor {
kind: ContributorKind::Ai,
model_id: model.map(|m| m.to_string()),
}),
ranges: vec![TraceRange {
start_line: 1,
end_line: 8,
contributor: None,
}],
related: vec![],
}],
}],
}
}
#[test]
fn report_summary_and_table() {
let rec = ai_record(
"xqnktzmlworukplnyrropmtzylsuxxlv",
"src/main.rs",
Some("anthropic/claude-opus-4-8"),
Some("conv-url"),
);
let index = TraceIndex::build(&[rec]);
let changes = vec![
change("xqnktzml", "2d31c7f1", "feat: add navigation"),
change("aaaaaaaa", "bbbbbbbb", "chore: human edit"),
];
let md = build_report(&changes, &index, "2026-06-08 10:30");
assert!(md.contains("# Agent Trace Report"));
assert!(md.contains("- Generated: 2026-06-08 10:30"));
assert!(md.contains("- Scope: 2 loaded changes"));
assert!(md.contains("AI 1/2 (50%) · [AI] 1 · [AI?] 0"));
assert!(md.contains("- anthropic/claude-opus-4-8: 1 changes"));
assert!(md.contains("| xqnktzml | [AI] | feat: add navigation |"));
assert!(md.contains("src/main.rs L1-8"));
assert!(md.contains("conv-url"));
assert!(!md.contains("chore: human edit"));
assert!(!md.contains("## Orphaned traces"));
}
#[test]
fn report_includes_orphaned_section() {
let rec = ai_record(
"xqnktzmlworukplnyrropmtzylsuxxlv",
"src/gone.rs",
None,
Some("orphan-url"),
);
let index = TraceIndex::build(&[rec]);
let changes = vec![change("zzzzzzzz", "00000000", "unrelated change")];
let md = build_report(&changes, &index, "t");
assert!(md.contains("## Orphaned traces"));
assert!(
md.contains("| jj | xqnktzmlworukplnyrropmtzylsuxxlv | src/gone.rs | orphan-url |")
);
}
#[test]
fn report_no_orphaned_section_when_all_matched() {
let rec = ai_record(
"xqnktzmlworukplnyrropmtzylsuxxlv",
"src/main.rs",
None,
None,
);
let index = TraceIndex::build(&[rec]);
let changes = vec![change("xqnktzml", "2d31c7f1", "matched")];
let md = build_report(&changes, &index, "t");
assert!(!md.contains("## Orphaned traces"));
}
#[test]
fn report_no_ai_changes() {
let index = TraceIndex::default();
let changes = vec![change("aaaaaaaa", "bbbbbbbb", "human only")];
let md = build_report(&changes, &index, "t");
assert!(md.contains("AI 0/1 (0%)"));
assert!(md.contains("### By model\n- (none)"));
assert!(md.contains("No AI-attributed changes in the loaded set."));
assert!(!md.contains("| change |"), "no table when 0 AI changes");
}
#[test]
fn report_empty_index_counts_total() {
let index = TraceIndex::default();
let changes = vec![
change("aaaaaaaa", "bbbbbbbb", "one"),
change("cccccccc", "dddddddd", "two"),
];
let md = build_report(&changes, &index, "t");
assert!(md.contains("AI 0/2 (0%)"));
assert!(md.contains("- Scope: 2 loaded changes"));
}
#[test]
fn report_escapes_pipe_in_description() {
let rec = ai_record(
"xqnktzmlworukplnyrropmtzylsuxxlv",
"src/main.rs",
None,
None,
);
let index = TraceIndex::build(&[rec]);
let changes = vec![change("xqnktzml", "2d31c7f1", "feat: a | b table")];
let md = build_report(&changes, &index, "t");
assert!(md.contains("feat: a \\| b table"));
assert!(!md.contains("feat: a | b table"));
}
#[test]
fn report_escapes_newline_in_file_path() {
let rec = ai_record(
"xqnktzmlworukplnyrropmtzylsuxxlv",
"src/evil\nname.rs",
None,
None,
);
let index = TraceIndex::build(&[rec]);
let changes = vec![change("xqnktzml", "2d31c7f1", "weird path")];
let md = build_report(&changes, &index, "t");
assert!(md.contains("src/evil name.rs"), "newline collapsed: {md}");
for row in md.lines().filter(|l| l.starts_with('|')) {
assert!(row.ends_with('|'), "broken table row: {row}");
}
}
#[test]
fn report_joins_multiple_models() {
let mut rec = ai_record(
"xqnktzmlworukplnyrropmtzylsuxxlv",
"src/main.rs",
Some("anthropic/claude-opus-4-8"),
None,
);
rec.files[0].conversations.push(TraceConversation {
url: None,
contributor: Some(TraceContributor {
kind: ContributorKind::Ai,
model_id: Some("openai/gpt-4o".to_string()),
}),
ranges: vec![TraceRange {
start_line: 9,
end_line: 12,
contributor: None,
}],
related: vec![],
});
let index = TraceIndex::build(&[rec]);
let changes = vec![change("xqnktzml", "2d31c7f1", "multi-model")];
let md = build_report(&changes, &index, "t");
assert!(md.contains("anthropic/claude-opus-4-8, openai/gpt-4o"));
}
#[test]
fn report_excludes_graph_only_rows() {
let index = TraceIndex::default();
let mut graph = change("xxxxxxxx", "yyyyyyyy", "graph row");
graph.is_graph_only = true;
let changes = vec![change("aaaaaaaa", "bbbbbbbb", "real"), graph];
let md = build_report(&changes, &index, "t");
assert!(md.contains("- Scope: 1 loaded changes"));
assert!(md.contains("AI 0/1"));
}
}