1use std::io::Write;
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35
36pub const VERDICTS: [&str; 2] = ["ok", "failed"];
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Verdict {
43 Ok,
44 Failed,
45}
46
47impl Verdict {
48 pub fn from_wire(s: &str) -> Option<Self> {
50 match s {
51 "ok" => Some(Self::Ok),
52 "failed" => Some(Self::Failed),
53 _ => None,
54 }
55 }
56
57 pub fn as_str(self) -> &'static str {
58 match self {
59 Self::Ok => "ok",
60 Self::Failed => "failed",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CheckRecord {
68 pub ts: u64,
70 pub entity: String,
72 pub verdict: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub method: Option<String>,
78 pub entity_hash: String,
81 pub actor: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub client: Option<String>,
86 pub role: String,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum CheckState {
95 NeverChecked,
96 CheckedOk,
97 CheckFailed,
98 CheckStale,
99}
100
101impl CheckState {
102 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::NeverChecked => "never_checked",
105 Self::CheckedOk => "checked_ok",
106 Self::CheckFailed => "check_failed",
107 Self::CheckStale => "check_stale",
108 }
109 }
110}
111
112pub fn derive_state(latest: Option<&CheckRecord>, current_hash: &str) -> CheckState {
115 match latest {
116 None => CheckState::NeverChecked,
117 Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
118 Some(rec) if rec.verdict == "failed" => CheckState::CheckFailed,
119 Some(_) => CheckState::CheckedOk,
120 }
121}
122
123fn checks_dir(workspace_root: &Path) -> PathBuf {
126 workspace_root
127 .join(crate::workspace_store::WORKSPACE_STORE_DIR)
128 .join("state")
129 .join("checks")
130}
131
132pub fn check_ledger_path(workspace_root: &Path) -> PathBuf {
134 checks_dir(workspace_root).join("checks.jsonl")
135}
136
137#[derive(Debug, Clone)]
139pub struct CheckLedger {
140 path: PathBuf,
141}
142
143impl CheckLedger {
144 pub fn for_workspace(workspace_root: &Path) -> Self {
145 Self {
146 path: check_ledger_path(workspace_root),
147 }
148 }
149
150 pub fn record(&self, rec: &CheckRecord) -> std::io::Result<()> {
155 if let Some(dir) = self.path.parent() {
156 std::fs::create_dir_all(dir)?;
157 }
158 let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
159 line.push('\n');
160 let mut f = std::fs::OpenOptions::new()
161 .create(true)
162 .append(true)
163 .open(&self.path)?;
164 f.write_all(line.as_bytes())
165 }
166
167 pub fn all(&self) -> Vec<CheckRecord> {
171 let Ok(content) = std::fs::read_to_string(&self.path) else {
172 return Vec::new();
173 };
174 content
175 .lines()
176 .filter_map(|l| serde_json::from_str(l).ok())
177 .collect()
178 }
179
180 pub fn latest_for(&self, entity: &str) -> Option<CheckRecord> {
182 self.all().into_iter().rev().find(|r| r.entity == entity)
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use tempfile::TempDir;
190
191 fn rec(entity: &str, verdict: &str, hash: &str) -> CheckRecord {
192 CheckRecord {
193 ts: 1,
194 entity: entity.to_string(),
195 verdict: verdict.to_string(),
196 method: None,
197 entity_hash: hash.to_string(),
198 actor: "cli".to_string(),
199 client: None,
200 role: "checker".to_string(),
201 }
202 }
203
204 #[test]
205 fn state_derivation_covers_all_four_states() {
206 assert_eq!(derive_state(None, "h1"), CheckState::NeverChecked);
207 let ok = rec("m--e", "ok", "h1");
208 assert_eq!(derive_state(Some(&ok), "h1"), CheckState::CheckedOk);
209 assert_eq!(derive_state(Some(&ok), "h2"), CheckState::CheckStale);
210 let failed = rec("m--e", "failed", "h1");
211 assert_eq!(derive_state(Some(&failed), "h1"), CheckState::CheckFailed);
212 assert_eq!(derive_state(Some(&failed), "h2"), CheckState::CheckStale);
215 }
216
217 #[test]
218 fn ledger_appends_and_serves_newest_per_entity() {
219 let tmp = TempDir::new().unwrap();
220 let ledger = CheckLedger::for_workspace(tmp.path());
221 assert!(ledger.latest_for("m--a").is_none());
222 ledger.record(&rec("m--a", "failed", "h1")).unwrap();
223 ledger.record(&rec("m--b", "ok", "h9")).unwrap();
224 ledger.record(&rec("m--a", "ok", "h2")).unwrap();
225 let latest = ledger.latest_for("m--a").unwrap();
226 assert_eq!(latest.verdict, "ok");
227 assert_eq!(latest.entity_hash, "h2");
228 assert_eq!(ledger.all().len(), 3);
230 }
231
232 #[test]
233 fn verdict_vocabulary_is_closed() {
234 assert!(Verdict::from_wire("ok").is_some());
235 assert!(Verdict::from_wire("failed").is_some());
236 assert!(Verdict::from_wire("passed").is_none());
237 assert!(Verdict::from_wire("OK").is_none());
238 }
239}