use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::rsi_stale_scan::{StaleFinding, Verdict, scan_brain_files};
pub const SCAN_MIN_INTERVAL_SECS: u64 = 24 * 3600;
pub const LEDGER_SCHEMA_VERSION: u32 = 1;
pub fn default_ledger_path() -> PathBuf {
crate::config::opencrabs_home().join("rsi/stale_scan.json")
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LedgerEntry {
pub verdict: Verdict,
pub evidence: String,
pub last_verified_cycle: u64,
#[serde(default)]
pub outstanding_stale: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StaleScanLedger {
pub schema_version: u32,
pub binary_version: String,
pub last_run_unix: u64,
pub entries: BTreeMap<String, LedgerEntry>,
}
impl Default for StaleScanLedger {
fn default() -> Self {
Self {
schema_version: LEDGER_SCHEMA_VERSION,
binary_version: String::new(),
last_run_unix: 0,
entries: BTreeMap::new(),
}
}
}
impl StaleScanLedger {
pub fn load(path: &Path) -> Option<Self> {
let raw = std::fs::read_to_string(path).ok()?;
let ledger: StaleScanLedger = serde_json::from_str(&raw).ok()?;
(ledger.schema_version == LEDGER_SCHEMA_VERSION).then_some(ledger)
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
}
pub fn rule_hash(line: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in line.trim().as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
pub fn ledger_key(rule_hash: &str, anchor: &str) -> String {
format!("{rule_hash}:{anchor}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CadenceDecision {
Run { forced: bool, reason: &'static str },
Skip { reason: &'static str },
}
pub fn cadence_gate(
ledger: Option<&StaleScanLedger>,
now_unix: u64,
binary_version: &str,
) -> CadenceDecision {
let Some(ledger) = ledger else {
return CadenceDecision::Run {
forced: false,
reason: "first run: no ledger on disk",
};
};
if ledger.binary_version != binary_version {
return CadenceDecision::Run {
forced: true,
reason: "binary version changed since last ledger write",
};
}
if now_unix.saturating_sub(ledger.last_run_unix) >= SCAN_MIN_INTERVAL_SECS {
return CadenceDecision::Run {
forced: false,
reason: "daily cadence elapsed",
};
}
CadenceDecision::Skip {
reason: "scan already ran within 24h on this binary version",
}
}
#[derive(Debug)]
pub struct ScanReport {
pub to_surface: Vec<StaleFinding>,
pub cleared: Vec<StaleFinding>,
pub suppressed: usize,
pub ledger: StaleScanLedger,
}
pub fn diff_and_record(
findings: Vec<StaleFinding>,
mut ledger: StaleScanLedger,
now_unix: u64,
cycle: u64,
binary_version: &str,
) -> ScanReport {
let mut to_surface = Vec::new();
let mut cleared = Vec::new();
let mut suppressed = 0usize;
for finding in findings {
let key = ledger_key(&rule_hash(&finding.line), &finding.anchor);
match ledger.entries.get(&key).cloned() {
None => {
if finding.verdict == Verdict::Stale {
to_surface.push(finding.clone());
}
ledger.entries.insert(
key,
LedgerEntry {
verdict: finding.verdict,
evidence: finding.evidence.clone(),
last_verified_cycle: cycle,
outstanding_stale: finding.verdict == Verdict::Stale,
},
);
}
Some(prev) if prev.verdict != finding.verdict => {
if finding.verdict == Verdict::Stale {
to_surface.push(finding.clone());
} else if prev.outstanding_stale && finding.verdict == Verdict::Ok {
cleared.push(finding.clone());
}
ledger.entries.insert(
key,
LedgerEntry {
verdict: finding.verdict,
evidence: finding.evidence.clone(),
last_verified_cycle: cycle,
outstanding_stale: match finding.verdict {
Verdict::Stale => true,
Verdict::Ok => false,
Verdict::Unverifiable => prev.outstanding_stale,
},
},
);
}
Some(_) => {
suppressed += 1;
if let Some(entry) = ledger.entries.get_mut(&key) {
entry.last_verified_cycle = cycle;
entry.evidence = finding.evidence.clone();
}
}
}
}
ledger.binary_version = binary_version.to_string();
ledger.last_run_unix = now_unix;
ScanReport {
to_surface,
cleared,
suppressed,
ledger,
}
}
#[derive(Debug)]
pub enum ScanRunOutcome {
Skipped { reason: &'static str },
Ran {
report: ScanReport,
persisted: Result<(), String>,
},
}
pub fn run_scan_with_ledger(
config: &crate::config::Config,
brain_root: &Path,
ledger_path: &Path,
now_unix: u64,
cycle: u64,
binary_version: &str,
) -> ScanRunOutcome {
let ledger = StaleScanLedger::load(ledger_path).unwrap_or_default();
match cadence_gate(Some(&ledger), now_unix, binary_version) {
CadenceDecision::Skip { reason } => ScanRunOutcome::Skipped { reason },
CadenceDecision::Run { .. } => {
let findings = scan_brain_files(config, brain_root);
let report = diff_and_record(findings, ledger, now_unix, cycle, binary_version);
let persisted = report.ledger.save(ledger_path).map_err(|e| e.to_string());
ScanRunOutcome::Ran { report, persisted }
}
}
}