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 self.record_check_with(
49 mem_name,
50 entity_id,
51 verdict,
52 &crate::check::RecordKind::Engine(kind),
53 method,
54 None,
55 actor,
56 client,
57 )
58 }
59
60 #[allow(clippy::too_many_arguments)]
66 pub fn record_check_with(
67 &mut self,
68 mem_name: &str,
69 entity_id: &str,
70 verdict: Verdict,
71 kind: &crate::check::RecordKind,
72 method: Option<&str>,
73 finding: Option<crate::check::CheckFinding>,
74 actor: Actor,
75 client: Option<&ClientId>,
76 ) -> Result<CheckRecord, EngineError> {
77 if let Some(f) = &finding {
78 f.validate()
79 .map_err(|reason| EngineError::InvalidCheckFinding { reason })?;
80 }
81 let mount_idx = self
82 .mounts
83 .iter()
84 .position(|m| m.mount.mem == mem_name)
85 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
86 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
87 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
88 }
89 let schema_ref = match kind.engine_kind() {
90 None | Some(CheckKind::Verification) => None,
91 Some(CheckKind::Conformance) => Some(
92 self.mounts[mount_idx]
93 .mount
94 .schema
95 .as_ref()
96 .map(|s| s.as_display())
97 .ok_or_else(|| {
98 EngineError::InvalidInput(format!(
99 "a conformance check binds to the mem's schema pin, and mem \
100 `{mem_name}` declares none"
101 ))
102 })?,
103 ),
104 };
105 let entity_hash = self
106 .store
107 .all_entities()
108 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
109 .map(|e| e.content_hash.clone())
110 .ok_or_else(|| EngineError::NotFound {
111 id: entity_id.to_string(),
112 })?;
113 let Some(root) = self.workspace_root() else {
114 return Err(EngineError::CheckNotRecorded {
115 reason: "engine has no workspace root — no durable check store".to_string(),
116 });
117 };
118 let ledger = CheckLedger::for_workspace(root);
119 let record = CheckRecord {
120 ts: std::time::SystemTime::now()
121 .duration_since(std::time::UNIX_EPOCH)
122 .map(|d| d.as_secs())
123 .unwrap_or(0),
124 entity: entity_id.to_string(),
125 verdict: verdict.as_str().to_string(),
126 method: method
127 .map(str::trim)
128 .filter(|m| !m.is_empty())
129 .map(str::to_string),
130 entity_hash,
131 actor: actor.as_trailer().to_string(),
132 client: client.map(|c| format!("{}@{}", c.name, c.version)),
133 role: self
134 .current_role()
135 .as_trailer()
136 .unwrap_or("unspecified")
137 .to_string(),
138 identity: self.current_identity().map(str::to_string),
142 kind: match kind {
146 crate::check::RecordKind::Engine(CheckKind::Verification) => None,
147 other => Some(other.as_wire().to_string()),
148 },
149 schema_ref,
150 finding,
151 };
152 ledger
153 .record(&record)
154 .map_err(|e| EngineError::CheckNotRecorded {
155 reason: format!("ledger append failed: {e}"),
156 })?;
157 Ok(record)
158 }
159
160 pub(crate) fn check_state_provider(
167 &self,
168 ) -> impl Fn(&crate::entity::Entity) -> CheckState + '_ {
169 let ledger = self.workspace_root().map(CheckLedger::for_workspace);
170 move |entity: &crate::entity::Entity| match &ledger {
171 None => CheckState::NeverChecked,
172 Some(ledger) => derive_state(
173 ledger
174 .latest_for_kind(&entity.id.0, CheckKind::Verification)
175 .as_ref(),
176 &entity.content_hash,
177 ),
178 }
179 }
180
181 pub fn entity_check_state(
186 &self,
187 mem_name: &str,
188 entity_id: &str,
189 ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
190 self.find_mount(mem_name)?;
191 let current_hash = self
192 .store
193 .all_entities()
194 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
195 .map(|e| e.content_hash.clone())
196 .ok_or_else(|| EngineError::NotFound {
197 id: entity_id.to_string(),
198 })?;
199 let latest = self
200 .workspace_root()
201 .map(CheckLedger::for_workspace)
202 .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Verification));
203 Ok((derive_state(latest.as_ref(), ¤t_hash), latest))
204 }
205
206 pub fn entity_conformance_state(
212 &self,
213 mem_name: &str,
214 entity_id: &str,
215 ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
216 let mount = self.find_mount(mem_name)?;
217 let current_pin = mount.mount.schema.as_ref().map(|s| s.as_display());
218 let current_hash = self
219 .store
220 .all_entities()
221 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
222 .map(|e| e.content_hash.clone())
223 .ok_or_else(|| EngineError::NotFound {
224 id: entity_id.to_string(),
225 })?;
226 let latest = self
227 .workspace_root()
228 .map(CheckLedger::for_workspace)
229 .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Conformance));
230 Ok((
231 crate::check::derive_state_pinned(
232 latest.as_ref(),
233 ¤t_hash,
234 current_pin.as_deref(),
235 ),
236 latest,
237 ))
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use crate::check::{CheckKind, Verdict};
244 use crate::vcs::Actor;
245 use crate::workspace::MountCapability;
246
247 #[test]
254 fn conformance_refuses_without_a_schema_pin() {
255 let tmp = tempfile::TempDir::new().unwrap();
256 std::fs::write(
257 tmp.path().join("anything.md"),
258 "---\nid: anything\ntitle: Anything\ntype: note\n---\n\nBody.\n",
259 )
260 .unwrap();
261 let mut mount = crate::engine::test_helpers::folder_mount("m", tmp.path().to_path_buf());
262 mount.schema = None;
263 let mut engine = crate::Engine::from_mounts(vec![(
264 mount,
265 Box::new(crate::storage::FilesystemMemWriter::new(
266 tmp.path().to_path_buf(),
267 )) as Box<dyn crate::backend::MemBackend>,
268 )])
269 .unwrap();
270 let err = engine
271 .record_check(
272 "m",
273 "m--anything",
274 Verdict::Ok,
275 CheckKind::Conformance,
276 None,
277 Actor::Cli,
278 None,
279 )
280 .unwrap_err();
281 assert_eq!(err.code(), "MEM_QUARANTINED");
282 }
283
284 #[test]
288 fn check_refuses_read_only_mounts_typed() {
289 let tmp = tempfile::TempDir::new().unwrap();
290 let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
291 mount.capability = MountCapability::ReadOnly;
292 let mut engine = crate::Engine::from_mounts(vec![(
293 mount,
294 Box::new(crate::storage::FilesystemMemWriter::new(
295 tmp.path().to_path_buf(),
296 )) as Box<dyn crate::backend::MemBackend>,
297 )])
298 .unwrap();
299 let err = engine
300 .record_check(
301 "ro",
302 "ro--anything",
303 Verdict::Ok,
304 crate::check::CheckKind::Verification,
305 None,
306 Actor::Cli,
307 None,
308 )
309 .unwrap_err();
310 assert_eq!(err.code(), "READ_ONLY_MOUNT");
311 }
312}