use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::finding::{
EvidenceClass, Finding, FindingId, Origin, RuleId, Severity, evidence_class_for_rule,
};
use crate::health_score::ScoreContext;
pub const SCHEMA_VERSION: u32 = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineFinding {
pub id: FindingId,
pub rule: RuleId,
pub severity: Severity,
pub evidence_class: EvidenceClass,
pub origin: Origin,
pub file: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub schema_version: u32,
pub judge_version: String,
pub rule_revisions: HashMap<String, u32>,
pub total_loc: usize,
#[serde(default)]
pub score_context: Option<ScoreContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_surface_size: Option<HashMap<String, usize>>,
pub findings: Vec<BaselineFinding>,
}
impl Baseline {
pub fn new(
findings: &[Finding],
_legacy_commit: String,
rule_revisions: HashMap<String, u32>,
total_loc: usize,
score_context: ScoreContext,
) -> Self {
Self {
schema_version: SCHEMA_VERSION,
judge_version: env!("CARGO_PKG_VERSION").to_string(),
rule_revisions,
total_loc,
score_context: Some(score_context),
api_surface_size: None,
findings: findings
.iter()
.map(|finding| BaselineFinding {
id: finding.id.clone(),
rule: finding.rule.clone(),
severity: finding.severity,
evidence_class: finding.evidence_class,
origin: finding.origin,
file: finding.location.file.clone(),
})
.collect(),
}
}
pub fn with_api_surface_size(mut self, size: HashMap<String, usize>) -> Self {
self.api_surface_size = Some(size);
self
}
pub fn relativize_paths(&mut self, workspace_root: &Path) {
for finding in &mut self.findings {
let Ok(relative) = finding.file.strip_prefix(workspace_root) else {
continue;
};
let relative = relative.to_path_buf();
if let (Some(absolute_text), Some(relative_text)) =
(finding.file.to_str(), relative.to_str())
{
finding.id =
FindingId::from(finding.id.as_str().replace(absolute_text, relative_text));
}
finding.file = relative;
}
}
}
#[derive(Debug)]
pub enum BaselineError {
Io(PathBuf, std::io::Error),
Serialize(serde_json::Error),
Deserialize(PathBuf, serde_json::Error),
UnsupportedSchemaVersion {
path: PathBuf,
found: Option<u64>,
},
}
pub(crate) enum StoreErrorRef<'a> {
Io(&'a Path, &'a std::io::Error),
Serialize(&'a serde_json::Error),
Deserialize(&'a Path, &'a serde_json::Error),
UnsupportedSchemaVersion {
path: &'a Path,
found: Option<u64>,
},
}
pub(crate) fn fmt_store_error(
f: &mut std::fmt::Formatter<'_>,
noun: &str,
schema_version: u32,
resave_command: &str,
error: StoreErrorRef<'_>,
) -> std::fmt::Result {
match error {
StoreErrorRef::Io(path, err) => write!(f, "{}: {err}", path.display()),
StoreErrorRef::Serialize(err) => write!(f, "failed to serialize {noun}: {err}"),
StoreErrorRef::Deserialize(path, err) => {
write!(f, "{}: failed to parse {noun}: {err}", path.display())
}
StoreErrorRef::UnsupportedSchemaVersion { path, found } => {
match found {
Some(found) => write!(
f,
"{}: unsupported {noun} schema_version {found} (this judge supports version {schema_version})",
path.display()
)?,
None => write!(
f,
"{}: {noun} has no schema_version (this judge supports version {schema_version})",
path.display()
)?,
}
write!(f, " — re-save it with a matching judge via `{resave_command}`")
}
}
}
impl std::fmt::Display for BaselineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let error = match self {
Self::Io(path, err) => StoreErrorRef::Io(path, err),
Self::Serialize(err) => StoreErrorRef::Serialize(err),
Self::Deserialize(path, err) => StoreErrorRef::Deserialize(path, err),
Self::UnsupportedSchemaVersion { path, found } => {
StoreErrorRef::UnsupportedSchemaVersion {
path,
found: *found,
}
}
};
fmt_store_error(
f,
"baseline",
SCHEMA_VERSION,
"cargo judge --save-baseline",
error,
)
}
}
impl std::error::Error for BaselineError {}
pub(crate) fn schema_version_of(value: &serde_json::Value) -> Option<u64> {
value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
}
pub(crate) fn read_json_value<E>(
path: &Path,
io_err: impl FnOnce(PathBuf, std::io::Error) -> E,
parse_err: impl FnOnce(PathBuf, serde_json::Error) -> E,
) -> Result<serde_json::Value, E> {
let content = std::fs::read_to_string(path).map_err(|err| io_err(path.to_path_buf(), err))?;
serde_json::from_str(&content).map_err(|err| parse_err(path.to_path_buf(), err))
}
pub(crate) fn deserialize_value<T, E>(
path: &Path,
value: serde_json::Value,
err: impl FnOnce(PathBuf, serde_json::Error) -> E,
) -> Result<T, E>
where
T: serde::de::DeserializeOwned,
{
serde_json::from_value(value).map_err(|inner| err(path.to_path_buf(), inner))
}
pub(crate) fn write_json_pretty<T, E>(
path: &Path,
value: &T,
io_err: impl Fn(PathBuf, std::io::Error) -> E,
serialize_err: impl FnOnce(serde_json::Error) -> E,
) -> Result<(), E>
where
T: Serialize,
{
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| io_err(parent.to_path_buf(), err))?;
}
let json = serde_json::to_string_pretty(value).map_err(serialize_err)?;
std::fs::write(path, json).map_err(|err| io_err(path.to_path_buf(), err))
}
pub fn save(path: &Path, baseline: &Baseline) -> Result<(), BaselineError> {
write_json_pretty(path, baseline, BaselineError::Io, BaselineError::Serialize)
}
pub fn load(path: &Path) -> Result<Baseline, BaselineError> {
let value = read_json_value(path, BaselineError::Io, BaselineError::Deserialize)?;
migrate(path, value)
}
fn migrate(path: &Path, mut value: serde_json::Value) -> Result<Baseline, BaselineError> {
let found = schema_version_of(&value);
match found {
Some(1) | Some(2) => {
if let Some(findings) = value
.get_mut("findings")
.and_then(serde_json::Value::as_array_mut)
{
for finding in findings {
let Some(finding) = finding.as_object_mut() else {
continue;
};
if finding.get("evidence_class").is_none() {
finding.remove("confidence");
let class = finding
.get("rule")
.and_then(serde_json::Value::as_str)
.map_or(EvidenceClass::Heuristic, |rule| {
evidence_class_for_rule(&RuleId::from(rule))
});
finding.insert(
"evidence_class".to_string(),
serde_json::to_value(class).map_err(BaselineError::Serialize)?,
);
}
}
}
value
.as_object_mut()
.expect("baseline must be an object")
.remove("commit");
value["schema_version"] = serde_json::json!(SCHEMA_VERSION);
deserialize_value(path, value, BaselineError::Deserialize)
}
Some(version) if version == u64::from(SCHEMA_VERSION) => {
deserialize_value(path, value, BaselineError::Deserialize)
}
_ => Err(BaselineError::UnsupportedSchemaVersion {
path: path.to_path_buf(),
found,
}),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Delta {
pub introduced: Vec<Finding>,
pub resolved: Vec<BaselineFinding>,
pub severity_changed: Vec<SeverityChange>,
pub unchanged_count: usize,
#[serde(skip)]
pub code_introduced: Vec<Finding>,
#[serde(skip)]
pub rule_introduced: Vec<Finding>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SeverityChange {
pub before: BaselineFinding,
pub after: Finding,
}
impl SeverityChange {
pub fn transition(&self) -> (&RuleId, Severity, Severity) {
(&self.after.rule, self.before.severity, self.after.severity)
}
}
pub fn partition_gating<'a, I>(findings: I) -> (Vec<&'a Finding>, Vec<&'a Finding>)
where
I: IntoIterator<Item = &'a Finding>,
{
findings.into_iter().partition(|finding| finding.is_gating())
}
pub fn diff(
current: &[Finding],
baseline: &Baseline,
_touched_files: &HashSet<PathBuf>,
_current_rule_revisions: &HashMap<String, u32>,
) -> Delta {
let known = baseline
.findings
.iter()
.map(|finding| (finding.id.as_str(), finding))
.collect::<HashMap<_, _>>();
let mut introduced = Vec::new();
let mut severity_changed = Vec::new();
let mut unchanged_count = 0;
for finding in current {
match known.get(finding.id.as_str()) {
Some(previous) if previous.severity == finding.severity => unchanged_count += 1,
Some(previous) => severity_changed.push(SeverityChange {
before: (*previous).clone(),
after: finding.clone(),
}),
None => introduced.push(finding.clone()),
}
}
let current_ids: HashSet<&str> = current.iter().map(|f| f.id.as_str()).collect();
let resolved = baseline
.findings
.iter()
.filter(|finding| !current_ids.contains(finding.id.as_str()))
.cloned()
.collect();
Delta {
code_introduced: introduced.clone(),
rule_introduced: Vec::new(),
introduced,
resolved,
severity_changed,
unchanged_count,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Pass,
Fail,
}
impl Verdict {
pub fn label(self) -> &'static str {
match self {
Verdict::Pass => "pass",
Verdict::Fail => "fail",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TriVerdict {
Pass,
Warn,
Fail,
}
impl Delta {
pub fn verdict(&self) -> Verdict {
if self
.introduced
.iter()
.filter(|finding| finding.is_gating())
.all(|finding| finding.severity == crate::finding::Severity::Info)
{
Verdict::Pass
} else {
Verdict::Fail
}
}
pub fn tri_verdict(&self) -> TriVerdict {
let mut has_warn = false;
for finding in self.introduced.iter().filter(|f| f.is_gating()) {
match finding.severity {
crate::finding::Severity::Fail => return TriVerdict::Fail,
crate::finding::Severity::Warn => has_warn = true,
crate::finding::Severity::Info => {}
}
}
if has_warn {
TriVerdict::Warn
} else {
TriVerdict::Pass
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{Location, OneBasedLine, Severity};
fn finding(id: &str, file: &str) -> Finding {
Finding {
id: id.into(),
rule: "duplicate-code".into(),
severity: Severity::Warn,
location: Location {
file: PathBuf::from(file),
line: OneBasedLine::FIRST,
item_path: "f".to_string(),
},
evidence_class: EvidenceClass::DerivedFact,
origin: Origin::Code,
evidence: None,
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
}
}
fn baseline_with(findings: &[Finding]) -> Baseline {
Baseline::new(
findings,
"abc123".to_string(),
HashMap::from([("duplicate-code".to_string(), 1)]),
1000,
ScoreContext::from_profiles(&[]),
)
}
fn current_revisions() -> HashMap<String, u32> {
HashMap::from([("duplicate-code".to_string(), 1)])
}
#[test]
fn save_and_load_round_trips() {
let dir = crate::test_util::TempDir::new("baseline-round-trip");
let path = dir.join(".judge/baseline.json");
let baseline = baseline_with(&[finding("a", "src/a.rs")]);
save(&path, &baseline).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded.findings.len(), 1);
assert_eq!(loaded.findings[0].id, "a");
assert_eq!(loaded.score_context, baseline.score_context);
}
fn write_baseline_json(name: &str, json: &str) -> (crate::test_util::TempDir, PathBuf) {
let dir = crate::test_util::TempDir::new(name);
let path = dir.join("baseline.json");
std::fs::write(&path, json).unwrap();
(dir, path)
}
#[test]
fn baseline_without_score_context_still_loads() {
let (_dir, path) = write_baseline_json(
"baseline-no-score-context",
r#"{
"schema_version": 2,
"judge_version": "0.1.0",
"commit": "abc123",
"rule_revisions": {},
"total_loc": 1000,
"findings": []
}"#,
);
let baseline = load(&path).unwrap();
assert!(baseline.score_context.is_none());
}
#[test]
fn baseline_without_api_surface_size_still_loads() {
let (_dir, path) = write_baseline_json(
"baseline-no-api-surface-size",
r#"{
"schema_version": 2,
"judge_version": "0.1.0",
"commit": "abc123",
"rule_revisions": {},
"total_loc": 1000,
"findings": []
}"#,
);
let baseline = load(&path).unwrap();
assert!(baseline.api_surface_size.is_none());
}
#[test]
fn v1_baseline_with_confidence_migrates_to_evidence_classes() {
let (_dir, path) = write_baseline_json(
"baseline-v1-migration",
r#"{
"schema_version": 1,
"judge_version": "0.1.0",
"commit": "abc123",
"rule_revisions": {},
"total_loc": 1000,
"findings": [
{
"id": "duplicate-code:src/a.rs:f:0-20",
"rule": "duplicate-code",
"severity": "warn",
"origin": "code",
"confidence": 1.0,
"file": "src/a.rs"
},
{
"id": "unused-pub-workspace:src/b.rs:g",
"rule": "unused-pub-workspace",
"severity": "warn",
"origin": "code",
"confidence": 1.0,
"file": "src/b.rs"
},
{
"id": "phantom-crate:app:dep",
"rule": "phantom-crate",
"severity": "fail",
"origin": "code",
"confidence": 0.9,
"file": "Cargo.toml"
},
{
"id": "hotspot:src/c.rs",
"rule": "hotspot",
"severity": "info",
"origin": "code",
"confidence": 1.0,
"file": "src/c.rs"
},
{
"id": "some-retired-rule:src/d.rs",
"rule": "some-retired-rule",
"severity": "warn",
"origin": "code",
"confidence": 0.7,
"file": "src/d.rs"
}
]
}"#,
);
let baseline = load(&path).unwrap();
assert_eq!(baseline.schema_version, SCHEMA_VERSION);
let classes: Vec<EvidenceClass> = baseline
.findings
.iter()
.map(|finding| finding.evidence_class)
.collect();
assert_eq!(
classes,
vec![
EvidenceClass::DerivedFact,
EvidenceClass::BoundedSemantic,
EvidenceClass::ExternalMeasurement,
EvidenceClass::Heuristic,
EvidenceClass::Heuristic,
]
);
}
#[test]
fn baseline_with_future_schema_version_is_rejected() {
let (_dir, path) = write_baseline_json(
"baseline-future-schema",
r#"{
"schema_version": 4,
"judge_version": "9.9.9",
"commit": "abc123",
"rule_revisions": {},
"total_loc": 1000,
"findings": []
}"#,
);
let err = load(&path).unwrap_err();
assert!(matches!(
err,
BaselineError::UnsupportedSchemaVersion { found: Some(4), .. }
));
let message = err.to_string();
assert!(message.contains("schema_version 4"), "{message}");
assert!(message.contains("supports version 3"), "{message}");
}
#[test]
fn baseline_without_schema_version_is_rejected() {
let (_dir, path) = write_baseline_json(
"baseline-missing-schema",
r#"{
"judge_version": "0.1.0",
"commit": "abc123",
"rule_revisions": {},
"total_loc": 1000,
"findings": []
}"#,
);
let err = load(&path).unwrap_err();
assert!(matches!(
err,
BaselineError::UnsupportedSchemaVersion { found: None, .. }
));
assert!(err.to_string().contains("no schema_version"), "{err}");
}
#[test]
fn known_finding_is_unchanged_not_introduced() {
let baseline = baseline_with(&[finding("a", "src/a.rs")]);
let current = [finding("a", "src/a.rs")];
let delta = diff(¤t, &baseline, &HashSet::new(), ¤t_revisions());
assert_eq!(delta.unchanged_count, 1);
assert!(delta.code_introduced.is_empty());
assert!(delta.rule_introduced.is_empty());
assert_eq!(delta.verdict(), Verdict::Pass);
}
#[test]
fn new_finding_in_a_touched_file_is_code_introduced() {
let baseline = baseline_with(&[]);
let current = [finding("new", "src/a.rs")];
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let delta = diff(¤t, &baseline, &touched, ¤t_revisions());
assert_eq!(delta.code_introduced.len(), 1);
assert!(delta.rule_introduced.is_empty());
assert_eq!(delta.verdict(), Verdict::Fail);
}
#[test]
fn new_finding_is_introduced_even_when_a_rule_revision_changed() {
let baseline = baseline_with(&[]);
let current = [finding("new", "src/a.rs")];
let mut revised = current_revisions();
revised.insert("duplicate-code".to_string(), 2);
let delta = diff(¤t, &baseline, &HashSet::new(), &revised);
assert_eq!(delta.introduced.len(), 1);
assert!(delta.rule_introduced.is_empty());
assert_eq!(delta.verdict(), Verdict::Fail);
}
#[test]
fn finding_missing_from_the_current_run_is_resolved() {
let baseline = baseline_with(&[finding("gone", "src/a.rs")]);
let delta = diff(&[], &baseline, &HashSet::new(), ¤t_revisions());
assert_eq!(delta.resolved.len(), 1);
assert_eq!(delta.resolved[0].id, "gone");
}
#[test]
fn new_finding_without_a_rule_change_fails_even_in_an_untouched_file() {
let baseline = baseline_with(&[]);
let current = [finding("new", "src/a.rs")];
let delta = diff(¤t, &baseline, &HashSet::new(), ¤t_revisions());
assert_eq!(delta.code_introduced.len(), 1);
assert!(delta.rule_introduced.is_empty());
assert_eq!(delta.verdict(), Verdict::Fail);
}
#[test]
fn changed_rule_keeps_an_identical_finding_unchanged() {
let baseline = baseline_with(&[finding("known", "src/a.rs")]);
let current = [finding("known", "src/a.rs")];
let revised = HashMap::from([("duplicate-code".to_string(), 2)]);
let delta = diff(¤t, &baseline, &HashSet::new(), &revised);
assert_eq!(delta.unchanged_count, 1);
assert!(delta.rule_introduced.is_empty());
}
#[test]
fn informational_code_finding_does_not_fail_the_verdict() {
let baseline = baseline_with(&[]);
let mut info = finding("info", "src/a.rs");
info.severity = Severity::Info;
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let delta = diff(&[info], &baseline, &touched, ¤t_revisions());
assert_eq!(delta.code_introduced.len(), 1);
assert_eq!(delta.verdict(), Verdict::Pass);
}
#[test]
fn tri_verdict_is_warn_for_warn_only_code_introduced_findings() {
let baseline = baseline_with(&[]);
let current = [finding("new", "src/a.rs")];
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let delta = diff(¤t, &baseline, &touched, ¤t_revisions());
assert_eq!(delta.tri_verdict(), TriVerdict::Warn);
}
#[test]
fn tri_verdict_is_fail_when_any_code_introduced_finding_fails() {
let baseline = baseline_with(&[]);
let mut fail = finding("fail", "src/a.rs");
fail.severity = Severity::Fail;
let warn = finding("warn", "src/a.rs");
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let delta = diff(&[fail, warn], &baseline, &touched, ¤t_revisions());
assert_eq!(delta.tri_verdict(), TriVerdict::Fail);
}
#[test]
fn heuristic_code_introduced_findings_are_advisory_and_do_not_fail() {
let baseline = baseline_with(&[]);
let mut heuristic_fail = finding("hotspot:src/a.rs", "src/a.rs");
heuristic_fail.rule = "hotspot".into();
heuristic_fail.severity = Severity::Fail;
heuristic_fail.evidence_class = EvidenceClass::Heuristic;
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let delta = diff(&[heuristic_fail], &baseline, &touched, ¤t_revisions());
assert_eq!(delta.code_introduced.len(), 1);
assert_eq!(delta.verdict(), Verdict::Pass);
assert_eq!(delta.tri_verdict(), TriVerdict::Pass);
}
#[test]
fn gating_fail_still_fails_alongside_advisory_findings() {
let baseline = baseline_with(&[]);
let mut derived_fail = finding("fail", "src/a.rs");
derived_fail.severity = Severity::Fail;
let mut heuristic = finding("hotspot:src/a.rs", "src/a.rs");
heuristic.rule = "hotspot".into();
heuristic.evidence_class = EvidenceClass::Heuristic;
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let delta = diff(
&[derived_fail, heuristic],
&baseline,
&touched,
¤t_revisions(),
);
assert_eq!(delta.verdict(), Verdict::Fail);
assert_eq!(delta.tri_verdict(), TriVerdict::Fail);
}
#[test]
fn tri_verdict_is_pass_for_empty_or_info_only_code_introduced_findings() {
let baseline = baseline_with(&[]);
assert_eq!(
diff(&[], &baseline, &HashSet::new(), ¤t_revisions()).tri_verdict(),
TriVerdict::Pass
);
let mut info = finding("info", "src/a.rs");
info.severity = Severity::Info;
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let delta = diff(&[info], &baseline, &touched, ¤t_revisions());
assert_eq!(delta.tri_verdict(), TriVerdict::Pass);
}
}