use std::collections::HashMap;
use std::path::{Path, PathBuf};
use padlock_core::findings::{Report, Severity};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::filter::FilterArgs;
use crate::paths::collect_layouts;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineEntry {
pub struct_name: String,
pub source_file: Option<String>,
pub score: f64,
pub worst_severity: String,
pub wasted_bytes: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Baseline {
pub padlock_version: String,
pub structs: Vec<BaselineEntry>,
}
#[derive(Debug, Serialize)]
pub struct RegressionEntry {
pub struct_name: String,
pub source_file: Option<String>,
pub reason: String,
pub baseline_score: Option<f64>,
pub current_score: f64,
}
#[derive(Debug, Serialize)]
pub struct CheckResult {
pub regressions: Vec<RegressionEntry>,
pub new_improvements: usize,
pub resolved: usize,
pub unchanged: usize,
pub passed: bool,
}
pub fn run(
paths: &[PathBuf],
baseline_path: Option<&Path>,
save_baseline: bool,
json: bool,
target: Option<String>,
filter: &FilterArgs,
) -> anyhow::Result<()> {
let cfg = Config::for_path(paths.first().map(|p| p.as_path()).unwrap_or(Path::new(".")));
let mut filter = filter.clone();
filter.apply_config_defaults(&cfg);
let (mut layouts, analyzed, _skipped) = collect_layouts(paths)?;
let arch_name_override = target.as_deref().or(cfg.arch_override.as_deref());
if let Some(arch_name) = arch_name_override {
let arch = padlock_core::arch::arch_by_name(arch_name).unwrap_or_else(|| {
eprintln!("padlock: warning: unknown target/arch '{arch_name}', ignoring override");
layouts
.first()
.map(|l| l.arch)
.unwrap_or(&padlock_core::arch::X86_64_SYSV)
});
for layout in &mut layouts {
layout.arch = arch;
}
}
layouts.retain(|l| {
!cfg.is_ignored(&l.name)
&& !l
.source_file
.as_deref()
.map(|f| cfg.is_path_excluded(f))
.unwrap_or(false)
});
filter.apply_to_layouts(&mut layouts)?;
let mut report = Report::from_layouts(&layouts);
report.analyzed_paths = analyzed;
filter.apply_to_report(&mut report);
if save_baseline {
let path = baseline_path.unwrap_or(Path::new(".padlock-baseline.json"));
let entries: Vec<BaselineEntry> = report
.structs
.iter()
.map(|sr| BaselineEntry {
struct_name: sr.struct_name.clone(),
source_file: sr.source_file.clone(),
score: sr.score,
worst_severity: worst_severity_str(&sr.findings),
wasted_bytes: sr.wasted_bytes,
})
.collect();
let baseline = Baseline {
padlock_version: env!("CARGO_PKG_VERSION").to_string(),
structs: entries,
};
let serialized = serde_json::to_string_pretty(&baseline)?;
std::fs::write(path, serialized)?;
if !json {
println!(
"padlock: baseline saved to {} ({} structs)",
path.display(),
baseline.structs.len()
);
}
return Ok(());
}
let baseline = match baseline_path {
Some(p) => {
let text = std::fs::read_to_string(p)
.map_err(|_| anyhow::anyhow!("baseline file not found: {}", p.display()))?;
let b: Baseline = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("failed to parse baseline: {e}"))?;
b
}
None => {
let has_high = report
.structs
.iter()
.any(|s| worst_severity_str(&s.findings) == "high");
if json {
let result = CheckResult {
regressions: vec![],
new_improvements: 0,
resolved: 0,
unchanged: report.structs.len(),
passed: !has_high,
};
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
print!("{}", padlock_output::render_report(&report));
if has_high {
eprintln!(
"\npadlock: check failed — High severity findings present (use --save-baseline to establish a baseline)"
);
}
}
if has_high {
std::process::exit(1);
}
return Ok(());
}
};
let baseline_map: HashMap<(String, Option<String>), &BaselineEntry> = baseline
.structs
.iter()
.map(|e| ((e.struct_name.clone(), e.source_file.clone()), e))
.collect();
let mut seen_baseline_keys: std::collections::HashSet<(String, Option<String>)> =
std::collections::HashSet::new();
let mut regressions: Vec<RegressionEntry> = Vec::new();
let mut improvements = 0usize;
let mut unchanged = 0usize;
for sr in &report.structs {
let key = (sr.struct_name.clone(), sr.source_file.clone());
let current_worst = worst_severity_str(&sr.findings);
match baseline_map.get(&key) {
Some(base) => {
seen_baseline_keys.insert(key);
let sev_regressed =
severity_rank(¤t_worst) > severity_rank(&base.worst_severity);
let score_regressed = sr.score < base.score - 1.0;
if sev_regressed || score_regressed {
let reason = if sev_regressed {
format!(
"severity increased: {} → {}",
base.worst_severity, current_worst
)
} else {
format!("score dropped: {:.0} → {:.0}", base.score, sr.score)
};
regressions.push(RegressionEntry {
struct_name: sr.struct_name.clone(),
source_file: sr.source_file.clone(),
reason,
baseline_score: Some(base.score),
current_score: sr.score,
});
} else if sr.score > base.score + 1.0 {
improvements += 1;
} else {
unchanged += 1;
}
}
None => {
if current_worst == "high" {
regressions.push(RegressionEntry {
struct_name: sr.struct_name.clone(),
source_file: sr.source_file.clone(),
reason: "new struct with High severity finding".to_string(),
baseline_score: None,
current_score: sr.score,
});
} else {
unchanged += 1;
}
}
}
}
let disappeared = baseline_map
.keys()
.filter(|k| !seen_baseline_keys.contains(*k))
.count();
let passed = regressions.is_empty();
let result = CheckResult {
regressions,
new_improvements: improvements,
resolved: improvements + disappeared,
unchanged,
passed,
};
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
render_check_result(&result);
}
if !passed {
std::process::exit(1);
}
Ok(())
}
fn render_check_result(result: &CheckResult) {
let drift_summary = format!(
"{} new, {} resolved, {} unchanged",
result.regressions.len(),
result.resolved,
result.unchanged,
);
if result.passed {
println!("padlock check passed — no regressions ({drift_summary})");
} else {
eprintln!(
"padlock check FAILED — {} regression(s) [{drift_summary}]\n",
result.regressions.len()
);
for r in &result.regressions {
let loc = r
.source_file
.as_deref()
.map(|f| format!(" ({})", f))
.unwrap_or_default();
eprintln!(" [REGRESSION] {}{}", r.struct_name, loc);
eprintln!(" {}", r.reason);
if let Some(base) = r.baseline_score {
eprintln!(" score: {:.0} → {:.0}", base, r.current_score);
}
}
}
}
fn worst_severity_str(findings: &[padlock_core::findings::Finding]) -> String {
let mut worst = 0u8;
for f in findings {
let rank = match f.severity() {
Severity::High => 3,
Severity::Medium => 2,
Severity::Low => 1,
};
worst = worst.max(rank);
}
match worst {
3 => "high".to_string(),
2 => "medium".to_string(),
1 => "low".to_string(),
_ => "none".to_string(),
}
}
fn severity_rank(s: &str) -> u8 {
match s {
"high" => 3,
"medium" => 2,
"low" => 1,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worst_severity_empty_is_none() {
assert_eq!(worst_severity_str(&[]), "none");
}
#[test]
fn severity_rank_ordering() {
assert!(severity_rank("high") > severity_rank("medium"));
assert!(severity_rank("medium") > severity_rank("low"));
assert!(severity_rank("low") > severity_rank("none"));
}
#[test]
fn baseline_round_trips_json() {
let b = Baseline {
padlock_version: "0.4.0".into(),
structs: vec![BaselineEntry {
struct_name: "Foo".into(),
source_file: Some("foo.rs".into()),
score: 90.0,
worst_severity: "low".into(),
wasted_bytes: 2,
}],
};
let json = serde_json::to_string(&b).unwrap();
let b2: Baseline = serde_json::from_str(&json).unwrap();
assert_eq!(b2.structs[0].struct_name, "Foo");
assert_eq!(b2.structs[0].score, 90.0);
}
}