use std::collections::HashSet;
use std::path::Path;
use super::CodeAuditResult;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuditBaseline {
pub created_at: String,
pub component_id: String,
pub findings_count: usize,
pub outliers_count: usize,
pub alignment_score: f32,
pub known_outliers: Vec<String>,
pub known_findings: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BaselineComparison {
pub new_findings: Vec<NewFinding>,
pub resolved_findings: Vec<String>,
pub delta: i64,
pub drift_increased: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct NewFinding {
pub fingerprint: String,
pub description: String,
pub file: String,
pub convention: String,
}
const BASELINE_DIR: &str = ".homeboy";
const BASELINE_FILE: &str = "audit-baseline.json";
pub fn baseline_path(source_path: &Path) -> std::path::PathBuf {
source_path.join(BASELINE_DIR).join(BASELINE_FILE)
}
pub fn save_baseline(result: &CodeAuditResult) -> Result<std::path::PathBuf, String> {
let source = Path::new(&result.source_path);
let dir = source.join(BASELINE_DIR);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create {}: {}", dir.display(), e))?;
let known_outliers: Vec<String> = result
.conventions
.iter()
.flat_map(|c| c.outliers.iter().map(|o| o.file.clone()))
.collect();
let known_findings: Vec<String> = result
.findings
.iter()
.map(|f| finding_fingerprint(&f.convention, &f.file, &format!("{:?}", f.kind), &f.description))
.collect();
let baseline = AuditBaseline {
created_at: chrono_now(),
component_id: result.component_id.clone(),
findings_count: result.findings.len(),
outliers_count: known_outliers.len(),
alignment_score: result.summary.alignment_score,
known_outliers,
known_findings,
};
let path = baseline_path(source);
let json = serde_json::to_string_pretty(&baseline)
.map_err(|e| format!("Failed to serialize baseline: {}", e))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(path)
}
pub fn load_baseline(source_path: &Path) -> Option<AuditBaseline> {
let path = baseline_path(source_path);
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn compare(result: &CodeAuditResult, baseline: &AuditBaseline) -> BaselineComparison {
let current_fingerprints: HashSet<String> = result
.findings
.iter()
.map(|f| finding_fingerprint(&f.convention, &f.file, &format!("{:?}", f.kind), &f.description))
.collect();
let baseline_fingerprints: HashSet<String> = baseline
.known_findings
.iter()
.cloned()
.collect();
let new_findings: Vec<NewFinding> = result
.findings
.iter()
.filter(|f| {
let fp = finding_fingerprint(&f.convention, &f.file, &format!("{:?}", f.kind), &f.description);
!baseline_fingerprints.contains(&fp)
})
.map(|f| NewFinding {
fingerprint: finding_fingerprint(&f.convention, &f.file, &format!("{:?}", f.kind), &f.description),
description: f.description.clone(),
file: f.file.clone(),
convention: f.convention.clone(),
})
.collect();
let resolved_findings: Vec<String> = baseline_fingerprints
.difference(¤t_fingerprints)
.cloned()
.collect();
let delta = result.findings.len() as i64 - baseline.findings_count as i64;
let drift_increased = !new_findings.is_empty();
BaselineComparison {
new_findings,
resolved_findings,
delta,
drift_increased,
}
}
fn finding_fingerprint(convention: &str, file: &str, kind: &str, description: &str) -> String {
format!("{}::{}::{}::{}", convention, file, kind, description)
}
fn chrono_now() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let secs_per_day = 86400u64;
let secs_per_hour = 3600u64;
let secs_per_min = 60u64;
let days = now / secs_per_day;
let remaining = now % secs_per_day;
let hours = remaining / secs_per_hour;
let remaining = remaining % secs_per_hour;
let minutes = remaining / secs_per_min;
let seconds = remaining % secs_per_min;
let (year, month, day) = days_to_date(days);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn days_to_date(mut days: u64) -> (u64, u64, u64) {
let mut year = 1970u64;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let leap = is_leap_year(year);
let month_days = [
31,
if leap { 29 } else { 28 },
31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
];
let mut month = 1u64;
for &md in &month_days {
if days < md {
break;
}
days -= md;
month += 1;
}
(year, month, days + 1)
}
fn is_leap_year(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::code_audit::findings::{Finding, Severity};
use crate::code_audit::conventions::DeviationKind;
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: DeviationKind::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: 0.8,
},
conventions: vec![],
directory_conventions: vec![],
findings,
}
}
#[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.component_id, "test");
assert_eq!(loaded.findings_count, 2);
assert_eq!(loaded.known_findings.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_findings.is_empty());
assert!(comparison.resolved_findings.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_findings.len(), 1);
assert_eq!(comparison.new_findings[0].file, "c.php");
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_findings.is_empty());
assert_eq!(comparison.resolved_findings.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_findings.len(), 1);
assert_eq!(comparison.resolved_findings.len(), 1);
assert_eq!(comparison.delta, 0);
let _ = std::fs::remove_dir_all(Path::new(&result_original.source_path));
}
#[test]
fn no_baseline_returns_none() {
let result = load_baseline(Path::new("/nonexistent/path"));
assert!(result.is_none());
}
#[test]
fn finding_fingerprint_is_stable() {
let fp1 = finding_fingerprint("Flow", "a.php", "MissingMethod", "Missing method: execute");
let fp2 = finding_fingerprint("Flow", "a.php", "MissingMethod", "Missing method: execute");
assert_eq!(fp1, fp2);
let fp3 = finding_fingerprint("Flow", "b.php", "MissingMethod", "Missing method: execute");
assert_ne!(fp1, fp3);
}
#[test]
fn chrono_now_produces_valid_iso8601() {
let now = chrono_now();
assert!(now.len() == 20, "Expected 20 chars, got {}: {}", now.len(), now);
assert!(now.ends_with('Z'));
assert!(now.contains('T'));
}
}