use crate::baseline::diff::BaselineStatus;
use crate::baseline::gate::CiGateResult;
use crate::findings::types::Finding;
use crate::review::diff::ChangedFile;
use crate::review::model::{ReviewReport, SeverityCounts};
use serde::Serialize;
pub fn render_console(report: &ReviewReport, ci_gate: Option<&CiGateResult>) -> String {
let mut output = String::new();
output.push_str("RepoPilot Review\n");
output.push_str(&format!("Path: {}\n", report.summary.root_path.display()));
output.push_str(&format!("Git root: {}\n", report.repo_root.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('\n');
output.push_str(&format!("Changed files: {}\n", report.changed_files.len()));
output.push_str(&format!("In-diff findings: {}\n", report.in_diff_count()));
output.push_str(&format!(
"Out-of-diff findings: {}\n",
report.out_of_diff_count()
));
output.push_str(&format!(
"New in-diff findings: {}\n",
report.new_in_diff_count()
));
output.push_str(&format!(
"Existing in-diff findings: {}\n",
report.existing_in_diff_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_str("\nChanged files:\n");
if report.changed_files.is_empty() {
output.push_str(" No changed files found\n");
} else {
for file in &report.changed_files {
output.push_str(&format!(
" {:?} {}{}\n",
file.status,
file.path.display(),
render_ranges_suffix(file)
));
}
}
render_findings_group(&mut output, "In-diff findings", &report.in_diff_findings());
render_findings_group(
&mut output,
"Out-of-diff findings",
&report.out_of_diff_findings(),
);
output
}
pub fn render_json(
report: &ReviewReport,
ci_gate: Option<&CiGateResult>,
) -> Result<String, serde_json::Error> {
let findings = report
.summary
.findings
.iter()
.enumerate()
.map(|(index, finding)| ReviewJsonFinding {
finding,
in_diff: report
.finding_status(index)
.map(|status| status.in_diff)
.unwrap_or(false),
baseline_status: report
.finding_status(index)
.and_then(|status| status.baseline_status),
})
.collect::<Vec<_>>();
let output = ReviewJsonReport {
root_path: report.summary.root_path.to_string_lossy().to_string(),
git_root: report.repo_root.to_string_lossy().to_string(),
files_count: report.summary.files_count,
directories_count: report.summary.directories_count,
lines_of_code: report.summary.lines_of_code,
changed_files: &report.changed_files,
review: ReviewJsonMetadata {
in_diff_findings: report.in_diff_count(),
out_of_diff_findings: report.out_of_diff_count(),
new_in_diff_findings: report.new_in_diff_count(),
existing_in_diff_findings: report.existing_in_diff_count(),
severity_counts: report.severity_counts(),
},
baseline: ReviewBaselineJsonMetadata {
path: report
.baseline_path
.as_ref()
.map(|path| path.to_string_lossy().to_string()),
},
ci_gate: ci_gate.map(ReviewCiGateJsonMetadata::from),
findings,
};
serde_json::to_string_pretty(&output)
}
pub fn render_markdown(report: &ReviewReport, ci_gate: Option<&CiGateResult>) -> String {
let mut output = String::new();
output.push_str("# RepoPilot Review Report\n\n");
output.push_str("## Summary\n\n");
output.push_str(&format!(
"- **Path:** `{}`\n",
report.summary.root_path.display()
));
output.push_str(&format!(
"- **Git root:** `{}`\n",
report.repo_root.display()
));
output.push_str(&format!(
"- **Changed files:** {}\n",
report.changed_files.len()
));
output.push_str(&format!(
"- **In-diff findings:** {}\n",
report.in_diff_count()
));
output.push_str(&format!(
"- **Out-of-diff findings:** {}\n",
report.out_of_diff_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_str("\n## Changed Files\n\n");
if report.changed_files.is_empty() {
output.push_str("No changed files found.\n\n");
} else {
output.push_str("| Status | Path | Ranges |\n");
output.push_str("| --- | --- | --- |\n");
for file in &report.changed_files {
output.push_str(&format!(
"| {:?} | `{}` | {} |\n",
file.status,
file.path.display(),
escape_table_cell(&render_ranges(file))
));
}
output.push('\n');
}
render_markdown_findings_group(
&mut output,
"In-Diff Findings",
report
.in_diff_findings()
.into_iter()
.map(|finding| (finding, status_for_finding(report, finding)))
.collect::<Vec<_>>()
.as_slice(),
);
render_markdown_findings_group(
&mut output,
"Out-Of-Diff Findings",
report
.out_of_diff_findings()
.into_iter()
.map(|finding| (finding, status_for_finding(report, finding)))
.collect::<Vec<_>>()
.as_slice(),
);
output
}
fn render_findings_group(output: &mut String, label: &str, findings: &[&Finding]) {
output.push_str(&format!("\n{label}: {}\n", findings.len()));
if findings.is_empty() {
return;
}
for finding in findings {
output.push_str(&format!(
" [{}] {} — {}\n",
finding.severity_label(),
finding.rule_id,
finding.title
));
for evidence in &finding.evidence {
output.push_str(&format!(
" Evidence: {}:{} — {}\n",
evidence.path.display(),
evidence.line_start,
evidence.snippet.trim()
));
}
}
}
fn render_markdown_findings_group(
output: &mut String,
label: &str,
findings: &[(&Finding, Option<BaselineStatus>)],
) {
output.push_str(&format!("## {label}\n\n"));
if findings.is_empty() {
output.push_str("No findings.\n\n");
return;
}
output.push_str("| Severity | Baseline | Rule | Title | Evidence |\n");
output.push_str("| --- | --- | --- | --- | --- |\n");
for (finding, status) in 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(),
status
.map(|status| format!("{status:?}"))
.unwrap_or_else(|| "n/a".to_string()),
finding.rule_id,
escape_table_cell(&finding.title),
escape_table_cell(&evidence)
));
}
output.push('\n');
}
fn status_for_finding(report: &ReviewReport, finding: &Finding) -> Option<BaselineStatus> {
report
.summary
.findings
.iter()
.position(|candidate| candidate == finding)
.and_then(|index| report.finding_status(index))
.and_then(|status| status.baseline_status)
}
fn render_ranges_suffix(file: &ChangedFile) -> String {
let ranges = render_ranges(file);
if ranges == "n/a" {
String::new()
} else {
format!(" ({ranges})")
}
}
fn render_ranges(file: &ChangedFile) -> String {
if file.ranges.is_empty() {
return "n/a".to_string();
}
file.ranges
.iter()
.map(|range| {
if range.start == range.end {
range.start.to_string()
} else {
format!("{}-{}", range.start, range.end)
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn escape_table_cell(value: &str) -> String {
value.replace('|', "\\|").replace('\n', " ")
}
#[derive(Serialize)]
struct ReviewJsonReport<'a> {
root_path: String,
git_root: String,
files_count: usize,
directories_count: usize,
lines_of_code: usize,
changed_files: &'a [ChangedFile],
review: ReviewJsonMetadata,
baseline: ReviewBaselineJsonMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
ci_gate: Option<ReviewCiGateJsonMetadata>,
findings: Vec<ReviewJsonFinding<'a>>,
}
#[derive(Serialize)]
struct ReviewJsonMetadata {
in_diff_findings: usize,
out_of_diff_findings: usize,
new_in_diff_findings: usize,
existing_in_diff_findings: usize,
severity_counts: SeverityCounts,
}
#[derive(Serialize)]
struct ReviewBaselineJsonMetadata {
path: Option<String>,
}
#[derive(Serialize)]
struct ReviewCiGateJsonMetadata {
fail_on: String,
status: &'static str,
failed_findings: usize,
}
impl From<&CiGateResult> for ReviewCiGateJsonMetadata {
fn from(result: &CiGateResult) -> Self {
Self {
fail_on: result.label(),
status: if result.passed() { "passed" } else { "failed" },
failed_findings: result.failed_findings,
}
}
}
#[derive(Serialize)]
struct ReviewJsonFinding<'a> {
#[serde(flatten)]
finding: &'a Finding,
in_diff: bool,
#[serde(skip_serializing_if = "Option::is_none")]
baseline_status: Option<BaselineStatus>,
}