use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const VERDICTS: [&str; 2] = ["ok", "failed"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Ok,
Failed,
}
impl Verdict {
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"ok" => Some(Self::Ok),
"failed" => Some(Self::Failed),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckRecord {
pub ts: u64,
pub entity: String,
pub verdict: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub entity_hash: String,
pub actor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
pub role: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckState {
NeverChecked,
CheckedOk,
CheckFailed,
CheckStale,
}
impl CheckState {
pub fn as_str(self) -> &'static str {
match self {
Self::NeverChecked => "never_checked",
Self::CheckedOk => "checked_ok",
Self::CheckFailed => "check_failed",
Self::CheckStale => "check_stale",
}
}
}
pub fn derive_state(latest: Option<&CheckRecord>, current_hash: &str) -> CheckState {
match latest {
None => CheckState::NeverChecked,
Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
Some(rec) if rec.verdict == "failed" => CheckState::CheckFailed,
Some(_) => CheckState::CheckedOk,
}
}
fn checks_dir(workspace_root: &Path) -> PathBuf {
workspace_root
.join(crate::workspace_store::WORKSPACE_STORE_DIR)
.join("state")
.join("checks")
}
pub fn check_ledger_path(workspace_root: &Path) -> PathBuf {
checks_dir(workspace_root).join("checks.jsonl")
}
#[derive(Debug, Clone)]
pub struct CheckLedger {
path: PathBuf,
}
impl CheckLedger {
pub fn for_workspace(workspace_root: &Path) -> Self {
Self {
path: check_ledger_path(workspace_root),
}
}
pub fn record(&self, rec: &CheckRecord) -> std::io::Result<()> {
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
line.push('\n');
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
f.write_all(line.as_bytes())
}
pub fn all(&self) -> Vec<CheckRecord> {
let Ok(content) = std::fs::read_to_string(&self.path) else {
return Vec::new();
};
content
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
pub fn latest_for(&self, entity: &str) -> Option<CheckRecord> {
self.all().into_iter().rev().find(|r| r.entity == entity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn rec(entity: &str, verdict: &str, hash: &str) -> CheckRecord {
CheckRecord {
ts: 1,
entity: entity.to_string(),
verdict: verdict.to_string(),
method: None,
entity_hash: hash.to_string(),
actor: "cli".to_string(),
client: None,
role: "checker".to_string(),
}
}
#[test]
fn state_derivation_covers_all_four_states() {
assert_eq!(derive_state(None, "h1"), CheckState::NeverChecked);
let ok = rec("m--e", "ok", "h1");
assert_eq!(derive_state(Some(&ok), "h1"), CheckState::CheckedOk);
assert_eq!(derive_state(Some(&ok), "h2"), CheckState::CheckStale);
let failed = rec("m--e", "failed", "h1");
assert_eq!(derive_state(Some(&failed), "h1"), CheckState::CheckFailed);
assert_eq!(derive_state(Some(&failed), "h2"), CheckState::CheckStale);
}
#[test]
fn ledger_appends_and_serves_newest_per_entity() {
let tmp = TempDir::new().unwrap();
let ledger = CheckLedger::for_workspace(tmp.path());
assert!(ledger.latest_for("m--a").is_none());
ledger.record(&rec("m--a", "failed", "h1")).unwrap();
ledger.record(&rec("m--b", "ok", "h9")).unwrap();
ledger.record(&rec("m--a", "ok", "h2")).unwrap();
let latest = ledger.latest_for("m--a").unwrap();
assert_eq!(latest.verdict, "ok");
assert_eq!(latest.entity_hash, "h2");
assert_eq!(ledger.all().len(), 3);
}
#[test]
fn verdict_vocabulary_is_closed() {
assert!(Verdict::from_wire("ok").is_some());
assert!(Verdict::from_wire("failed").is_some());
assert!(Verdict::from_wire("passed").is_none());
assert!(Verdict::from_wire("OK").is_none());
}
}