use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub title: String,
pub severity: FindingSeverity,
pub file: String,
pub line: Option<usize>,
pub description: String,
pub suggested_fix: Option<String>,
pub agent: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Critical,
High,
Medium,
Low,
Info,
}
impl std::fmt::Display for FindingSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Critical => write!(f, "critical"),
Self::High => write!(f, "high"),
Self::Medium => write!(f, "medium"),
Self::Low => write!(f, "low"),
Self::Info => write!(f, "info"),
}
}
}
impl FindingSeverity {
const fn bumped(self) -> Self {
match self {
Self::Info => Self::Low,
Self::Low => Self::Medium,
Self::Medium => Self::High,
Self::High | Self::Critical => Self::Critical,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewReport {
pub agent: String,
pub partition_label: String,
pub mandate: String,
pub findings: Vec<Finding>,
pub completed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsolidatedReport {
pub title: String,
pub generated_at: String,
pub agent_count: usize,
pub total_findings: usize,
pub deduplicated_findings: usize,
pub groups: Vec<FindingGroup>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindingGroup {
pub canonical: Finding,
pub duplicates: Vec<Finding>,
pub consensus_count: usize,
pub effective_severity: FindingSeverity,
}
#[must_use]
pub fn similarity_score(a: &Finding, b: &Finding) -> f64 {
let mut score = 0.0;
if a.file == b.file {
score += 0.4;
}
if a.severity == b.severity {
score += 0.1;
}
score += 0.3 * jaccard_similarity(&a.title, &b.title);
score += 0.2 * jaccard_similarity(&a.description, &b.description);
score
}
fn jaccard_similarity(a: &str, b: &str) -> f64 {
let set_a: HashSet<String> = a.split_whitespace().map(str::to_lowercase).collect();
let set_b: HashSet<String> = b.split_whitespace().map(str::to_lowercase).collect();
if set_a.is_empty() && set_b.is_empty() {
return 0.0;
}
let intersection =
f64::from(u32::try_from(set_a.intersection(&set_b).count()).unwrap_or(u32::MAX));
let union = f64::from(u32::try_from(set_a.union(&set_b).count()).unwrap_or(u32::MAX));
intersection / union
}
const SIMILARITY_THRESHOLD: f64 = 0.5;
pub fn parse_reports(dir: &Path) -> Result<Vec<ReviewReport>> {
let mut reports = Vec::new();
let entries = std::fs::read_dir(dir)
.with_context(|| format!("failed to read directory {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_owned(),
None => continue,
};
if file_name.starts_with("review-findings-")
&& std::path::Path::new(&file_name)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
{
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let report: ReviewReport = serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
reports.push(report);
}
}
Ok(reports)
}
pub fn consolidate(reports: Vec<ReviewReport>) -> ConsolidatedReport {
let agent_count = reports
.iter()
.map(|r| r.agent.as_str())
.collect::<HashSet<_>>()
.len();
let all_findings: Vec<Finding> = reports
.into_iter()
.flat_map(|r| r.findings.into_iter())
.collect();
let total_findings = all_findings.len();
let mut groups: Vec<Vec<Finding>> = Vec::new();
for finding in all_findings {
let mut merged = false;
for group in &mut groups {
if similarity_score(&group[0], &finding) >= SIMILARITY_THRESHOLD {
group.push(finding.clone());
merged = true;
break;
}
}
if !merged {
groups.push(vec![finding]);
}
}
let mut finding_groups: Vec<FindingGroup> =
groups.into_iter().map(build_finding_group).collect();
finding_groups.sort_by(|a, b| {
a.effective_severity
.cmp(&b.effective_severity)
.then_with(|| b.consensus_count.cmp(&a.consensus_count))
});
let deduplicated_findings = finding_groups.len();
ConsolidatedReport {
title: "Consolidated Review Findings".to_string(),
generated_at: chrono::Utc::now().to_rfc3339(),
agent_count,
total_findings,
deduplicated_findings,
groups: finding_groups,
}
}
fn build_finding_group(mut members: Vec<Finding>) -> FindingGroup {
assert!(!members.is_empty());
members.sort_by_key(|b| std::cmp::Reverse(b.description.len()));
let canonical = members.remove(0);
let mut agents: HashSet<&str> = HashSet::new();
agents.insert(&canonical.agent);
for m in &members {
agents.insert(&m.agent);
}
let consensus_count = agents.len();
let effective_severity = if consensus_count >= 3 {
canonical.severity.bumped()
} else {
canonical.severity
};
FindingGroup {
canonical,
duplicates: members,
consensus_count,
effective_severity,
}
}
#[must_use]
pub fn generate_markdown_report(report: &ConsolidatedReport) -> String {
let mut md = String::new();
let _ = writeln!(md, "# {}\n", report.title);
let _ = writeln!(md, "Generated: {}\n", report.generated_at);
md.push_str("## Summary\n\n");
md.push_str("| Metric | Value |\n");
md.push_str("|--------|-------|\n");
let _ = writeln!(md, "| Agents | {} |", report.agent_count);
let _ = writeln!(md, "| Total findings | {} |", report.total_findings);
let _ = writeln!(
md,
"| After deduplication | {} |",
report.deduplicated_findings
);
md.push('\n');
let severity_order = [
FindingSeverity::Critical,
FindingSeverity::High,
FindingSeverity::Medium,
FindingSeverity::Low,
FindingSeverity::Info,
];
let grouped: HashMap<FindingSeverity, Vec<&FindingGroup>> = {
let mut map: HashMap<FindingSeverity, Vec<&FindingGroup>> = HashMap::new();
for g in &report.groups {
map.entry(g.effective_severity).or_default().push(g);
}
map
};
for severity in &severity_order {
let Some(groups) = grouped.get(severity) else {
continue;
};
let _ = writeln!(md, "## {} Findings\n", severity_header(*severity));
for (i, group) in groups.iter().enumerate() {
let f = &group.canonical;
let location = f
.line
.map_or_else(|| f.file.clone(), |line| format!("{}:{}", f.file, line));
let _ = writeln!(
md,
"### {}. {} ({})\n",
i + 1,
f.title,
group.effective_severity
);
let _ = writeln!(md, "**File:** `{location}`\n");
let _ = writeln!(
md,
"**Consensus:** {}/{} agents\n",
group.consensus_count,
group.consensus_count
);
let _ = writeln!(md, "{}\n", f.description);
if let Some(fix) = &f.suggested_fix {
let _ = writeln!(md, "**Suggested fix:** {fix}\n");
}
if !group.duplicates.is_empty() {
md.push_str("<details>\n<summary>Duplicate reports</summary>\n\n");
for dup in &group.duplicates {
let _ = writeln!(
md,
"- **{}** (agent: {}, severity: {}): {}",
dup.title, dup.agent, dup.severity, dup.description
);
}
md.push_str("\n</details>\n\n");
}
}
}
md
}
const fn severity_header(s: FindingSeverity) -> &'static str {
match s {
FindingSeverity::Critical => "Critical",
FindingSeverity::High => "High",
FindingSeverity::Medium => "Medium",
FindingSeverity::Low => "Low",
FindingSeverity::Info => "Informational",
}
}
#[must_use]
pub fn cross_reference_issues(
findings: &[FindingGroup],
existing_issues: &[String],
) -> Vec<FindingGroup> {
let existing_lower: HashSet<String> =
existing_issues.iter().map(|t| t.to_lowercase()).collect();
findings
.iter()
.filter(|g| !existing_lower.contains(&g.canonical.title.to_lowercase()))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn make_finding(
title: &str,
severity: FindingSeverity,
file: &str,
description: &str,
agent: &str,
) -> Finding {
Finding {
title: title.to_string(),
severity,
file: file.to_string(),
line: Some(42),
description: description.to_string(),
suggested_fix: Some("Fix it".to_string()),
agent: agent.to_string(),
}
}
fn make_report(agent: &str, findings: Vec<Finding>) -> ReviewReport {
ReviewReport {
agent: agent.to_string(),
partition_label: "partition-a".to_string(),
mandate: "review everything".to_string(),
findings,
completed_at: Some("2026-01-01T00:00:00Z".to_string()),
}
}
#[test]
fn similarity_identical_findings_high_score() {
let a = make_finding(
"SQL injection risk",
FindingSeverity::High,
"src/db.rs",
"User input passed directly to SQL query",
"agent-1",
);
let b = make_finding(
"SQL injection risk",
FindingSeverity::High,
"src/db.rs",
"User input passed directly to SQL query",
"agent-2",
);
let score = similarity_score(&a, &b);
assert!(
score > 0.9,
"identical findings should have score > 0.9, got {score}"
);
}
#[test]
fn similarity_same_file_similar_title_above_threshold() {
let a = make_finding(
"SQL injection vulnerability",
FindingSeverity::High,
"src/db.rs",
"Unsanitized input in query builder",
"agent-1",
);
let b = make_finding(
"Potential SQL injection",
FindingSeverity::Medium,
"src/db.rs",
"Input not sanitized before database query",
"agent-2",
);
let score = similarity_score(&a, &b);
assert!(
score >= SIMILARITY_THRESHOLD,
"same-file + similar-title should be >= threshold, got {score}"
);
}
#[test]
fn similarity_different_findings_low_score() {
let a = make_finding(
"SQL injection risk",
FindingSeverity::High,
"src/db.rs",
"User input passed directly to SQL query",
"agent-1",
);
let b = make_finding(
"Missing error handling",
FindingSeverity::Low,
"src/server.rs",
"unwrap() called on network response",
"agent-2",
);
let score = similarity_score(&a, &b);
assert!(
score < SIMILARITY_THRESHOLD,
"unrelated findings should be < threshold, got {score}"
);
}
#[test]
fn similarity_empty_strings() {
let a = make_finding("", FindingSeverity::Info, "", "", "agent-1");
let b = make_finding("", FindingSeverity::Info, "", "", "agent-2");
let score = similarity_score(&a, &b);
assert!(score >= 0.5, "empty-string findings: got {score}");
}
#[test]
fn consolidation_deduplicates_overlapping_findings() {
let f1 = make_finding(
"SQL injection risk",
FindingSeverity::High,
"src/db.rs",
"User input passed directly to SQL query without sanitization",
"agent-1",
);
let f2 = make_finding(
"SQL injection risk",
FindingSeverity::High,
"src/db.rs",
"Direct SQL interpolation of user input",
"agent-2",
);
let f3 = make_finding(
"Missing error handling",
FindingSeverity::Low,
"src/server.rs",
"unwrap() called on network response",
"agent-3",
);
let reports = vec![
make_report("agent-1", vec![f1]),
make_report("agent-2", vec![f2]),
make_report("agent-3", vec![f3]),
];
let consolidated = consolidate(reports);
assert_eq!(consolidated.total_findings, 3);
assert_eq!(
consolidated.deduplicated_findings, 2,
"should merge the two SQL findings into one group"
);
assert_eq!(consolidated.agent_count, 3);
}
#[test]
fn consolidation_severity_boost_three_agents() {
let reports: Vec<ReviewReport> = (1..=3)
.map(|i| {
make_finding(
"Hardcoded secret",
FindingSeverity::Medium,
"src/config.rs",
"API key is hardcoded in source code which is a security concern",
&format!("agent-{i}"),
)
})
.map(|f| {
let agent = f.agent.clone();
make_report(&agent, vec![f])
})
.collect();
let consolidated = consolidate(reports);
assert_eq!(consolidated.deduplicated_findings, 1);
let group = &consolidated.groups[0];
assert_eq!(group.consensus_count, 3);
assert_eq!(
group.effective_severity,
FindingSeverity::High,
"Medium + 3 agents should boost to High"
);
}
#[test]
fn consolidation_no_boost_two_agents() {
let f1 = make_finding(
"Hardcoded secret",
FindingSeverity::Medium,
"src/config.rs",
"API key is hardcoded in source code",
"agent-1",
);
let f2 = make_finding(
"Hardcoded secret",
FindingSeverity::Medium,
"src/config.rs",
"API key is hardcoded in source code",
"agent-2",
);
let reports = vec![
make_report("agent-1", vec![f1]),
make_report("agent-2", vec![f2]),
];
let consolidated = consolidate(reports);
let group = &consolidated.groups[0];
assert_eq!(group.consensus_count, 2);
assert_eq!(
group.effective_severity,
FindingSeverity::Medium,
"only 2 agents should not boost"
);
}
#[test]
fn consolidation_critical_stays_critical_on_boost() {
let reports: Vec<ReviewReport> = (1..=3)
.map(|i| {
make_finding(
"RCE via deserialization",
FindingSeverity::Critical,
"src/api.rs",
"Untrusted data deserialized without validation allowing remote code execution",
&format!("agent-{i}"),
)
})
.map(|f| {
let agent = f.agent.clone();
make_report(&agent, vec![f])
})
.collect();
let consolidated = consolidate(reports);
let group = &consolidated.groups[0];
assert_eq!(group.effective_severity, FindingSeverity::Critical);
}
#[test]
fn consolidation_sort_order() {
let critical = make_finding(
"RCE",
FindingSeverity::Critical,
"src/api.rs",
"Remote code execution",
"agent-1",
);
let low = make_finding(
"Style issue",
FindingSeverity::Low,
"src/fmt.rs",
"Inconsistent formatting",
"agent-1",
);
let high = make_finding(
"Auth bypass",
FindingSeverity::High,
"src/auth.rs",
"Authentication bypass",
"agent-1",
);
let reports = vec![make_report("agent-1", vec![low, critical, high])];
let consolidated = consolidate(reports);
let severities: Vec<FindingSeverity> = consolidated
.groups
.iter()
.map(|g| g.effective_severity)
.collect();
assert_eq!(
severities,
vec![
FindingSeverity::Critical,
FindingSeverity::High,
FindingSeverity::Low,
]
);
}
#[test]
fn markdown_report_contains_expected_sections() {
let f = make_finding(
"SQL injection",
FindingSeverity::High,
"src/db.rs",
"Unsanitized input",
"agent-1",
);
let report = ConsolidatedReport {
title: "Test Report".to_string(),
generated_at: "2026-01-01T00:00:00Z".to_string(),
agent_count: 1,
total_findings: 1,
deduplicated_findings: 1,
groups: vec![FindingGroup {
canonical: f,
duplicates: vec![],
consensus_count: 1,
effective_severity: FindingSeverity::High,
}],
};
let md = generate_markdown_report(&report);
assert!(md.contains("# Test Report"), "should have title");
assert!(md.contains("## Summary"), "should have summary section");
assert!(md.contains("| Agents | 1 |"), "should show agent count");
assert!(
md.contains("## High Findings"),
"should have severity section"
);
assert!(md.contains("SQL injection"), "should contain finding title");
assert!(md.contains("`src/db.rs:42`"), "should contain file:line");
assert!(
md.contains("Unsanitized input"),
"should contain description"
);
assert!(
md.contains("**Suggested fix:**"),
"should show suggested fix"
);
}
#[test]
fn markdown_report_shows_duplicates() {
let canonical = make_finding(
"Bug",
FindingSeverity::Medium,
"src/lib.rs",
"A bug was found here",
"agent-1",
);
let dup = make_finding(
"Bug",
FindingSeverity::Medium,
"src/lib.rs",
"Same bug",
"agent-2",
);
let report = ConsolidatedReport {
title: "Test".to_string(),
generated_at: "2026-01-01T00:00:00Z".to_string(),
agent_count: 2,
total_findings: 2,
deduplicated_findings: 1,
groups: vec![FindingGroup {
canonical,
duplicates: vec![dup],
consensus_count: 2,
effective_severity: FindingSeverity::Medium,
}],
};
let md = generate_markdown_report(&report);
assert!(
md.contains("Duplicate reports"),
"should show duplicates section"
);
assert!(
md.contains("agent-2"),
"should mention the duplicate's agent"
);
}
#[test]
fn serde_roundtrip_finding() {
let f = make_finding(
"Test",
FindingSeverity::High,
"src/main.rs",
"A test finding",
"agent-1",
);
let json = serde_json::to_string(&f).unwrap();
let deserialized: Finding = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.title, f.title);
assert_eq!(deserialized.severity, f.severity);
assert_eq!(deserialized.file, f.file);
assert_eq!(deserialized.line, f.line);
assert_eq!(deserialized.description, f.description);
assert_eq!(deserialized.agent, f.agent);
}
#[test]
fn serde_roundtrip_review_report() {
let report = make_report(
"agent-1",
vec![make_finding(
"Bug",
FindingSeverity::Low,
"f.rs",
"desc",
"agent-1",
)],
);
let json = serde_json::to_string(&report).unwrap();
let deserialized: ReviewReport = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.agent, report.agent);
assert_eq!(deserialized.findings.len(), 1);
}
#[test]
fn serde_roundtrip_consolidated_report() {
let f = make_finding("T", FindingSeverity::Info, "f.rs", "d", "a");
let report = ConsolidatedReport {
title: "R".to_string(),
generated_at: "2026-01-01T00:00:00Z".to_string(),
agent_count: 1,
total_findings: 1,
deduplicated_findings: 1,
groups: vec![FindingGroup {
canonical: f,
duplicates: vec![],
consensus_count: 1,
effective_severity: FindingSeverity::Info,
}],
};
let json = serde_json::to_string(&report).unwrap();
let deserialized: ConsolidatedReport = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.title, "R");
assert_eq!(deserialized.groups.len(), 1);
}
#[test]
fn serde_severity_rename_all() {
let json = serde_json::to_string(&FindingSeverity::Critical).unwrap();
assert_eq!(json, "\"critical\"");
let deser: FindingSeverity = serde_json::from_str("\"high\"").unwrap();
assert_eq!(deser, FindingSeverity::High);
}
#[test]
fn parse_reports_reads_matching_files() {
let dir = tempfile::tempdir().unwrap();
let report = make_report(
"agent-1",
vec![make_finding(
"Bug",
FindingSeverity::Low,
"f.rs",
"desc",
"agent-1",
)],
);
let path = dir.path().join("review-findings-agent-1.json");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(serde_json::to_string(&report).unwrap().as_bytes())
.unwrap();
let ignored = dir.path().join("other-file.json");
std::fs::File::create(&ignored)
.unwrap()
.write_all(b"{}")
.unwrap();
let reports = parse_reports(dir.path()).unwrap();
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].agent, "agent-1");
}
#[test]
fn parse_reports_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let reports = parse_reports(dir.path()).unwrap();
assert!(reports.is_empty());
}
#[test]
fn cross_reference_filters_matching_issues() {
let g1 = FindingGroup {
canonical: make_finding(
"SQL injection risk",
FindingSeverity::High,
"src/db.rs",
"desc",
"a",
),
duplicates: vec![],
consensus_count: 1,
effective_severity: FindingSeverity::High,
};
let g2 = FindingGroup {
canonical: make_finding(
"Missing tests",
FindingSeverity::Low,
"src/lib.rs",
"desc",
"a",
),
duplicates: vec![],
consensus_count: 1,
effective_severity: FindingSeverity::Low,
};
let existing = vec!["SQL Injection Risk".to_string()]; let filtered = cross_reference_issues(&[g1, g2], &existing);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].canonical.title, "Missing tests");
}
#[test]
fn cross_reference_no_existing_issues() {
let g = FindingGroup {
canonical: make_finding("Bug", FindingSeverity::Low, "f.rs", "d", "a"),
duplicates: vec![],
consensus_count: 1,
effective_severity: FindingSeverity::Low,
};
let filtered = cross_reference_issues(&[g], &[]);
assert_eq!(filtered.len(), 1);
}
#[test]
fn severity_bump_chain() {
assert_eq!(FindingSeverity::Info.bumped(), FindingSeverity::Low);
assert_eq!(FindingSeverity::Low.bumped(), FindingSeverity::Medium);
assert_eq!(FindingSeverity::Medium.bumped(), FindingSeverity::High);
assert_eq!(FindingSeverity::High.bumped(), FindingSeverity::Critical);
assert_eq!(
FindingSeverity::Critical.bumped(),
FindingSeverity::Critical
);
}
#[test]
fn severity_display_all_variants() {
assert_eq!(format!("{}", FindingSeverity::Critical), "critical");
assert_eq!(format!("{}", FindingSeverity::High), "high");
assert_eq!(format!("{}", FindingSeverity::Medium), "medium");
assert_eq!(format!("{}", FindingSeverity::Low), "low");
assert_eq!(format!("{}", FindingSeverity::Info), "info");
}
#[test]
fn severity_header_all_variants() {
assert_eq!(severity_header(FindingSeverity::Critical), "Critical");
assert_eq!(severity_header(FindingSeverity::High), "High");
assert_eq!(severity_header(FindingSeverity::Medium), "Medium");
assert_eq!(severity_header(FindingSeverity::Low), "Low");
assert_eq!(severity_header(FindingSeverity::Info), "Informational");
}
#[test]
fn generate_markdown_report_no_line_location() {
let findings = vec![Finding {
title: "Test finding".to_string(),
description: "A test".to_string(),
severity: FindingSeverity::Medium,
file: "src/lib.rs".to_string(),
line: None,
suggested_fix: None,
agent: "agent-1".to_string(),
}];
let reports = vec![ReviewReport {
agent: "agent-1".to_string(),
partition_label: "test".to_string(),
mandate: "test mandate".to_string(),
findings,
completed_at: None,
}];
let report = consolidate(reports);
let md = generate_markdown_report(&report);
assert!(md.contains("src/lib.rs"));
}
}