use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const VERDICTS: [&str; 2] = ["ok", "failed"];
pub const CHECK_KINDS: [&str; 2] = ["verification", "conformance"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckKind {
Verification,
Conformance,
}
impl CheckKind {
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"verification" => Some(Self::Verification),
"conformance" => Some(Self::Conformance),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Verification => "verification",
Self::Conformance => "conformance",
}
}
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_ref: Option<String>,
}
impl CheckRecord {
pub fn resolved_kind(&self) -> CheckKind {
self.kind
.as_deref()
.and_then(CheckKind::from_wire)
.unwrap_or(CheckKind::Verification)
}
}
#[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 {
derive_state_pinned(latest, current_hash, None)
}
pub fn derive_state_pinned(
latest: Option<&CheckRecord>,
current_hash: &str,
current_schema_ref: Option<&str>,
) -> CheckState {
match latest {
None => CheckState::NeverChecked,
Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
Some(rec)
if rec.schema_ref.is_some() && rec.schema_ref.as_deref() != current_schema_ref =>
{
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)
}
pub fn latest_for_kind(&self, entity: &str, kind: CheckKind) -> Option<CheckRecord> {
self.all()
.into_iter()
.rev()
.find(|r| r.entity == entity && r.resolved_kind() == kind)
}
}
#[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(),
identity: None,
kind: None,
schema_ref: None,
}
}
#[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());
}
fn conf(entity: &str, verdict: &str, hash: &str, pin: &str) -> CheckRecord {
CheckRecord {
kind: Some("conformance".to_string()),
schema_ref: Some(pin.to_string()),
..rec(entity, verdict, hash)
}
}
#[test]
fn kind_vocabulary_is_closed() {
assert!(CheckKind::from_wire("verification").is_some());
assert!(CheckKind::from_wire("conformance").is_some());
assert!(CheckKind::from_wire("semantic").is_none());
assert!(CheckKind::from_wire("Conformance").is_none());
}
#[test]
fn legacy_lines_read_as_verification() {
let legacy = r#"{"ts":1,"entity":"m--e","verdict":"ok","entity_hash":"h1","actor":"cli","role":"checker"}"#;
let parsed: CheckRecord = serde_json::from_str(legacy).unwrap();
assert_eq!(parsed.resolved_kind(), CheckKind::Verification);
let fresh = rec("m--e", "ok", "h1");
let line = serde_json::to_string(&fresh).unwrap();
assert!(!line.contains("kind"));
assert!(!line.contains("schema_ref"));
assert!(!line.contains("identity"));
}
#[test]
fn latest_is_per_kind() {
let tmp = TempDir::new().unwrap();
let ledger = CheckLedger::for_workspace(tmp.path());
ledger.record(&rec("m--a", "ok", "h1")).unwrap();
ledger
.record(&conf("m--a", "failed", "h1", "planning@1.0.0"))
.unwrap();
let v = ledger
.latest_for_kind("m--a", CheckKind::Verification)
.unwrap();
assert_eq!(v.verdict, "ok");
let c = ledger
.latest_for_kind("m--a", CheckKind::Conformance)
.unwrap();
assert_eq!(c.verdict, "failed");
assert_eq!(c.schema_ref.as_deref(), Some("planning@1.0.0"));
}
#[test]
fn conformance_stales_on_pin_move_verification_does_not() {
let c = conf("m--e", "ok", "h1", "planning@1.0.0");
assert_eq!(
derive_state_pinned(Some(&c), "h1", Some("planning@1.0.0")),
CheckState::CheckedOk
);
assert_eq!(
derive_state_pinned(Some(&c), "h2", Some("planning@1.0.0")),
CheckState::CheckStale
);
assert_eq!(
derive_state_pinned(Some(&c), "h1", Some("planning@2.0.0")),
CheckState::CheckStale
);
assert_eq!(
derive_state_pinned(Some(&c), "h1", None),
CheckState::CheckStale
);
let v = rec("m--e", "ok", "h1");
assert_eq!(
derive_state_pinned(Some(&v), "h1", Some("planning@9.0.0")),
CheckState::CheckedOk
);
assert_eq!(derive_state(Some(&v), "h1"), CheckState::CheckedOk);
}
}