#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string_pretty};
use thiserror::Error;
use crate::cache::sha256_hex;
use crate::finding::Finding;
pub const BASELINE_FILE: &str = ".slopguard-baseline.json";
const BASELINE_VERSION: u32 = 1;
const CONTEXT_LINES: usize = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineEntry {
pub hash: String,
pub rule_id: String,
pub file: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub version: u32,
pub findings: Vec<BaselineEntry>,
}
#[derive(Debug, Error)]
pub enum BaselineError {
#[error("failed to read baseline '{path}': {source}")]
Io {
path: String,
#[source]
source: io::Error,
},
#[error("failed to parse baseline '{path}': {source}")]
Parse {
path: String,
#[source]
source: serde_json::Error,
},
#[error("unsupported baseline version {found} in '{path}' (expected {expected})")]
Version {
path: String,
found: u32,
expected: u32,
},
}
pub fn relative_path(file: &Path, root: &Path) -> String {
let relative = match (file.canonicalize(), root.canonicalize()) {
(Ok(abs_file), Ok(abs_root)) => abs_file
.strip_prefix(&abs_root)
.map(Path::to_path_buf)
.unwrap_or(abs_file),
_ => file.strip_prefix(root).unwrap_or(file).to_path_buf(),
};
relative.to_string_lossy().replace('\\', "/")
}
fn context_snippet(lines: &[&str], finding: &Finding) -> String {
if finding.line == 0 || finding.line > lines.len() {
return String::new();
}
let index = finding.line - 1;
let start = index.saturating_sub(CONTEXT_LINES);
let end = (index + CONTEXT_LINES + 1).min(lines.len());
lines[start..end]
.iter()
.map(|line| line.trim())
.collect::<Vec<_>>()
.join("\n")
}
pub fn finding_hash(finding: &Finding, root: &Path, source: Option<&str>) -> String {
let context = match source {
Some(content) => {
let lines: Vec<&str> = content.lines().collect();
context_snippet(&lines, finding)
}
None => String::new(),
};
let input = format!(
"{}\0{}\0{}\0{}",
finding.rule_id,
relative_path(&finding.file, root),
finding.matched_text,
context
);
sha256_hex(input.as_bytes())
}
pub fn hash_findings(findings: &[Finding], root: &Path) -> Vec<String> {
let mut contents: HashMap<PathBuf, Option<String>> = HashMap::new();
findings
.iter()
.map(|finding| {
let source = contents
.entry(finding.file.clone())
.or_insert_with(|| fs::read_to_string(&finding.file).ok());
finding_hash(finding, root, source.as_deref())
})
.collect()
}
pub fn build(findings: &[Finding], root: &Path) -> Baseline {
let mut entries: Vec<BaselineEntry> = hash_findings(findings, root)
.into_iter()
.zip(findings)
.map(|(hash, finding)| BaselineEntry {
hash,
rule_id: finding.rule_id.to_string(),
file: relative_path(&finding.file, root),
})
.collect();
entries.sort_by(|a, b| (&a.file, &a.rule_id, &a.hash).cmp(&(&b.file, &b.rule_id, &b.hash)));
Baseline {
version: BASELINE_VERSION,
findings: entries,
}
}
pub fn write(baseline: &Baseline, path: &Path) -> Result<(), BaselineError> {
let json = to_string_pretty(baseline).map_err(|e| BaselineError::Io {
path: path.display().to_string(),
source: io::Error::other(e),
})?;
fs::write(path, format!("{json}\n")).map_err(|e| BaselineError::Io {
path: path.display().to_string(),
source: e,
})
}
pub fn load(path: &Path) -> Result<Baseline, BaselineError> {
let content = fs::read_to_string(path).map_err(|e| BaselineError::Io {
path: path.display().to_string(),
source: e,
})?;
let baseline: Baseline = from_str(&content).map_err(|e| BaselineError::Parse {
path: path.display().to_string(),
source: e,
})?;
if baseline.version != BASELINE_VERSION {
return Err(BaselineError::Version {
path: path.display().to_string(),
found: baseline.version,
expected: BASELINE_VERSION,
});
}
Ok(baseline)
}
pub fn find_baseline_file(start: &Path) -> Option<PathBuf> {
start.ancestors().find_map(|dir| {
let candidate = dir.join(BASELINE_FILE);
candidate.is_file().then_some(candidate)
})
}
pub fn project_root(start: &Path) -> PathBuf {
start
.ancestors()
.find(|dir| dir.join("slopguard.toml").is_file())
.or_else(|| start.ancestors().find(|dir| dir.join(".git").exists()))
.map(Path::to_path_buf)
.unwrap_or_else(|| start.to_path_buf())
}
pub fn filter(findings: Vec<Finding>, baseline: &Baseline, root: &Path) -> (Vec<Finding>, usize) {
let mut remaining: HashMap<&str, usize> = HashMap::new();
for entry in &baseline.findings {
*remaining.entry(entry.hash.as_str()).or_insert(0) += 1;
}
let hashes = hash_findings(&findings, root);
let mut kept = Vec::new();
let mut filtered = 0;
for (finding, hash) in findings.into_iter().zip(hashes) {
match remaining.get_mut(hash.as_str()) {
Some(count) if *count > 0 => {
*count -= 1;
filtered += 1;
}
_ => kept.push(finding),
}
}
(kept, filtered)
}