1use crate::check::{CheckKind, CheckLedger, CheckRecord, CheckState, Verdict, derive_state};
18use crate::vcs::{Actor, ClientId};
19
20use super::{Engine, error::EngineError};
21
22impl Engine {
23 #[allow(clippy::too_many_arguments)] pub fn record_check(
39 &mut self,
40 mem_name: &str,
41 entity_id: &str,
42 verdict: Verdict,
43 kind: CheckKind,
44 method: Option<&str>,
45 actor: Actor,
46 client: Option<&ClientId>,
47 ) -> Result<CheckRecord, EngineError> {
48 let mount_idx = self
49 .mounts
50 .iter()
51 .position(|m| m.mount.mem == mem_name)
52 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
53 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
54 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
55 }
56 let schema_ref = match kind {
57 CheckKind::Verification => None,
58 CheckKind::Conformance => Some(
59 self.mounts[mount_idx]
60 .mount
61 .schema
62 .as_ref()
63 .map(|s| s.as_display())
64 .ok_or_else(|| {
65 EngineError::InvalidInput(format!(
66 "a conformance check binds to the mem's schema pin, and mem \
67 `{mem_name}` declares none"
68 ))
69 })?,
70 ),
71 };
72 let entity_hash = self
73 .store
74 .all_entities()
75 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
76 .map(|e| e.content_hash.clone())
77 .ok_or_else(|| EngineError::NotFound {
78 id: entity_id.to_string(),
79 })?;
80 let Some(root) = self.workspace_root() else {
81 return Err(EngineError::CheckNotRecorded {
82 reason: "engine has no workspace root — no durable check store".to_string(),
83 });
84 };
85 let ledger = CheckLedger::for_workspace(root);
86 let record = CheckRecord {
87 ts: std::time::SystemTime::now()
88 .duration_since(std::time::UNIX_EPOCH)
89 .map(|d| d.as_secs())
90 .unwrap_or(0),
91 entity: entity_id.to_string(),
92 verdict: verdict.as_str().to_string(),
93 method: method
94 .map(str::trim)
95 .filter(|m| !m.is_empty())
96 .map(str::to_string),
97 entity_hash,
98 actor: actor.as_trailer().to_string(),
99 client: client.map(|c| format!("{}@{}", c.name, c.version)),
100 role: self
101 .current_role()
102 .as_trailer()
103 .unwrap_or("unspecified")
104 .to_string(),
105 identity: self.current_identity().map(str::to_string),
109 kind: match kind {
113 CheckKind::Verification => None,
114 CheckKind::Conformance => Some(kind.as_str().to_string()),
115 },
116 schema_ref,
117 };
118 ledger
119 .record(&record)
120 .map_err(|e| EngineError::CheckNotRecorded {
121 reason: format!("ledger append failed: {e}"),
122 })?;
123 Ok(record)
124 }
125
126 pub(crate) fn check_state_provider(
133 &self,
134 ) -> impl Fn(&crate::entity::Entity) -> CheckState + '_ {
135 let ledger = self.workspace_root().map(CheckLedger::for_workspace);
136 move |entity: &crate::entity::Entity| match &ledger {
137 None => CheckState::NeverChecked,
138 Some(ledger) => derive_state(
139 ledger
140 .latest_for_kind(&entity.id.0, CheckKind::Verification)
141 .as_ref(),
142 &entity.content_hash,
143 ),
144 }
145 }
146
147 pub fn entity_check_state(
152 &self,
153 mem_name: &str,
154 entity_id: &str,
155 ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
156 self.find_mount(mem_name)?;
157 let current_hash = self
158 .store
159 .all_entities()
160 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
161 .map(|e| e.content_hash.clone())
162 .ok_or_else(|| EngineError::NotFound {
163 id: entity_id.to_string(),
164 })?;
165 let latest = self
166 .workspace_root()
167 .map(CheckLedger::for_workspace)
168 .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Verification));
169 Ok((derive_state(latest.as_ref(), ¤t_hash), latest))
170 }
171
172 pub fn entity_conformance_state(
178 &self,
179 mem_name: &str,
180 entity_id: &str,
181 ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
182 let mount = self.find_mount(mem_name)?;
183 let current_pin = mount.mount.schema.as_ref().map(|s| s.as_display());
184 let current_hash = self
185 .store
186 .all_entities()
187 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
188 .map(|e| e.content_hash.clone())
189 .ok_or_else(|| EngineError::NotFound {
190 id: entity_id.to_string(),
191 })?;
192 let latest = self
193 .workspace_root()
194 .map(CheckLedger::for_workspace)
195 .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Conformance));
196 Ok((
197 crate::check::derive_state_pinned(
198 latest.as_ref(),
199 ¤t_hash,
200 current_pin.as_deref(),
201 ),
202 latest,
203 ))
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use crate::check::{CheckKind, Verdict};
210 use crate::vcs::Actor;
211 use crate::workspace::MountCapability;
212
213 #[test]
220 fn conformance_refuses_without_a_schema_pin() {
221 let tmp = tempfile::TempDir::new().unwrap();
222 std::fs::write(
223 tmp.path().join("anything.md"),
224 "---\nid: anything\ntitle: Anything\ntype: note\n---\n\nBody.\n",
225 )
226 .unwrap();
227 let mut mount = crate::engine::test_helpers::folder_mount("m", tmp.path().to_path_buf());
228 mount.schema = None;
229 let mut engine = crate::Engine::from_mounts(vec![(
230 mount,
231 Box::new(crate::storage::FilesystemMemWriter::new(
232 tmp.path().to_path_buf(),
233 )) as Box<dyn crate::backend::MemBackend>,
234 )])
235 .unwrap();
236 let err = engine
237 .record_check(
238 "m",
239 "m--anything",
240 Verdict::Ok,
241 CheckKind::Conformance,
242 None,
243 Actor::Cli,
244 None,
245 )
246 .unwrap_err();
247 assert_eq!(err.code(), "MEM_QUARANTINED");
248 }
249
250 #[test]
254 fn check_refuses_read_only_mounts_typed() {
255 let tmp = tempfile::TempDir::new().unwrap();
256 let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
257 mount.capability = MountCapability::ReadOnly;
258 let mut engine = crate::Engine::from_mounts(vec![(
259 mount,
260 Box::new(crate::storage::FilesystemMemWriter::new(
261 tmp.path().to_path_buf(),
262 )) as Box<dyn crate::backend::MemBackend>,
263 )])
264 .unwrap();
265 let err = engine
266 .record_check(
267 "ro",
268 "ro--anything",
269 Verdict::Ok,
270 crate::check::CheckKind::Verification,
271 None,
272 Actor::Cli,
273 None,
274 )
275 .unwrap_err();
276 assert_eq!(err.code(), "READ_ONLY_MOUNT");
277 }
278}