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 fn entity_check_state(
165 &self,
166 mem_name: &str,
167 entity_id: &str,
168 ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
169 self.find_mount(mem_name)?;
170 let current_hash = self
171 .store
172 .all_entities()
173 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
174 .map(|e| e.content_hash.clone())
175 .ok_or_else(|| EngineError::NotFound {
176 id: entity_id.to_string(),
177 })?;
178 let latest = self
179 .workspace_root()
180 .map(CheckLedger::for_workspace)
181 .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Verification));
182 Ok((derive_state(latest.as_ref(), ¤t_hash), latest))
183 }
184
185 pub fn entity_conformance_state(
191 &self,
192 mem_name: &str,
193 entity_id: &str,
194 ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
195 let mount = self.find_mount(mem_name)?;
196 let current_pin = mount.mount.schema.as_ref().map(|s| s.as_display());
197 let current_hash = self
198 .store
199 .all_entities()
200 .find(|e| e.mem == mem_name && e.id.0 == entity_id)
201 .map(|e| e.content_hash.clone())
202 .ok_or_else(|| EngineError::NotFound {
203 id: entity_id.to_string(),
204 })?;
205 let latest = self
206 .workspace_root()
207 .map(CheckLedger::for_workspace)
208 .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Conformance));
209 Ok((
210 crate::check::derive_state_pinned(
211 latest.as_ref(),
212 ¤t_hash,
213 current_pin.as_deref(),
214 ),
215 latest,
216 ))
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use crate::check::{CheckKind, Verdict};
223 use crate::vcs::Actor;
224 use crate::workspace::MountCapability;
225
226 #[test]
233 fn conformance_refuses_without_a_schema_pin() {
234 let tmp = tempfile::TempDir::new().unwrap();
235 std::fs::write(
236 tmp.path().join("anything.md"),
237 "---\nid: anything\ntitle: Anything\ntype: note\n---\n\nBody.\n",
238 )
239 .unwrap();
240 let mut mount = crate::engine::test_helpers::folder_mount("m", tmp.path().to_path_buf());
241 mount.schema = None;
242 let mut engine = crate::Engine::from_mounts(vec![(
243 mount,
244 Box::new(crate::storage::FilesystemMemWriter::new(
245 tmp.path().to_path_buf(),
246 )) as Box<dyn crate::backend::MemBackend>,
247 )])
248 .unwrap();
249 let err = engine
250 .record_check(
251 "m",
252 "m--anything",
253 Verdict::Ok,
254 CheckKind::Conformance,
255 None,
256 Actor::Cli,
257 None,
258 )
259 .unwrap_err();
260 assert_eq!(err.code(), "MEM_QUARANTINED");
261 }
262
263 #[test]
267 fn check_refuses_read_only_mounts_typed() {
268 let tmp = tempfile::TempDir::new().unwrap();
269 let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
270 mount.capability = MountCapability::ReadOnly;
271 let mut engine = crate::Engine::from_mounts(vec![(
272 mount,
273 Box::new(crate::storage::FilesystemMemWriter::new(
274 tmp.path().to_path_buf(),
275 )) as Box<dyn crate::backend::MemBackend>,
276 )])
277 .unwrap();
278 let err = engine
279 .record_check(
280 "ro",
281 "ro--anything",
282 Verdict::Ok,
283 crate::check::CheckKind::Verification,
284 None,
285 Actor::Cli,
286 None,
287 )
288 .unwrap_err();
289 assert_eq!(err.code(), "READ_ONLY_MOUNT");
290 }
291}