use crate::baseline::diff::BaselineScanReport;
use crate::findings::types::{Finding, Severity};
use crate::output::FindingRenderLimit;
use crate::output::render_helpers::escape_table_cell;
use crate::output::report_stats::{
category_order, first_location, indexed_findings_for_rule, indexed_sorted_findings,
rule_ids_for_indexed_findings,
};
use crate::output::report_text::{category_label_rank, category_title, first_sentence};
use std::collections::BTreeMap;
use std::fmt::Write;
pub(crate) fn render_findings_index(
output: &mut String,
findings: &[Finding],
baseline: Option<&BaselineScanReport>,
findings_limit: FindingRenderLimit,
) {
output.push_str("## Findings Index\n\n");
if findings.is_empty() {
output.push_str("No findings found.\n\n");
return;
}
let indexed_findings = limited_indexed_findings(findings, findings_limit);
render_limit_note(
output,
findings.len(),
indexed_findings.len(),
findings_limit,
);
let rows = grouped_index_rows(&indexed_findings, baseline);
if baseline.is_some() {
output.push_str(
"| Category | Rule | Max severity | Count | New | Existing | First location |\n",
);
output.push_str("| --- | --- | --- | ---: | ---: | ---: | --- |\n");
for row in rows {
writeln!(
output,
"| {} | `{}` | {} | {} | {} | {} | {} |",
escape_table_cell(&row.category),
escape_table_cell(&row.rule_id),
row.severity.label(),
row.count,
row.new_count,
row.existing_count,
escape_table_cell(&row.first_location.unwrap_or_else(|| "n/a".to_string()))
)
.unwrap();
}
} else {
output.push_str("| Category | Rule | Max severity | Count | First location |\n");
output.push_str("| --- | --- | --- | ---: | --- |\n");
for row in rows {
writeln!(
output,
"| {} | `{}` | {} | {} | {} |",
escape_table_cell(&row.category),
escape_table_cell(&row.rule_id),
row.severity.label(),
row.count,
escape_table_cell(&row.first_location.unwrap_or_else(|| "n/a".to_string()))
)
.unwrap();
}
}
output.push('\n');
}
pub(crate) fn render_grouped_findings<F>(
output: &mut String,
findings: &[Finding],
findings_limit: FindingRenderLimit,
status_for: F,
) where
F: Fn(usize) -> Option<&'static str>,
{
output.push_str("## Findings\n\n");
if findings.is_empty() {
output.push_str("No findings found.\n");
return;
}
let indexed_findings = limited_indexed_findings(findings, findings_limit);
for category in category_order() {
let category_findings = indexed_findings
.iter()
.copied()
.filter(|(_, finding)| finding.category == category)
.collect::<Vec<_>>();
if category_findings.is_empty() {
continue;
}
writeln!(output, "### {}\n", category_title(&category)).unwrap();
let rules = rule_ids_for_indexed_findings(&category_findings);
for rule_id in rules {
let rule_findings = indexed_findings_for_rule(&category_findings, &rule_id);
writeln!(output, "#### `{rule_id}` ({})\n", rule_findings.len()).unwrap();
for (index, finding) in rule_findings {
render_finding_detail(output, finding, status_for(index));
}
}
}
}
fn render_finding_detail(output: &mut String, finding: &Finding, status: Option<&str>) {
writeln!(
output,
"- **[{}] {}**",
finding.severity_label(),
finding.title
)
.unwrap();
writeln!(output, " - Confidence: {}", finding.confidence_label()).unwrap();
writeln!(
output,
" - Priority: {} (risk {}/100)",
finding.risk.priority.label(),
finding.risk.score
)
.unwrap();
if let Some(reasons) = risk_reason_text(finding) {
writeln!(output, " - Risk signals: {reasons}").unwrap();
}
if let Some(status) = status {
writeln!(output, " - Baseline: {status}").unwrap();
}
if let Some(location) = first_location(finding) {
writeln!(output, " - Location: `{location}`").unwrap();
}
for evidence in &finding.evidence {
let location = if evidence.line_start > 0 {
format!("{}:{}", evidence.path.display(), evidence.line_start)
} else {
evidence.path.display().to_string()
};
let snippet = evidence.snippet.trim();
if snippet.is_empty() {
writeln!(output, " - Evidence: `{location}`").unwrap();
} else {
writeln!(
output,
" - Evidence: `{location}` - {}",
inline_snippet(snippet)
)
.unwrap();
}
}
if !finding.description.is_empty() {
writeln!(
output,
" - Context: {}",
first_sentence(&finding.description, 180)
)
.unwrap();
}
writeln!(
output,
" - Recommendation: {}",
first_sentence(finding.recommendation_or_default(), 220)
)
.unwrap();
if let Some(url) = &finding.docs_url {
writeln!(output, " - Docs: {url}").unwrap();
}
output.push('\n');
}
fn risk_reason_text(finding: &Finding) -> Option<String> {
let reasons = finding
.risk
.signals
.iter()
.take(4)
.map(|signal| format!("{:+} {}: {}", signal.weight, signal.label, signal.reason))
.collect::<Vec<_>>();
(!reasons.is_empty()).then(|| reasons.join(", "))
}
struct IndexRow {
category: String,
rule_id: String,
severity: Severity,
count: usize,
new_count: usize,
existing_count: usize,
first_location: Option<String>,
}
fn grouped_index_rows(
findings: &[(usize, &Finding)],
baseline: Option<&BaselineScanReport>,
) -> Vec<IndexRow> {
let mut rows: BTreeMap<(String, String), IndexRow> = BTreeMap::new();
for (index, finding) in findings.iter().copied() {
let key = (
finding.category.label().to_string(),
finding.rule_id.clone(),
);
let row = rows.entry(key).or_insert_with(|| IndexRow {
category: finding.category.label().to_string(),
rule_id: finding.rule_id.clone(),
severity: finding.severity,
count: 0,
new_count: 0,
existing_count: 0,
first_location: first_location(finding),
});
row.count += 1;
row.severity = row.severity.max(finding.severity);
if row.first_location.is_none() {
row.first_location = first_location(finding);
}
if let Some(report) = baseline {
match report.finding_status(index).lowercase_label() {
"new" => row.new_count += 1,
"existing" => row.existing_count += 1,
_ => {}
}
}
}
let mut rows = rows.into_values().collect::<Vec<_>>();
rows.sort_by(|left, right| {
category_label_rank(&left.category)
.cmp(&category_label_rank(&right.category))
.then_with(|| right.severity.cmp(&left.severity))
.then_with(|| right.count.cmp(&left.count))
.then_with(|| left.rule_id.cmp(&right.rule_id))
});
rows
}
fn limited_indexed_findings(
findings: &[Finding],
findings_limit: FindingRenderLimit,
) -> Vec<(usize, &Finding)> {
let shown = findings_limit.detailed_limit(findings.len());
indexed_sorted_findings(findings)
.into_iter()
.take(shown)
.collect()
}
fn render_limit_note(
output: &mut String,
total: usize,
shown: usize,
findings_limit: FindingRenderLimit,
) {
if matches!(findings_limit, FindingRenderLimit::Limit(_)) && shown < total {
writeln!(
output,
"Showing {shown} of {total} findings. Use `--max-findings none` to render all findings.\n"
)
.unwrap();
}
}
fn inline_snippet(snippet: &str) -> String {
snippet.replace('`', "'")
}