use crate::models::{Finding, FindingsSummary, HealthReport, Severity};
use crate::reporters;
use anyhow::Result;
use console::style;
use std::path::{Path, PathBuf};
fn normalize_path(path: &Path) -> String {
let path_str = path.display().to_string();
if let Some(stripped) = path_str.strip_prefix("/tmp/") {
if let Some(pos) = stripped.find('/') {
return stripped[pos + 1..].to_string();
}
}
if let Ok(home) = std::env::var("HOME") {
if let Some(stripped) = path_str.strip_prefix(&home) {
return stripped.trim_start_matches('/').to_string();
}
}
path_str
}
pub(crate) fn filter_findings(
findings: &mut Vec<Finding>,
severity: Option<Severity>,
top: Option<usize>,
) {
if let Some(min) = severity {
findings.retain(|f| f.severity >= min);
}
findings.sort_by(|a, b| b.severity.cmp(&a.severity));
if let Some(n) = top {
findings.truncate(n);
}
}
pub(crate) fn paginate_findings(
mut findings: Vec<Finding>,
page: usize,
per_page: usize,
) -> (Vec<Finding>, Option<(usize, usize, usize, usize)>) {
findings.sort_by(|a, b| {
(b.severity as u8)
.cmp(&(a.severity as u8))
.then_with(|| {
let a_file = a
.affected_files
.first()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let b_file = b
.affected_files
.first()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
a_file.cmp(&b_file)
})
.then_with(|| a.line_start.cmp(&b.line_start))
.then_with(|| a.detector.cmp(&b.detector))
.then_with(|| a.title.cmp(&b.title))
});
let displayed_findings = findings.len();
if per_page > 0 {
let total_pages = displayed_findings.div_ceil(per_page);
let page = page.max(1).min(total_pages.max(1));
let start = (page - 1) * per_page;
let end = (start + per_page).min(displayed_findings);
let paginated: Vec<_> = findings[start..end].to_vec();
(
paginated,
Some((page, total_pages, per_page, displayed_findings)),
)
} else {
(findings, None)
}
}
pub(crate) fn format_and_output(
report: &HealthReport,
all_findings: &[Finding],
format: reporters::OutputFormat,
output_path: Option<&Path>,
repotoire_dir: &Path,
pagination_info: Option<(usize, usize, usize, usize)>,
_displayed_findings: usize,
no_emoji: bool,
) -> Result<()> {
use reporters::OutputFormat;
let use_all = matches!(format, OutputFormat::Sarif | OutputFormat::Html | OutputFormat::Markdown)
|| (format == OutputFormat::Json && output_path.is_some());
let report_for_output = if use_all && !all_findings.is_empty() {
let mut full_report = report.clone();
full_report.findings = all_findings.to_vec();
full_report.findings_summary = FindingsSummary::from_findings(all_findings);
full_report
} else {
let mut r = report.clone();
r.findings_summary = FindingsSummary::from_findings(&r.findings);
r
};
let output_str = reporters::report_with_format(&report_for_output, format)?;
let write_to_file = output_path.is_some();
if write_to_file {
let out_path = if let Some(p) = output_path {
p.to_path_buf()
} else {
let ext = reporters::file_extension(format);
repotoire_dir.join(format!("report.{}", ext))
};
std::fs::write(&out_path, &output_str)?;
let file_icon = if no_emoji { "" } else { "📄 " };
eprintln!(
"\n{}Report written to: {}",
style(file_icon).bold(),
style(out_path.display()).cyan()
);
} else {
if !matches!(format, OutputFormat::Json | OutputFormat::Sarif) {
println!();
}
println!("{}", output_str);
}
cache_results(repotoire_dir, report, all_findings)?;
let quiet_mode = matches!(format, OutputFormat::Json | OutputFormat::Sarif | OutputFormat::Html | OutputFormat::Markdown)
|| output_path.is_some();
if let Some((current_page, total_pages, per_page, total)) =
pagination_info.filter(|_| !quiet_mode)
{
let page_icon = if no_emoji { "" } else { "📑 " };
println!(
"\n{}Showing page {} of {} ({} findings per page, {} total)",
style(page_icon).bold(),
style(current_page).cyan(),
style(total_pages).cyan(),
style(per_page).dim(),
style(total).cyan(),
);
if current_page < total_pages {
println!(
" Use {} to see more",
style(format!("--page {}", current_page + 1)).yellow()
);
}
}
Ok(())
}
pub(crate) fn check_fail_threshold(fail_on: Option<Severity>, report: &HealthReport) -> Result<()> {
if let Some(threshold) = fail_on {
let should_fail = match threshold {
Severity::Critical => report.findings_summary.critical > 0,
Severity::High => report.findings_summary.critical > 0 || report.findings_summary.high > 0,
Severity::Medium => {
report.findings_summary.critical > 0
|| report.findings_summary.high > 0
|| report.findings_summary.medium > 0
}
Severity::Low => {
report.findings_summary.critical > 0
|| report.findings_summary.high > 0
|| report.findings_summary.medium > 0
|| report.findings_summary.low > 0
}
Severity::Info => {
report.findings_summary.critical > 0
|| report.findings_summary.high > 0
|| report.findings_summary.medium > 0
|| report.findings_summary.low > 0
}
};
if should_fail {
anyhow::bail!("Failing due to --fail-on={} threshold", threshold);
}
}
Ok(())
}
pub fn load_cached_findings_from(path: &Path) -> Option<Vec<Finding>> {
load_findings_from_file(path)
}
pub fn load_cached_findings(repotoire_dir: &Path) -> Option<Vec<Finding>> {
load_findings_from_file(&repotoire_dir.join("last_findings.json"))
}
fn load_findings_from_file(path: &Path) -> Option<Vec<Finding>> {
let data = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&data).ok()?;
let cached_version = json.get("version").and_then(|v| v.as_str()).unwrap_or("");
if cached_version != env!("CARGO_PKG_VERSION") {
tracing::debug!(
"Findings cache version mismatch ({} vs {}), ignoring",
cached_version,
env!("CARGO_PKG_VERSION")
);
return None;
}
let findings_arr = json.get("findings")?.as_array()?;
let mut findings = Vec::new();
for f in findings_arr {
let severity = f.get("severity")?.as_str()?
.parse::<Severity>()
.unwrap_or(Severity::Info);
let affected_files: Vec<PathBuf> = f
.get("affected_files")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(PathBuf::from))
.collect()
})
.unwrap_or_default();
findings.push(Finding {
id: f
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
detector: f
.get("detector")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
title: f
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
description: f
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
severity,
affected_files,
line_start: f
.get("line_start")
.and_then(|v| v.as_u64())
.map(|v| v as u32),
line_end: f.get("line_end").and_then(|v| v.as_u64()).map(|v| v as u32),
suggested_fix: f
.get("suggested_fix")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
category: f
.get("category")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
cwe_id: f
.get("cwe_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
why_it_matters: f
.get("why_it_matters")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
confidence: f.get("confidence").and_then(|v| v.as_f64()),
..Default::default()
});
}
tracing::debug!(
"Loaded {} post-processed findings from {}",
findings.len(),
path.display()
);
Some(findings)
}
pub fn cache_results(
repotoire_dir: &Path,
report: &HealthReport,
all_findings: &[Finding],
) -> Result<()> {
use std::fs;
let findings_cache = repotoire_dir.join("last_findings.json");
let health_cache = repotoire_dir.join("last_health.json");
if findings_cache.exists() {
let _ = fs::copy(&findings_cache, repotoire_dir.join("baseline_findings.json"));
}
if health_cache.exists() {
let _ = fs::copy(&health_cache, repotoire_dir.join("baseline_health.json"));
}
let health_cache = repotoire_dir.join("last_health.json");
let health_json = serde_json::json!({
"health_score": report.overall_score,
"structure_score": report.structure_score,
"quality_score": report.quality_score,
"architecture_score": report.architecture_score,
"grade": report.grade,
"total_files": report.total_files,
"total_functions": report.total_functions,
"total_classes": report.total_classes,
"total_loc": report.total_loc,
});
fs::write(&health_cache, serde_json::to_string_pretty(&health_json)?)?;
let findings_cache = repotoire_dir.join("last_findings.json");
let findings_json = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"findings": all_findings.iter().map(|f| {
serde_json::json!({
"id": f.id,
"detector": f.detector,
"title": f.title,
"description": f.description,
"severity": f.severity.to_string(),
"affected_files": f.affected_files.iter().map(|p| normalize_path(p)).collect::<Vec<_>>(),
"line_start": f.line_start,
"line_end": f.line_end,
"suggested_fix": f.suggested_fix,
"category": f.category,
"cwe_id": f.cwe_id,
"why_it_matters": f.why_it_matters,
"confidence": f.confidence,
"threshold_metadata": &f.threshold_metadata,
})
}).collect::<Vec<_>>()
});
fs::write(
&findings_cache,
serde_json::to_string(&findings_json)?,
)?;
tracing::debug!("Cached analysis results to {}", repotoire_dir.display());
Ok(())
}