use crate::baseline::diff::BaselineScanReport;
use crate::baseline::gate::CiGateResult;
use crate::output::render_helpers::escape_table_cell;
use crate::scan::types::ScanSummary;
pub fn render(summary: &ScanSummary) -> String {
let mut output = String::new();
output.push_str("# RepoPilot Scan Report\n\n");
output.push_str("## Summary\n\n");
output.push_str(&format!("- **Path:** `{}`\n", summary.root_path.display()));
output.push_str(&format!("- **Files analyzed:** {}\n", summary.files_count));
output.push_str(&format!(
"- **Directories analyzed:** {}\n",
summary.directories_count
));
output.push_str(&format!(
"- **Lines of code:** {}\n\n",
summary.lines_of_code
));
if summary.skipped_files_count > 0 {
output.push_str(&format!(
"- **Files skipped:** {} ({} bytes)\n\n",
summary.skipped_files_count, summary.skipped_bytes
));
}
output.push_str("## Languages\n\n");
if summary.languages.is_empty() {
output.push_str("No languages detected.\n\n");
} else {
output.push_str("| Language | Files |\n");
output.push_str("| --- | ---: |\n");
for language in &summary.languages {
output.push_str(&format!(
"| {} | {} |\n",
escape_table_cell(&language.name),
language.files_count
));
}
output.push('\n');
}
output.push_str("## Findings\n\n");
if summary.findings.is_empty() {
output.push_str("No findings found.\n\n");
} else {
output.push_str("| Severity | Rule | Title | Evidence |\n");
output.push_str("| --- | --- | --- | --- |\n");
for finding in &summary.findings {
let evidence = finding
.evidence
.first()
.map(|evidence| {
format!(
"`{}:{}` — {}",
evidence.path.display(),
evidence.line_start,
evidence.snippet.trim()
)
})
.unwrap_or_else(|| "No evidence".to_string());
output.push_str(&format!(
"| {} | `{}` | {} | {} |\n",
finding.severity_label(),
finding.rule_id,
escape_table_cell(&finding.title),
escape_table_cell(&evidence)
));
}
output.push('\n');
}
output.push_str("## Markers\n\n");
let marker_findings: Vec<_> = summary
.findings
.iter()
.filter(|f| f.rule_id.starts_with("code-marker."))
.collect();
if marker_findings.is_empty() {
output.push_str("No TODO/FIXME/HACK markers found.\n");
} else {
output.push_str("| Type | File | Line | Snippet |\n");
output.push_str("| --- | --- | ---: | --- |\n");
for finding in marker_findings {
let kind = finding
.rule_id
.strip_prefix("code-marker.")
.unwrap_or("")
.to_uppercase();
if let Some(ev) = finding.evidence.first() {
output.push_str(&format!(
"| {} | `{}` | {} | {} |\n",
kind,
ev.path.display(),
ev.line_start,
escape_table_cell(ev.snippet.trim())
));
}
}
}
output
}
pub fn render_with_baseline(report: &BaselineScanReport, ci_gate: Option<&CiGateResult>) -> String {
let summary = &report.summary;
let mut output = String::new();
output.push_str("# RepoPilot Scan Report\n\n");
output.push_str("## Summary\n\n");
output.push_str(&format!("- **Path:** `{}`\n", summary.root_path.display()));
match &report.baseline_path {
Some(path) => output.push_str(&format!("- **Baseline:** `{}`\n", path.display())),
None => output.push_str("- **Baseline:** none (all findings treated as new)\n"),
}
output.push_str(&format!("- **Files analyzed:** {}\n", summary.files_count));
output.push_str(&format!(
"- **Directories analyzed:** {}\n",
summary.directories_count
));
output.push_str(&format!("- **Lines of code:** {}\n", summary.lines_of_code));
if summary.skipped_files_count > 0 {
output.push_str(&format!(
"- **Files skipped:** {} ({} bytes)\n",
summary.skipped_files_count, summary.skipped_bytes
));
}
output.push_str(&format!("- **New findings:** {}\n", report.new_count()));
output.push_str(&format!(
"- **Existing findings:** {}\n",
report.existing_count()
));
if let Some(ci_gate) = ci_gate {
let status = if ci_gate.passed() { "passed" } else { "failed" };
output.push_str(&format!(
"- **CI gate:** {status} (`{}`)\n",
ci_gate.label()
));
}
output.push('\n');
output.push_str("## Languages\n\n");
if summary.languages.is_empty() {
output.push_str("No languages detected.\n\n");
} else {
output.push_str("| Language | Files |\n");
output.push_str("| --- | ---: |\n");
for language in &summary.languages {
output.push_str(&format!(
"| {} | {} |\n",
escape_table_cell(&language.name),
language.files_count
));
}
output.push('\n');
}
output.push_str("## Findings\n\n");
if summary.findings.is_empty() {
output.push_str("No findings found.\n\n");
} else {
output.push_str("| Severity | Baseline | Rule | Title | Evidence |\n");
output.push_str("| --- | --- | --- | --- | --- |\n");
for (index, finding) in summary.findings.iter().enumerate() {
output.push_str(&format!(
"| {} | {} | `{}` | {} | {} |\n",
finding.severity_label(),
report.finding_status(index).lowercase_label(),
finding.rule_id,
escape_table_cell(&finding.title),
escape_table_cell(&render_evidence(finding))
));
}
output.push('\n');
}
output.push_str("## Markers\n\n");
let marker_findings: Vec<_> = summary
.findings
.iter()
.filter(|f| f.rule_id.starts_with("code-marker."))
.collect();
if marker_findings.is_empty() {
output.push_str("No TODO/FIXME/HACK markers found.\n");
} else {
output.push_str("| Type | Baseline | File | Line | Snippet |\n");
output.push_str("| --- | --- | --- | ---: | --- |\n");
for finding in marker_findings {
let kind = finding
.rule_id
.strip_prefix("code-marker.")
.unwrap_or("")
.to_uppercase();
let status = summary
.findings
.iter()
.position(|candidate| candidate == finding)
.map(|index| report.finding_status(index).lowercase_label())
.unwrap_or("new");
if let Some(ev) = finding.evidence.first() {
output.push_str(&format!(
"| {} | {} | `{}` | {} | {} |\n",
kind,
status,
ev.path.display(),
ev.line_start,
escape_table_cell(ev.snippet.trim())
));
}
}
}
output
}
fn render_evidence(finding: &crate::findings::types::Finding) -> String {
finding
.evidence
.first()
.map(|evidence| {
format!(
"`{}:{}` — {}",
evidence.path.display(),
evidence.line_start,
evidence.snippet.trim()
)
})
.unwrap_or_else(|| "No evidence".to_string())
}