use std::collections::BTreeMap;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::core::error::{Error, Result};
use crate::core::finding::Finding;
use crate::core::hash::{fnv1a_64_chunked, fnv1a_hex};
use crate::core::severity::SeverityCounts;
pub const FINDINGS_RECORD_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FindingsRecord {
pub version: u32,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_sha: Option<String>,
pub worktree_clean: bool,
pub config_hash: String,
pub severity_counts: SeverityCounts,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspaces: Vec<WorkspaceSummary>,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceSummary {
pub path: String,
pub severity_counts: SeverityCounts,
}
impl FindingsRecord {
#[must_use]
pub fn new(
head_sha: Option<String>,
worktree_clean: bool,
config_hash: String,
findings: Vec<Finding>,
) -> Self {
let severity_counts = SeverityCounts::from_findings(&findings);
let workspaces = workspace_summaries(&findings);
let id = record_id(head_sha.as_deref(), &config_hash, worktree_clean);
Self {
version: FINDINGS_RECORD_VERSION,
id,
head_sha,
worktree_clean,
config_hash,
severity_counts,
workspaces,
findings,
}
}
#[must_use]
pub fn project_to_workspace(&self, workspace: &str) -> Self {
let findings: Vec<Finding> = self
.findings
.iter()
.filter(|f| f.workspace.as_deref() == Some(workspace))
.cloned()
.collect();
let severity_counts = SeverityCounts::from_findings(&findings);
let workspaces = self
.workspaces
.iter()
.filter(|w| w.path == workspace)
.cloned()
.collect();
Self {
version: self.version,
id: self.id.clone(),
head_sha: self.head_sha.clone(),
worktree_clean: self.worktree_clean,
config_hash: self.config_hash.clone(),
severity_counts,
workspaces,
findings,
}
}
pub fn apply_accepted(&mut self, map: &crate::core::accepted::AcceptedMap) {
if map.is_empty() {
return;
}
crate::core::accepted::decorate_findings(&mut self.findings, map);
self.recompute_summary();
}
pub(crate) fn recompute_summary(&mut self) {
self.severity_counts = SeverityCounts::from_findings(&self.findings);
self.workspaces = workspace_summaries(&self.findings);
}
#[must_use]
pub fn is_fresh_against(
&self,
head_sha: Option<&str>,
config_hash: &str,
worktree_clean: bool,
) -> bool {
if !self.worktree_clean || !worktree_clean {
return false;
}
self.head_sha.as_deref() == head_sha && self.config_hash == config_hash
}
}
fn workspace_summaries(findings: &[Finding]) -> Vec<WorkspaceSummary> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<String, SeverityCounts> = BTreeMap::new();
for f in findings {
let Some(ws) = f.workspace.as_deref() else {
continue;
};
groups.entry(ws.to_owned()).or_default().tally(f.severity);
}
groups
.into_iter()
.map(|(path, severity_counts)| WorkspaceSummary {
path,
severity_counts,
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FixedFinding {
pub finding_id: String,
pub commit_sha: String,
pub fixed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegressedEntry {
pub finding_id: String,
pub previous_commit_sha: String,
pub previous_fixed_at: DateTime<Utc>,
pub regressed_in_record_id: String,
pub regressed_at: DateTime<Utc>,
}
#[must_use]
pub fn config_hash(config_toml: &[u8], calibration_toml: &[u8]) -> String {
fnv1a_hex(fnv1a_64_chunked(&[config_toml, calibration_toml]))
}
#[must_use]
pub fn record_id(head_sha: Option<&str>, config_hash: &str, worktree_clean: bool) -> String {
let head = head_sha.unwrap_or("");
let clean = if worktree_clean { b"clean" } else { b"dirty" };
fnv1a_hex(fnv1a_64_chunked(&[
head.as_bytes(),
config_hash.as_bytes(),
clean,
]))
}
#[must_use]
pub fn config_hash_from_paths(config: &Path, calibration: &Path) -> String {
let cfg = std::fs::read(config).unwrap_or_default();
let cal = std::fs::read(calibration).unwrap_or_default();
config_hash(&cfg, &cal)
}
pub fn write_record(latest_path: &Path, record: &FindingsRecord) -> Result<()> {
let body =
serde_json::to_vec_pretty(record).expect("FindingsRecord serialization is infallible");
crate::core::fs::atomic_write(latest_path, &body)
}
pub fn read_latest(latest_path: &Path) -> Result<Option<FindingsRecord>> {
#[derive(Deserialize)]
struct VersionPeek {
version: u32,
}
let bytes = match std::fs::read(latest_path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(Error::Io {
path: latest_path.to_path_buf(),
source: e,
})
}
};
let peek: VersionPeek = serde_json::from_slice(&bytes).map_err(|e| Error::CacheParse {
path: latest_path.to_path_buf(),
source: e,
})?;
if peek.version != FINDINGS_RECORD_VERSION {
return Ok(None);
}
let record: FindingsRecord = serde_json::from_slice(&bytes).map_err(|e| Error::CacheParse {
path: latest_path.to_path_buf(),
source: e,
})?;
Ok(Some(record))
}
pub type FixedMap = BTreeMap<String, FixedFinding>;
pub fn read_fixed(fixed_path: &Path) -> Result<FixedMap> {
let bytes = match std::fs::read(fixed_path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(FixedMap::new()),
Err(e) => {
return Err(Error::Io {
path: fixed_path.to_path_buf(),
source: e,
})
}
};
match serde_json::from_slice::<FixedMap>(&bytes) {
Ok(map) => Ok(map),
Err(err) => {
eprintln!(
"heal: ignoring unreadable {} ({err}); the next `heal mark fix` will rewrite it",
fixed_path.display(),
);
Ok(FixedMap::new())
}
}
}
fn write_fixed(fixed_path: &Path, map: &FixedMap) -> Result<()> {
let body = serde_json::to_vec_pretty(map).expect("FixedMap serialization is infallible");
crate::core::fs::atomic_write(fixed_path, &body)
}
pub fn upsert_fixed(fixed_path: &Path, entry: FixedFinding) -> Result<()> {
let mut map = read_fixed(fixed_path)?;
map.insert(entry.finding_id.clone(), entry);
write_fixed(fixed_path, &map)
}
pub fn read_regressed(regressed_log: &Path) -> Result<Vec<RegressedEntry>> {
read_jsonl(regressed_log)
}
pub fn reconcile_fixed(
fixed_path: &Path,
regressed_log: &Path,
record: &FindingsRecord,
) -> Result<Vec<RegressedEntry>> {
let mut map = read_fixed(fixed_path)?;
if map.is_empty() {
return Ok(Vec::new());
}
let mut regressed: Vec<RegressedEntry> = Vec::new();
for finding in &record.findings {
let Some(entry) = map.remove(&finding.id) else {
continue;
};
regressed.push(RegressedEntry {
finding_id: entry.finding_id,
previous_commit_sha: entry.commit_sha,
previous_fixed_at: entry.fixed_at,
regressed_in_record_id: record.id.clone(),
regressed_at: Utc::now(),
});
}
if regressed.is_empty() {
return Ok(Vec::new());
}
write_fixed(fixed_path, &map)?;
for entry in ®ressed {
append_jsonl(regressed_log, entry)?;
}
Ok(regressed)
}
fn append_jsonl<T: Serialize>(path: &Path, value: &T) -> Result<()> {
use std::io::Write as _;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let line = serde_json::to_string(value).expect("entry serialization is infallible");
let mut body = line.into_bytes();
body.push(b'\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| Error::Io {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(&body).map_err(|e| Error::Io {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
fn read_jsonl<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Vec<T>> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(Error::Io {
path: path.to_path_buf(),
source: e,
})
}
};
let mut out = Vec::new();
for line in bytes.split(|&b| b == b'\n') {
if line.is_empty() {
continue;
}
if let Ok(value) = serde_json::from_slice::<T>(line) {
out.push(value);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::finding::{Finding, Location};
use crate::core::severity::Severity;
use std::path::PathBuf;
use tempfile::TempDir;
fn finding(id_seed: &str, severity: Severity) -> Finding {
let mut f = Finding::new(
"ccn",
Location {
file: PathBuf::from(format!("src/{id_seed}.rs")),
line: Some(1),
symbol: Some(id_seed.to_owned()),
},
format!("CCN finding {id_seed}"),
id_seed,
);
f.severity = severity;
f
}
#[test]
fn project_to_workspace_narrows_findings_summary_and_workspaces() {
let mut a = finding("alpha", Severity::High);
a.workspace = Some("packages/web".into());
let mut b = finding("beta", Severity::Critical);
b.workspace = Some("packages/api".into());
let c = finding("gamma", Severity::Medium); let rec = FindingsRecord::new(
Some("sha".into()),
true,
"h".into(),
vec![a.clone(), b.clone(), c.clone()],
);
let web = rec.project_to_workspace("packages/web");
assert_eq!(web.findings.len(), 1);
assert_eq!(web.findings[0].id, a.id);
assert_eq!(web.workspaces.len(), 1);
assert_eq!(web.workspaces[0].path, "packages/web");
assert_eq!(web.severity_counts.high, 1);
assert_eq!(web.severity_counts.critical, 0);
assert_eq!(web.head_sha, rec.head_sha);
assert_eq!(web.config_hash, rec.config_hash);
assert_eq!(web.id, rec.id);
}
#[test]
fn config_hash_distinguishes_concatenation_boundary() {
let a = config_hash(b"ab", b"c");
let b = config_hash(b"a", b"bc");
assert_ne!(a, b);
}
#[test]
fn config_hash_is_stable_across_calls() {
let a = config_hash(b"foo", b"bar");
let b = config_hash(b"foo", b"bar");
assert_eq!(a, b);
}
#[test]
fn record_id_is_deterministic_across_calls() {
let r1 = FindingsRecord::new(
Some("abc".into()),
true,
"deadbeef".into(),
vec![finding("foo", Severity::Critical)],
);
let r2 = FindingsRecord::new(
Some("abc".into()),
true,
"deadbeef".into(),
vec![finding("foo", Severity::Critical)],
);
assert_eq!(r1.id, r2.id);
}
#[test]
fn record_id_distinguishes_dirty_from_clean() {
let clean = FindingsRecord::new(Some("abc".into()), true, "h".into(), Vec::new());
let dirty = FindingsRecord::new(Some("abc".into()), false, "h".into(), Vec::new());
assert_ne!(clean.id, dirty.id);
}
#[test]
fn record_id_changes_with_head_or_config() {
let base = FindingsRecord::new(Some("a".into()), true, "h".into(), Vec::new());
let other_head = FindingsRecord::new(Some("b".into()), true, "h".into(), Vec::new());
let other_cfg = FindingsRecord::new(Some("a".into()), true, "h2".into(), Vec::new());
assert_ne!(base.id, other_head.id);
assert_ne!(base.id, other_cfg.id);
}
#[test]
fn write_then_read_round_trips() {
let tmp = TempDir::new().unwrap();
let latest = tmp.path().join("checks/latest.json");
let rec = FindingsRecord::new(
Some("abc".into()),
true,
"deadbeef".into(),
vec![finding("foo", Severity::Critical)],
);
write_record(&latest, &rec).unwrap();
let back = read_latest(&latest).unwrap().expect("record present");
assert_eq!(back.id, rec.id);
assert_eq!(back.findings.len(), 1);
assert_eq!(back.severity_counts.critical, 1);
}
#[test]
fn write_record_overwrites_in_place() {
let tmp = TempDir::new().unwrap();
let latest = tmp.path().join("checks/latest.json");
let r1 = FindingsRecord::new(Some("a".into()), true, "h".into(), Vec::new());
write_record(&latest, &r1).unwrap();
let r2 = FindingsRecord::new(Some("b".into()), true, "h".into(), Vec::new());
write_record(&latest, &r2).unwrap();
let back = read_latest(&latest).unwrap().unwrap();
assert_eq!(back.id, r2.id);
}
#[test]
fn freshness_requires_clean_worktree_on_both_sides() {
let rec = FindingsRecord::new(Some("a".into()), true, "h".into(), Vec::new());
assert!(rec.is_fresh_against(Some("a"), "h", true));
assert!(!rec.is_fresh_against(Some("a"), "h", false));
assert!(!rec.is_fresh_against(Some("a"), "h2", true));
assert!(!rec.is_fresh_against(Some("b"), "h", true));
let dirty = FindingsRecord::new(Some("a".into()), false, "h".into(), Vec::new());
assert!(!dirty.is_fresh_against(Some("a"), "h", true));
}
#[test]
fn reconcile_fixed_drops_redetected_and_records_regression() {
let tmp = TempDir::new().unwrap();
let fixed_path = tmp.path().join("fixed.json");
let regressed_log = tmp.path().join("regressed.jsonl");
let still_fixed = finding("clean", Severity::Critical);
let regressed = finding("regressed", Severity::High);
upsert_fixed(
&fixed_path,
FixedFinding {
finding_id: still_fixed.id.clone(),
commit_sha: "sha-clean".into(),
fixed_at: Utc::now(),
},
)
.unwrap();
upsert_fixed(
&fixed_path,
FixedFinding {
finding_id: regressed.id.clone(),
commit_sha: "sha-regressed".into(),
fixed_at: Utc::now(),
},
)
.unwrap();
let rec = FindingsRecord::new(None, true, "h".into(), vec![regressed.clone()]);
let surfaced = reconcile_fixed(&fixed_path, ®ressed_log, &rec).unwrap();
assert_eq!(surfaced.len(), 1);
assert_eq!(surfaced[0].finding_id, regressed.id);
let surviving = read_fixed(&fixed_path).unwrap();
assert_eq!(surviving.len(), 1);
assert!(surviving.contains_key(&still_fixed.id));
let regs = read_regressed(®ressed_log).unwrap();
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].finding_id, regressed.id);
assert_eq!(regs[0].regressed_in_record_id, rec.id);
}
#[test]
fn reconcile_fixed_is_noop_when_nothing_regresses() {
let tmp = TempDir::new().unwrap();
let fixed_path = tmp.path().join("fixed.json");
let regressed_log = tmp.path().join("regressed.jsonl");
let f = finding("only", Severity::Critical);
upsert_fixed(
&fixed_path,
FixedFinding {
finding_id: f.id.clone(),
commit_sha: "sha".into(),
fixed_at: Utc::now(),
},
)
.unwrap();
let rec = FindingsRecord::new(None, true, "h".into(), Vec::new());
let surfaced = reconcile_fixed(&fixed_path, ®ressed_log, &rec).unwrap();
assert!(surfaced.is_empty());
assert_eq!(read_fixed(&fixed_path).unwrap().len(), 1);
assert!(!regressed_log.exists());
}
#[test]
fn upsert_fixed_overwrites_existing_entry_for_same_finding() {
let tmp = TempDir::new().unwrap();
let fixed_path = tmp.path().join("fixed.json");
let f = finding("only", Severity::Critical);
let original_at = Utc::now() - chrono::Duration::days(1);
upsert_fixed(
&fixed_path,
FixedFinding {
finding_id: f.id.clone(),
commit_sha: "old".into(),
fixed_at: original_at,
},
)
.unwrap();
upsert_fixed(
&fixed_path,
FixedFinding {
finding_id: f.id.clone(),
commit_sha: "new".into(),
fixed_at: Utc::now(),
},
)
.unwrap();
let map = read_fixed(&fixed_path).unwrap();
assert_eq!(map.len(), 1);
assert_eq!(map[&f.id].commit_sha, "new");
}
}