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_key(|f| std::cmp::Reverse(f.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) struct FormatAndOutputArgs<'a> {
pub report: &'a HealthReport,
pub all_findings: &'a [Finding],
pub format: reporters::OutputFormat,
pub output_path: Option<&'a Path>,
pub repotoire_dir: &'a Path,
pub pagination_info: Option<(usize, usize, usize, usize)>,
pub displayed_findings: usize,
pub no_emoji: bool,
}
pub(crate) fn format_and_output(args: FormatAndOutputArgs<'_>) -> Result<()> {
let FormatAndOutputArgs {
report,
all_findings,
format,
output_path,
repotoire_dir,
pagination_info,
displayed_findings: _displayed_findings,
no_emoji,
} = args;
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>,
fail_on_tier: Option<crate::models::Tier>,
findings: &[Finding],
report: &HealthReport,
) -> Result<()> {
if fail_on.is_some() && fail_on_tier.is_some() {
anyhow::bail!("--fail-on and --fail-on-tier are mutually exclusive; use --fail-on-tier");
}
if let Some(tier) = fail_on_tier {
let tripped = findings.iter().filter(|f| f.tier >= tier).count();
if tripped > 0 {
anyhow::bail!(
"Failing due to --fail-on-tier={}: {} {}+ finding(s)",
tier,
tripped,
tier
);
}
return Ok(());
}
if let Some(threshold) = fail_on {
eprintln!("warning: --fail-on is deprecated; use --fail-on-tier blocking");
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 | 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(())
}
#[derive(Debug)]
pub enum CacheLoadOutcome {
Missing,
VersionMismatch {
#[allow(dead_code)]
cached: String,
#[allow(dead_code)]
current: &'static str,
},
Findings(Vec<Finding>),
Corrupt { path: PathBuf, reason: String },
}
impl CacheLoadOutcome {
pub fn user_warning(&self) -> Option<String> {
match self {
CacheLoadOutcome::Corrupt { path, reason } => Some(format!(
"⚠ Findings cache at {} appears stale or corrupt: {}. \
Run `repotoire analyze` to regenerate it.",
path.display(),
reason
)),
_ => None,
}
}
}
pub fn load_cached_findings_outcome_from(path: &Path) -> CacheLoadOutcome {
load_findings_from_file_outcome(path)
}
fn load_findings_from_file_outcome(path: &Path) -> CacheLoadOutcome {
let data = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return CacheLoadOutcome::Missing,
Err(e) => {
let reason = format!("failed to read file: {e}");
tracing::warn!("Findings cache at {}: {reason}", path.display());
return CacheLoadOutcome::Corrupt {
path: path.to_path_buf(),
reason,
};
}
};
let json: serde_json::Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
let reason = format!("invalid JSON: {e}");
tracing::warn!("Findings cache at {}: {reason}", path.display());
return CacheLoadOutcome::Corrupt {
path: path.to_path_buf(),
reason,
};
}
};
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 CacheLoadOutcome::VersionMismatch {
cached: cached_version.to_string(),
current: env!("CARGO_PKG_VERSION"),
};
}
let findings_value = match json.get("findings") {
Some(v) => v.clone(),
None => {
let reason = "missing `findings` field".to_string();
tracing::warn!("Findings cache at {}: {reason}", path.display());
return CacheLoadOutcome::Corrupt {
path: path.to_path_buf(),
reason,
};
}
};
let findings: Vec<Finding> = match serde_json::from_value(findings_value) {
Ok(v) => v,
Err(e) => {
let reason = format!("findings array failed to deserialize: {e}");
tracing::warn!("Findings cache at {}: {reason}", path.display());
return CacheLoadOutcome::Corrupt {
path: path.to_path_buf(),
reason,
};
}
};
let original_count = findings.len();
let (valid, invalid): (Vec<Finding>, Vec<Finding>) =
findings.into_iter().partition(|f| f.is_valid());
if !invalid.is_empty() {
for (i, bad) in invalid.iter().enumerate() {
tracing::warn!(
"Cache entry {} of {} at {} is invalid and will be skipped: {:?}",
i + 1,
original_count,
path.display(),
bad.validation_errors(),
);
}
if valid.is_empty() {
return CacheLoadOutcome::Corrupt {
path: path.to_path_buf(),
reason: format!("all {} findings failed semantic validation", original_count),
};
}
println!(
"⚠ {} of {} findings in cache at {} are invalid and were skipped. \
Re-run `repotoire analyze` to regenerate the cache.",
invalid.len(),
original_count,
path.display()
);
}
tracing::debug!(
"Loaded {} post-processed findings from {}",
valid.len(),
path.display()
);
CacheLoadOutcome::Findings(valid)
}
fn cache_findings_payload(all_findings: &[Finding]) -> serde_json::Value {
let normalized: Vec<Finding> = all_findings
.iter()
.map(|f| {
let mut clone = f.clone();
clone.affected_files = f
.affected_files
.iter()
.map(|p| PathBuf::from(normalize_path(p)))
.collect();
clone
})
.collect();
serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"findings": normalized,
})
}
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 baseline_findings = repotoire_dir.join("baseline_findings.json");
if findings_cache.exists() {
match load_cached_findings_outcome_from(&findings_cache) {
CacheLoadOutcome::Findings(_) => {
let _ = fs::copy(&findings_cache, &baseline_findings);
}
CacheLoadOutcome::Corrupt { .. } | CacheLoadOutcome::VersionMismatch { .. } => {
let _ = fs::remove_file(&baseline_findings);
}
CacheLoadOutcome::Missing => {
}
}
}
let health_cache = repotoire_dir.join("last_health.json");
if health_cache.exists() {
let _ = fs::copy(&health_cache, repotoire_dir.join("baseline_health.json"));
}
let health_json = serde_json::json!({
"health_score": report.overall_score,
"overall_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)?)?;
debug_assert!(
all_findings.iter().all(|f| f.is_valid()),
"cache_results received an invalid Finding; this is a pipeline bug. \
Offenders: {:?}",
all_findings
.iter()
.filter(|f| !f.is_valid())
.map(|f| (f.detector.clone(), f.title.clone(), f.validation_errors()))
.collect::<Vec<_>>(),
);
let mut findings_json = cache_findings_payload(all_findings);
if let Some(obj) = findings_json.as_object_mut() {
obj.insert(
"suppression_events".to_string(),
serde_json::to_value(&report.suppression_events)?,
);
obj.insert(
"suppressed_unaccounted_blocking_count".to_string(),
serde_json::json!(report.suppressed_unaccounted_blocking_count),
);
}
fs::write(&findings_cache, serde_json::to_string(&findings_json)?)?;
tracing::debug!("Cached analysis results to {}", repotoire_dir.display());
Ok(())
}
pub fn load_suppression_audit_from(path: &Path) -> (Vec<crate::models::SuppressionEvent>, usize) {
let Ok(data) = std::fs::read_to_string(path) else {
return (Vec::new(), 0);
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) else {
return (Vec::new(), 0);
};
let events = json
.get("suppression_events")
.cloned()
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
let count = json
.get("suppressed_unaccounted_blocking_count")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
(events, count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dual_branch::{
AlternativeBranch, BranchLabel, PredictionReason, PredictionReasonKind,
};
use std::collections::BTreeSet;
#[test]
fn cache_round_trips_dual_branch_payload() {
let mut finding = Finding {
id: "test-1".to_string(),
detector: "TestDetector".to_string(),
title: "title".to_string(),
severity: Severity::High,
affected_files: vec![PathBuf::from("src/test.py")],
line_start: Some(10),
..Default::default()
};
finding.alternative_branch = Some(AlternativeBranch {
label: BranchLabel::Benign,
severity: Severity::Info,
title: "Benign interpretation".to_string(),
description: "This may be intentional".to_string(),
suggested_fix: Some("Annotate as safe".to_string()),
});
finding.prediction_reasons = vec![PredictionReason {
kind: PredictionReasonKind::KeywordArgument {
name: "verify".to_string(),
value: "False".to_string(),
},
weight: -0.4,
note: "verify=False on a TLS call leans RealBug.".to_string(),
}];
let payload = cache_findings_payload(std::slice::from_ref(&finding));
let findings: Vec<Finding> = serde_json::from_value(payload["findings"].clone())
.expect("findings array round-trips through serde");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].alternative_branch, finding.alternative_branch);
assert_eq!(findings[0].prediction_reasons, finding.prediction_reasons);
}
#[test]
fn cache_payload_field_set_matches_finding_serialization() {
let finding = Finding::default();
let payload = cache_findings_payload(std::slice::from_ref(&finding));
let cache_keys: BTreeSet<String> = payload["findings"][0]
.as_object()
.expect("finding entry is a JSON object")
.keys()
.cloned()
.collect();
let direct_value = serde_json::to_value(&finding).expect("Finding serializes via serde");
let direct_keys: BTreeSet<String> = direct_value
.as_object()
.expect("Finding is a JSON object")
.keys()
.cloned()
.collect();
assert_eq!(
cache_keys, direct_keys,
"cache payload must persist every Finding field; \
a divergence here means someone hand-rolled JSON \
again instead of letting serde do it"
);
}
#[test]
fn cache_round_trips_previously_dropped_scalar_fields() {
let finding = Finding {
id: "test-scalars".to_string(),
detector: "TestDetector".to_string(),
deterministic: true,
estimated_effort: Some("low".to_string()),
original_severity: Some(Severity::Critical),
status: crate::models::FindingStatus::Baselined,
attribution: crate::models::Attribution::InChangedNode,
..Default::default()
};
let payload = cache_findings_payload(std::slice::from_ref(&finding));
let findings: Vec<Finding> = serde_json::from_value(payload["findings"].clone())
.expect("findings array round-trips");
assert!(findings[0].deterministic);
assert_eq!(findings[0].estimated_effort.as_deref(), Some("low"));
assert_eq!(findings[0].original_severity, Some(Severity::Critical));
assert_eq!(findings[0].status, crate::models::FindingStatus::Baselined);
assert_eq!(
findings[0].attribution,
crate::models::Attribution::InChangedNode
);
}
#[test]
fn cache_normalizes_affected_files_paths() {
let finding = Finding {
affected_files: vec![PathBuf::from("/tmp/abc123/repo/src/main.py")],
..Default::default()
};
let payload = cache_findings_payload(&[finding]);
let affected = payload["findings"][0]["affected_files"]
.as_array()
.expect("affected_files is an array");
assert_eq!(affected.len(), 1);
assert_eq!(affected[0].as_str(), Some("repo/src/main.py"));
}
#[test]
fn load_findings_rejects_fully_invalid_cache() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("last_findings.json");
let mut f = std::fs::File::create(&path).expect("create");
write!(
f,
r#"{{"version": "{}", "findings": [{{"bogus": "x"}}]}}"#,
env!("CARGO_PKG_VERSION")
)
.expect("write");
drop(f);
let outcome = load_findings_from_file_outcome(&path);
match outcome {
CacheLoadOutcome::Corrupt { reason, .. } => {
assert!(
reason.contains("semantic validation"),
"expected semantic-validation reason; got {reason}"
);
}
other => panic!("expected Corrupt for fully-invalid cache; got {other:?}"),
}
}
#[test]
fn load_findings_filters_invalid_keeps_valid() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("last_findings.json");
let mut f = std::fs::File::create(&path).expect("create");
write!(
f,
r#"{{"version": "{ver}", "findings": [
{{"bogus": "x"}},
{{"id": "f1", "detector": "Det", "title": "T",
"affected_files": ["src/main.py"], "line_start": 1}}
]}}"#,
ver = env!("CARGO_PKG_VERSION"),
)
.expect("write");
drop(f);
match load_findings_from_file_outcome(&path) {
CacheLoadOutcome::Findings(v) => {
assert_eq!(v.len(), 1);
assert_eq!(v[0].detector, "Det");
}
other => panic!("expected Findings for partial-valid cache; got {other:?}"),
}
}
#[test]
fn load_findings_classifies_invalid_json_as_corrupt() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("last_findings.json");
std::fs::write(&path, "not even json {{").expect("write");
match load_findings_from_file_outcome(&path) {
CacheLoadOutcome::Corrupt { path: p, reason } => {
assert_eq!(p, path);
assert!(
reason.contains("invalid JSON") || reason.contains("JSON"),
"expected JSON-mention reason; got {reason}"
);
let outcome = load_findings_from_file_outcome(&path);
let msg = outcome
.user_warning()
.expect("Corrupt outcome must have user_warning");
assert!(msg.contains("last_findings.json"));
assert!(msg.contains("repotoire analyze"));
}
other => panic!("expected Corrupt; got {other:?}"),
}
}
#[test]
fn load_findings_classifies_missing_correctly() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("does-not-exist.json");
assert!(matches!(
load_findings_from_file_outcome(&path),
CacheLoadOutcome::Missing
));
}
fn empty_health_report() -> crate::models::HealthReport {
crate::models::HealthReport {
overall_score: 0.0,
grade: crate::models::Grade::F,
structure_score: 0.0,
quality_score: 0.0,
architecture_score: None,
findings: vec![],
findings_summary: crate::models::FindingsSummary::from_findings(&[]),
total_files: 0,
total_functions: 0,
total_classes: 0,
total_loc: 0,
suppression_events: Vec::new(),
suppressed_unaccounted_blocking_count: 0,
}
}
#[test]
fn cache_results_drops_baseline_when_last_findings_corrupt() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let repotoire_dir = dir.path();
let last_findings = repotoire_dir.join("last_findings.json");
let mut f = std::fs::File::create(&last_findings).expect("create last");
write!(
f,
r#"{{"version": "{}", "findings": [{{"bogus": "x"}}]}}"#,
env!("CARGO_PKG_VERSION")
)
.expect("write last");
drop(f);
let baseline = repotoire_dir.join("baseline_findings.json");
let mut f = std::fs::File::create(&baseline).expect("create baseline");
write!(
f,
r#"{{"version": "{}", "findings": []}}"#,
env!("CARGO_PKG_VERSION")
)
.expect("write baseline");
drop(f);
assert!(baseline.exists(), "precondition: baseline present");
let report = empty_health_report();
cache_results(repotoire_dir, &report, &[]).expect("cache_results");
assert!(
!baseline.exists(),
"corrupt last_findings.json must cause baseline_findings.json to be removed, \
not overwritten with the corrupt snapshot"
);
}
#[test]
fn cache_results_snapshots_baseline_when_last_findings_valid() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let repotoire_dir = dir.path();
let last_findings = repotoire_dir.join("last_findings.json");
let mut f = std::fs::File::create(&last_findings).expect("create last");
write!(
f,
r#"{{"version": "{}", "findings": [
{{"id": "f1", "detector": "Det", "title": "T",
"affected_files": ["src/main.py"], "line_start": 1}}
]}}"#,
env!("CARGO_PKG_VERSION")
)
.expect("write last");
drop(f);
let baseline = repotoire_dir.join("baseline_findings.json");
assert!(!baseline.exists(), "precondition: no prior baseline");
let report = empty_health_report();
cache_results(repotoire_dir, &report, &[]).expect("cache_results");
assert!(
baseline.exists(),
"valid last_findings.json must produce a baseline snapshot"
);
match load_findings_from_file_outcome(&baseline) {
CacheLoadOutcome::Findings(v) => assert_eq!(v.len(), 1),
other => panic!("expected valid Findings in snapshot baseline; got {other:?}"),
}
}
}