slopguard_core/
baseline.rs1#[cfg(test)]
11mod tests;
12
13use std::collections::HashMap;
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use serde_json::{from_str, to_string_pretty};
20use thiserror::Error;
21
22use crate::cache::sha256_hex;
23use crate::finding::Finding;
24
25pub const BASELINE_FILE: &str = ".slopguard-baseline.json";
28
29const BASELINE_VERSION: u32 = 1;
31
32const CONTEXT_LINES: usize = 2;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BaselineEntry {
39 pub hash: String,
41 pub rule_id: String,
44 pub file: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Baseline {
50 pub version: u32,
51 pub findings: Vec<BaselineEntry>,
52}
53
54#[derive(Debug, Error)]
55pub enum BaselineError {
56 #[error("failed to read baseline '{path}': {source}")]
57 Io {
58 path: String,
59 #[source]
60 source: io::Error,
61 },
62 #[error("failed to parse baseline '{path}': {source}")]
63 Parse {
64 path: String,
65 #[source]
66 source: serde_json::Error,
67 },
68 #[error("unsupported baseline version {found} in '{path}' (expected {expected})")]
69 Version {
70 path: String,
71 found: u32,
72 expected: u32,
73 },
74}
75
76pub fn relative_path(file: &Path, root: &Path) -> String {
83 let relative = match (file.canonicalize(), root.canonicalize()) {
84 (Ok(abs_file), Ok(abs_root)) => abs_file
85 .strip_prefix(&abs_root)
86 .map(Path::to_path_buf)
87 .unwrap_or(abs_file),
88 _ => file.strip_prefix(root).unwrap_or(file).to_path_buf(),
89 };
90 relative.to_string_lossy().replace('\\', "/")
91}
92
93fn context_snippet(lines: &[&str], finding: &Finding) -> String {
98 if finding.line == 0 || finding.line > lines.len() {
99 return String::new();
100 }
101 let index = finding.line - 1;
102 let start = index.saturating_sub(CONTEXT_LINES);
103 let end = (index + CONTEXT_LINES + 1).min(lines.len());
104 lines[start..end]
105 .iter()
106 .map(|line| line.trim())
107 .collect::<Vec<_>>()
108 .join("\n")
109}
110
111pub fn finding_hash(finding: &Finding, root: &Path, source: Option<&str>) -> String {
117 let context = match source {
118 Some(content) => {
119 let lines: Vec<&str> = content.lines().collect();
120 context_snippet(&lines, finding)
121 }
122 None => String::new(),
123 };
124 let input = format!(
125 "{}\0{}\0{}\0{}",
126 finding.rule_id,
127 relative_path(&finding.file, root),
128 finding.matched_text,
129 context
130 );
131 sha256_hex(input.as_bytes())
132}
133
134pub fn hash_findings(findings: &[Finding], root: &Path) -> Vec<String> {
138 let mut contents: HashMap<PathBuf, Option<String>> = HashMap::new();
139 findings
140 .iter()
141 .map(|finding| {
142 let source = contents
143 .entry(finding.file.clone())
144 .or_insert_with(|| fs::read_to_string(&finding.file).ok());
145 finding_hash(finding, root, source.as_deref())
146 })
147 .collect()
148}
149
150pub fn build(findings: &[Finding], root: &Path) -> Baseline {
156 let mut entries: Vec<BaselineEntry> = hash_findings(findings, root)
157 .into_iter()
158 .zip(findings)
159 .map(|(hash, finding)| BaselineEntry {
160 hash,
161 rule_id: finding.rule_id.to_string(),
162 file: relative_path(&finding.file, root),
163 })
164 .collect();
165 entries.sort_by(|a, b| (&a.file, &a.rule_id, &a.hash).cmp(&(&b.file, &b.rule_id, &b.hash)));
166 Baseline {
167 version: BASELINE_VERSION,
168 findings: entries,
169 }
170}
171
172pub fn write(baseline: &Baseline, path: &Path) -> Result<(), BaselineError> {
178 let json = to_string_pretty(baseline).map_err(|e| BaselineError::Io {
179 path: path.display().to_string(),
180 source: io::Error::other(e),
181 })?;
182 fs::write(path, format!("{json}\n")).map_err(|e| BaselineError::Io {
183 path: path.display().to_string(),
184 source: e,
185 })
186}
187
188pub fn load(path: &Path) -> Result<Baseline, BaselineError> {
196 let content = fs::read_to_string(path).map_err(|e| BaselineError::Io {
197 path: path.display().to_string(),
198 source: e,
199 })?;
200 let baseline: Baseline = from_str(&content).map_err(|e| BaselineError::Parse {
201 path: path.display().to_string(),
202 source: e,
203 })?;
204 if baseline.version != BASELINE_VERSION {
205 return Err(BaselineError::Version {
206 path: path.display().to_string(),
207 found: baseline.version,
208 expected: BASELINE_VERSION,
209 });
210 }
211 Ok(baseline)
212}
213
214pub fn find_baseline_file(start: &Path) -> Option<PathBuf> {
216 start.ancestors().find_map(|dir| {
217 let candidate = dir.join(BASELINE_FILE);
218 candidate.is_file().then_some(candidate)
219 })
220}
221
222pub fn project_root(start: &Path) -> PathBuf {
225 start
226 .ancestors()
227 .find(|dir| dir.join("slopguard.toml").is_file())
228 .or_else(|| start.ancestors().find(|dir| dir.join(".git").exists()))
229 .map(Path::to_path_buf)
230 .unwrap_or_else(|| start.to_path_buf())
231}
232
233pub fn filter(findings: Vec<Finding>, baseline: &Baseline, root: &Path) -> (Vec<Finding>, usize) {
240 let mut remaining: HashMap<&str, usize> = HashMap::new();
241 for entry in &baseline.findings {
242 *remaining.entry(entry.hash.as_str()).or_insert(0) += 1;
243 }
244
245 let hashes = hash_findings(&findings, root);
246 let mut kept = Vec::new();
247 let mut filtered = 0;
248 for (finding, hash) in findings.into_iter().zip(hashes) {
249 match remaining.get_mut(hash.as_str()) {
250 Some(count) if *count > 0 => {
251 *count -= 1;
252 filtered += 1;
253 }
254 _ => kept.push(finding),
255 }
256 }
257 (kept, filtered)
258}