use std::path::Path;
use crate::engine::baseline::{self as generic, BaselineConfig, Fingerprintable};
use super::findings::Finding;
use super::CodeAuditResult;
const BASELINE_KEY: &str = "audit";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuditBaselineMetadata {
pub outliers_count: usize,
pub alignment_score: Option<f32>,
pub known_outliers: Vec<String>,
}
struct AuditFinding<'a>(&'a Finding);
impl Fingerprintable for AuditFinding<'_> {
fn fingerprint(&self) -> String {
format!("{}::{}::{:?}", self.0.convention, self.0.file, self.0.kind)
}
fn description(&self) -> String {
self.0.description.clone()
}
fn context_label(&self) -> String {
self.0.convention.clone()
}
}
pub type AuditBaseline = generic::Baseline<AuditBaselineMetadata>;
pub type BaselineComparison = generic::Comparison;
pub type NewFinding = generic::NewItem;
pub fn save_baseline(result: &CodeAuditResult) -> Result<std::path::PathBuf, String> {
let source = Path::new(&result.source_path);
let config = BaselineConfig::new(source, BASELINE_KEY);
let known_outliers: Vec<String> = result
.conventions
.iter()
.flat_map(|c| c.outliers.iter().map(|o| o.file.clone()))
.collect();
let metadata = AuditBaselineMetadata {
outliers_count: known_outliers.len(),
alignment_score: result.summary.alignment_score,
known_outliers,
};
let items: Vec<AuditFinding> = result.findings.iter().map(AuditFinding).collect();
generic::save(&config, &result.component_id, &items, metadata).map_err(|e| e.message)
}
pub fn save_baseline_scoped(
result: &CodeAuditResult,
changed_files: &[String],
) -> Result<std::path::PathBuf, String> {
let source = Path::new(&result.source_path);
let config = BaselineConfig::new(source, BASELINE_KEY);
let known_outliers: Vec<String> = result
.conventions
.iter()
.flat_map(|c| c.outliers.iter().map(|o| o.file.clone()))
.collect();
let metadata = AuditBaselineMetadata {
outliers_count: known_outliers.len(),
alignment_score: result.summary.alignment_score,
known_outliers,
};
let items: Vec<AuditFinding> = result.findings.iter().map(AuditFinding).collect();
generic::save_scoped(
&config,
&result.component_id,
&items,
metadata,
changed_files,
file_from_audit_fingerprint,
)
.map_err(|e| e.message)
}
fn file_from_audit_fingerprint(fingerprint: &str) -> Option<String> {
let first = fingerprint.find("::")?;
let rest = &fingerprint[first + 2..];
let last = rest.rfind("::")?;
Some(rest[..last].to_string())
}
pub fn load_baseline(source_path: &Path) -> Option<AuditBaseline> {
let config = BaselineConfig::new(source_path, BASELINE_KEY);
generic::load::<AuditBaselineMetadata>(&config)
.ok()
.flatten()
}
pub fn compare(result: &CodeAuditResult, baseline: &AuditBaseline) -> BaselineComparison {
let items: Vec<AuditFinding> = result.findings.iter().map(AuditFinding).collect();
generic::compare(&items, baseline)
}
pub fn load_baseline_from_ref(source_path: &str, git_ref: &str) -> Option<AuditBaseline> {
generic::load_from_git_ref::<AuditBaselineMetadata>(source_path, git_ref, BASELINE_KEY)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::code_audit::conventions::AuditFinding;
use crate::code_audit::findings::{Finding, Severity};
use crate::code_audit::{AuditSummary, CodeAuditResult};
fn make_finding(convention: &str, file: &str, description: &str) -> Finding {
Finding {
convention: convention.to_string(),
severity: Severity::Warning,
file: file.to_string(),
description: description.to_string(),
suggestion: String::new(),
kind: AuditFinding::MissingMethod,
}
}
fn make_result(findings: Vec<Finding>, test_name: &str) -> CodeAuditResult {
let dir = std::env::temp_dir().join(format!("homeboy_baseline_{}", test_name));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::create_dir_all(&dir);
CodeAuditResult {
component_id: "test".to_string(),
source_path: dir.to_str().unwrap().to_string(),
summary: AuditSummary {
files_scanned: 10,
conventions_detected: 1,
outliers_found: findings.len(),
alignment_score: Some(0.8),
files_skipped: 0,
warnings: vec![],
},
conventions: vec![],
directory_conventions: vec![],
findings,
duplicate_groups: vec![],
}
}
#[test]
fn save_and_load_baseline() {
let result = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
],
"save_load",
);
let path = save_baseline(&result).unwrap();
assert!(path.exists());
let loaded = load_baseline(Path::new(&result.source_path)).unwrap();
assert_eq!(loaded.context_id, "test");
assert_eq!(loaded.item_count, 2);
assert_eq!(loaded.known_fingerprints.len(), 2);
let _ = std::fs::remove_dir_all(Path::new(&result.source_path));
}
#[test]
fn compare_no_new_drift() {
let result = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
],
"no_new_drift",
);
let _ = save_baseline(&result).unwrap();
let baseline = load_baseline(Path::new(&result.source_path)).unwrap();
let comparison = compare(&result, &baseline);
assert!(!comparison.drift_increased);
assert!(comparison.new_items.is_empty());
assert!(comparison.resolved_fingerprints.is_empty());
assert_eq!(comparison.delta, 0);
let _ = std::fs::remove_dir_all(Path::new(&result.source_path));
}
#[test]
fn compare_detects_new_drift() {
let result_original = make_result(
vec![make_finding("Flow", "a.php", "Missing method: execute")],
"new_drift",
);
let _ = save_baseline(&result_original).unwrap();
let baseline = load_baseline(Path::new(&result_original.source_path)).unwrap();
let mut current = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "c.php", "Missing method: register"),
],
"new_drift_current",
);
current.source_path = result_original.source_path.clone();
let comparison = compare(¤t, &baseline);
assert!(comparison.drift_increased);
assert_eq!(comparison.new_items.len(), 1);
assert_eq!(
comparison.new_items[0].fingerprint,
"Flow::c.php::MissingMethod"
);
assert_eq!(comparison.delta, 1);
let _ = std::fs::remove_dir_all(Path::new(&result_original.source_path));
}
#[test]
fn compare_detects_resolved_drift() {
let result_original = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
],
"resolved_drift",
);
let _ = save_baseline(&result_original).unwrap();
let baseline = load_baseline(Path::new(&result_original.source_path)).unwrap();
let mut current = make_result(
vec![make_finding("Flow", "a.php", "Missing method: execute")],
"resolved_drift_current",
);
current.source_path = result_original.source_path.clone();
let comparison = compare(¤t, &baseline);
assert!(!comparison.drift_increased);
assert!(comparison.new_items.is_empty());
assert_eq!(comparison.resolved_fingerprints.len(), 1);
assert_eq!(comparison.delta, -1);
let _ = std::fs::remove_dir_all(Path::new(&result_original.source_path));
}
#[test]
fn compare_new_and_resolved_simultaneously() {
let result_original = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
],
"new_and_resolved",
);
let _ = save_baseline(&result_original).unwrap();
let baseline = load_baseline(Path::new(&result_original.source_path)).unwrap();
let mut current = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "c.php", "Missing method: register"),
],
"new_and_resolved_current",
);
current.source_path = result_original.source_path.clone();
let comparison = compare(¤t, &baseline);
assert!(comparison.drift_increased);
assert_eq!(comparison.new_items.len(), 1);
assert_eq!(comparison.resolved_fingerprints.len(), 1);
assert_eq!(comparison.delta, 0);
let _ = std::fs::remove_dir_all(Path::new(&result_original.source_path));
}
#[test]
fn auto_ratchet_saves_updated_baseline_after_resolving_findings() {
let result_original = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
make_finding("Flow", "c.php", "Missing method: register"),
],
"auto_ratchet",
);
let _ = save_baseline(&result_original).unwrap();
let baseline_before = load_baseline(Path::new(&result_original.source_path)).unwrap();
assert_eq!(baseline_before.item_count, 3);
let mut current = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
],
"auto_ratchet_current",
);
current.source_path = result_original.source_path.clone();
let comparison = compare(¤t, &baseline_before);
assert!(!comparison.drift_increased);
assert_eq!(comparison.resolved_fingerprints.len(), 1);
let _ = save_baseline(¤t).unwrap();
let baseline_after = load_baseline(Path::new(¤t.source_path)).unwrap();
assert_eq!(baseline_after.item_count, 2);
let recheck = compare(¤t, &baseline_after);
assert!(!recheck.drift_increased);
assert!(recheck.resolved_fingerprints.is_empty());
assert_eq!(recheck.delta, 0);
let _ = std::fs::remove_dir_all(Path::new(&result_original.source_path));
}
#[test]
fn auto_ratchet_preserves_baseline_when_no_findings_resolved() {
let result = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
],
"auto_ratchet_no_change",
);
let _ = save_baseline(&result).unwrap();
let baseline_before = load_baseline(Path::new(&result.source_path)).unwrap();
let comparison = compare(&result, &baseline_before);
assert!(comparison.resolved_fingerprints.is_empty());
assert!(!comparison.drift_increased);
let _ = std::fs::remove_dir_all(Path::new(&result.source_path));
}
#[test]
fn no_baseline_returns_none() {
let result = load_baseline(Path::new("/nonexistent/path"));
assert!(result.is_none());
}
#[test]
fn audit_metadata_roundtrips() {
let result = make_result(
vec![make_finding("Flow", "a.php", "Missing method")],
"metadata_roundtrip",
);
let _ = save_baseline(&result).unwrap();
let loaded = load_baseline(Path::new(&result.source_path)).unwrap();
assert_eq!(loaded.metadata.alignment_score, Some(0.8));
let _ = std::fs::remove_dir_all(Path::new(&result.source_path));
}
#[test]
fn fingerprint_is_stable() {
let f1 = make_finding("Flow", "a.php", "Missing method: execute");
let f2 = make_finding("Flow", "a.php", "Missing method: execute");
assert_eq!(
AuditFinding(&f1).fingerprint(),
AuditFinding(&f2).fingerprint()
);
let f3 = make_finding("Flow", "b.php", "Missing method: execute");
assert_ne!(
AuditFinding(&f1).fingerprint(),
AuditFinding(&f3).fingerprint()
);
}
#[test]
fn fingerprint_ignores_description() {
let f1 = Finding {
convention: "structural".to_string(),
severity: Severity::Warning,
file: "deploy.rs".to_string(),
description: "File has 2484 lines (threshold: 1000)".to_string(),
suggestion: String::new(),
kind: AuditFinding::GodFile,
};
let f2 = Finding {
convention: "structural".to_string(),
severity: Severity::Warning,
file: "deploy.rs".to_string(),
description: "File has 2645 lines (threshold: 1000)".to_string(),
suggestion: String::new(),
kind: AuditFinding::GodFile,
};
assert_eq!(
AuditFinding(&f1).fingerprint(),
AuditFinding(&f2).fingerprint(),
"fingerprint should not change when line count changes"
);
}
#[test]
fn file_from_audit_fingerprint_extracts_file_path() {
assert_eq!(
file_from_audit_fingerprint("Commands::src/commands/version.rs::NamingMismatch"),
Some("src/commands/version.rs".to_string())
);
}
#[test]
fn file_from_audit_fingerprint_handles_nested_paths() {
assert_eq!(
file_from_audit_fingerprint(
"test_coverage::src/core/code_audit/baseline.rs::MissingTestMethod"
),
Some("src/core/code_audit/baseline.rs".to_string())
);
}
#[test]
fn file_from_audit_fingerprint_returns_none_for_invalid() {
assert_eq!(file_from_audit_fingerprint("no_separators"), None);
assert_eq!(file_from_audit_fingerprint("only::one"), None);
}
#[test]
fn save_baseline_scoped_preserves_out_of_scope() {
let result_initial = make_result(
vec![
make_finding("Flow", "a.php", "Missing method: execute"),
make_finding("Flow", "b.php", "Missing method: validate"),
make_finding("Flow", "c.php", "Missing method: register"),
],
"scoped_preserve",
);
let _ = save_baseline(&result_initial).unwrap();
let baseline_before = load_baseline(Path::new(&result_initial.source_path)).unwrap();
assert_eq!(baseline_before.item_count, 3);
let mut result_scoped = make_result(vec![], "scoped_preserve_update");
result_scoped.source_path = result_initial.source_path.clone();
let changed = vec!["a.php".to_string()];
let _ = save_baseline_scoped(&result_scoped, &changed).unwrap();
let baseline_after = load_baseline(Path::new(&result_initial.source_path)).unwrap();
assert_eq!(baseline_after.item_count, 2);
assert!(!baseline_after
.known_fingerprints
.iter()
.any(|fp| fp.contains("a.php")));
assert!(baseline_after
.known_fingerprints
.iter()
.any(|fp| fp.contains("b.php")));
assert!(baseline_after
.known_fingerprints
.iter()
.any(|fp| fp.contains("c.php")));
let _ = std::fs::remove_dir_all(Path::new(&result_initial.source_path));
}
#[test]
fn save_baseline_scoped_adds_new_in_scope() {
let result_initial = make_result(
vec![make_finding("Flow", "a.php", "Missing method: execute")],
"scoped_add",
);
let _ = save_baseline(&result_initial).unwrap();
let mut result_scoped = make_result(
vec![make_finding("Flow", "b.php", "Missing method: validate")],
"scoped_add_update",
);
result_scoped.source_path = result_initial.source_path.clone();
let changed = vec!["b.php".to_string()];
let _ = save_baseline_scoped(&result_scoped, &changed).unwrap();
let baseline_after = load_baseline(Path::new(&result_initial.source_path)).unwrap();
assert_eq!(baseline_after.item_count, 2);
assert!(baseline_after
.known_fingerprints
.iter()
.any(|fp| fp.contains("a.php")));
assert!(baseline_after
.known_fingerprints
.iter()
.any(|fp| fp.contains("b.php")));
let _ = std::fs::remove_dir_all(Path::new(&result_initial.source_path));
}
}