use std::collections::HashSet;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::core::error::{Error, Result};
use crate::core::eventlog::{Event, EventLog};
use crate::core::finding::Finding;
use crate::core::hash::{fnv1a_64_chunked, fnv1a_hex};
use crate::core::snapshot::SeverityCounts;
pub const CHECK_RECORD_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CheckRecord {
pub version: u32,
pub check_id: String,
pub started_at: DateTime<Utc>,
#[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,
pub findings: Vec<Finding>,
}
impl CheckRecord {
#[must_use]
pub fn new(
head_sha: Option<String>,
worktree_clean: bool,
config_hash: String,
findings: Vec<Finding>,
) -> Self {
let started_at = Utc::now();
let severity_counts = tally_findings(&findings);
Self {
version: CHECK_RECORD_VERSION,
check_id: ulid::Ulid::new().to_string(),
started_at,
head_sha,
worktree_clean,
config_hash,
severity_counts,
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 tally_findings(findings: &[Finding]) -> SeverityCounts {
let mut counts = SeverityCounts::default();
for f in findings {
counts.tally(f.severity);
}
counts
}
#[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_check_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 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(checks_dir: &Path, latest_path: &Path, record: &CheckRecord) -> Result<()> {
EventLog::new(checks_dir).append(&Event {
timestamp: record.started_at,
event: "check".to_owned(),
data: serde_json::to_value(record).expect("CheckRecord serialization is infallible"),
})?;
let body = serde_json::to_vec_pretty(record).expect("CheckRecord serialization is infallible");
crate::core::fs::atomic_write(latest_path, &body)
}
pub fn read_latest(latest_path: &Path) -> Result<Option<CheckRecord>> {
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 record: CheckRecord = serde_json::from_slice(&bytes).map_err(|e| Error::CacheParse {
path: latest_path.to_path_buf(),
source: e,
})?;
if record.version > CHECK_RECORD_VERSION {
return Ok(None);
}
Ok(Some(record))
}
pub fn iter_records(checks_dir: &Path) -> Result<Vec<(DateTime<Utc>, CheckRecord)>> {
let segments = EventLog::new(checks_dir).segments()?;
let mut out: Vec<(DateTime<Utc>, CheckRecord)> = Vec::new();
for ev in EventLog::iter_segments(segments).flatten() {
if ev.event != "check" {
continue;
}
if let Ok(rec) = serde_json::from_value::<CheckRecord>(ev.data.clone()) {
if rec.version <= CHECK_RECORD_VERSION {
out.push((ev.timestamp, rec));
}
}
}
out.sort_by_key(|(ts, _)| *ts);
out.reverse();
Ok(out)
}
#[must_use]
pub fn find_by_id<'a>(
records: &'a [(DateTime<Utc>, CheckRecord)],
check_id: &str,
) -> Option<&'a CheckRecord> {
records
.iter()
.find(|(_, r)| r.check_id == check_id)
.map(|(_, r)| r)
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckRecordSummary {
pub check_id: String,
pub started_at: DateTime<Utc>,
pub head_sha: Option<String>,
pub findings_count: usize,
pub severity_counts: SeverityCounts,
pub worktree_clean: bool,
}
impl From<&CheckRecord> for CheckRecordSummary {
fn from(r: &CheckRecord) -> Self {
Self {
check_id: r.check_id.clone(),
started_at: r.started_at,
head_sha: r.head_sha.clone(),
findings_count: r.findings.len(),
severity_counts: r.severity_counts,
worktree_clean: r.worktree_clean,
}
}
}
pub fn append_fixed(fixed_log: &Path, entry: &FixedFinding) -> Result<()> {
append_jsonl(fixed_log, entry)
}
pub fn read_fixed(fixed_log: &Path) -> Result<Vec<FixedFinding>> {
read_jsonl(fixed_log)
}
pub fn read_regressed(regressed_log: &Path) -> Result<Vec<RegressedEntry>> {
read_jsonl(regressed_log)
}
pub fn reconcile_fixed(
fixed_log: &Path,
regressed_log: &Path,
record: &CheckRecord,
) -> Result<Vec<RegressedEntry>> {
let fixed = read_fixed(fixed_log)?;
if fixed.is_empty() {
return Ok(Vec::new());
}
let active_ids: HashSet<&str> = record.findings.iter().map(|f| f.id.as_str()).collect();
let mut surviving: Vec<FixedFinding> = Vec::with_capacity(fixed.len());
let mut regressed: Vec<RegressedEntry> = Vec::new();
for entry in fixed {
if active_ids.contains(entry.finding_id.as_str()) {
regressed.push(RegressedEntry {
finding_id: entry.finding_id.clone(),
previous_commit_sha: entry.commit_sha.clone(),
previous_fixed_at: entry.fixed_at,
regressed_check_id: record.check_id.clone(),
regressed_at: record.started_at,
});
} else {
surviving.push(entry);
}
}
if regressed.is_empty() {
return Ok(Vec::new());
}
rewrite_jsonl(fixed_log, &surviving)?;
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)
}
fn rewrite_jsonl<T: Serialize>(path: &Path, values: &[T]) -> Result<()> {
let mut body: Vec<u8> = Vec::new();
for v in values {
let line = serde_json::to_string(v).expect("entry serialization is infallible");
body.extend_from_slice(line.as_bytes());
body.push(b'\n');
}
crate::core::fs::atomic_write(path, &body)
}
#[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 check_id_is_unique_across_calls() {
let r1 = CheckRecord::new(None, false, "h".into(), Vec::new());
let r2 = CheckRecord::new(None, false, "h".into(), Vec::new());
assert_ne!(r1.check_id, r2.check_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 write_then_read_round_trips() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("checks");
let latest = dir.join("latest.json");
let rec = CheckRecord::new(
Some("abc".into()),
true,
"deadbeef".into(),
vec![finding("foo", Severity::Critical)],
);
write_record(&dir, &latest, &rec).unwrap();
let back = read_latest(&latest).unwrap().expect("record present");
assert_eq!(back.check_id, rec.check_id);
assert_eq!(back.findings.len(), 1);
assert_eq!(back.severity_counts.critical, 1);
}
#[test]
fn iter_records_returns_newest_first() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("checks");
let latest = dir.join("latest.json");
let r1 = CheckRecord::new(Some("a".into()), true, "h".into(), Vec::new());
write_record(&dir, &latest, &r1).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
let r2 = CheckRecord::new(Some("b".into()), true, "h".into(), Vec::new());
write_record(&dir, &latest, &r2).unwrap();
let records = iter_records(&dir).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].1.check_id, r2.check_id);
assert_eq!(records[1].1.check_id, r1.check_id);
}
#[test]
fn freshness_requires_clean_worktree_on_both_sides() {
let rec = CheckRecord::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 = CheckRecord::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_log = tmp.path().join("fixed.jsonl");
let regressed_log = tmp.path().join("regressed.jsonl");
let still_fixed = finding("clean", Severity::Critical);
let regressed = finding("regressed", Severity::High);
append_fixed(
&fixed_log,
&FixedFinding {
finding_id: still_fixed.id.clone(),
commit_sha: "sha-clean".into(),
fixed_at: Utc::now(),
},
)
.unwrap();
append_fixed(
&fixed_log,
&FixedFinding {
finding_id: regressed.id.clone(),
commit_sha: "sha-regressed".into(),
fixed_at: Utc::now(),
},
)
.unwrap();
let rec = CheckRecord::new(None, true, "h".into(), vec![regressed.clone()]);
let surfaced = reconcile_fixed(&fixed_log, ®ressed_log, &rec).unwrap();
assert_eq!(surfaced.len(), 1);
assert_eq!(surfaced[0].finding_id, regressed.id);
let surviving = read_fixed(&fixed_log).unwrap();
assert_eq!(surviving.len(), 1);
assert_eq!(surviving[0].finding_id, 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_check_id, rec.check_id);
}
#[test]
fn reconcile_fixed_is_noop_when_nothing_regresses() {
let tmp = TempDir::new().unwrap();
let fixed_log = tmp.path().join("fixed.jsonl");
let regressed_log = tmp.path().join("regressed.jsonl");
let f = finding("only", Severity::Critical);
append_fixed(
&fixed_log,
&FixedFinding {
finding_id: f.id.clone(),
commit_sha: "sha".into(),
fixed_at: Utc::now(),
},
)
.unwrap();
let rec = CheckRecord::new(None, true, "h".into(), Vec::new());
let surfaced = reconcile_fixed(&fixed_log, ®ressed_log, &rec).unwrap();
assert!(surfaced.is_empty());
assert_eq!(read_fixed(&fixed_log).unwrap().len(), 1);
assert!(!regressed_log.exists());
}
}