memstead_base/engine/drift.rs
1//! Drift detection and per-mem change synthesis.
2//!
3//! `reload_if_stale` probes each candidate mount's `current_head()`
4//! cursor on every operation (no throttle), reloads mems whose
5//! on-disk state has advanced past the engine's cached head, and
6//! surfaces `MemReloaded` warnings for handlers that need to
7//! re-derive conclusions from a now-reloaded snapshot. `changes_since`
8//! produces
9//! the per-entity diff between a stored cursor and the backend's
10//! current state — folder mounts synthesise from the changelog, the
11//! git-branch hook walks the tree with rename detection, archive
12//! mounts return empty.
13
14use crate::backend::BackendError;
15use crate::workspace::MountStorage;
16
17use super::mutation::lookup_title_and_type;
18use super::{Engine, EngineError};
19
20impl Engine {
21 /// Reload-before-operation: before any read or write executes,
22 /// check the mem ref; if it advanced past the engine's cached
23 /// `last_known_head`, reload the affected mem(s) and return one
24 /// [`WarningHint::MemReloaded`] per reload so the caller can
25 /// surface the drift to the agent (the response *itself* already
26 /// carries fresh content — the warning explains why state
27 /// shifted).
28 ///
29 /// The ref check runs on **every** call — there is no throttle
30 /// window. A per-operation `current_head()` read is microseconds,
31 /// effectively free at LLM latencies, and a throttle that let an
32 /// operation execute against an already-moved ref would reintroduce
33 /// the exact silent-staleness this guards against. This is the
34 /// correctness floor: no operation acts on a projection that is
35 /// behind git truth.
36 ///
37 /// `mem = Some(name)` scopes the probe to one mount; `None`
38 /// scans every mount. Read handlers that target a known mem
39 /// (`memstead_entity` derives the mem from the id;
40 /// `memstead_changes_since` takes it as a param) and every mutation
41 /// (which knows its target mem) pass a name; tools that scan
42 /// multi-mem (`memstead_search` without a mem filter,
43 /// `memstead_overview`, `memstead_health`) pass `None`.
44 ///
45 /// Behaviour matrix per mount:
46 /// - cached `Some(old)` + on-disk `Some(new)`, `old != new` →
47 /// reload the mem, emit `MemReloaded`, refresh the cached
48 /// head to `new`.
49 /// - cached `None` + on-disk `Some(new)` → silently capture the
50 /// first observed head as the baseline (no warning — there's
51 /// no prior in-memory snapshot to be stale against).
52 /// - cached / on-disk match, on-disk `None` (folder, archive,
53 /// refdb hiccup), or `current_head` errors → no-op.
54 ///
55 /// Reload errors are warn-logged and the affected mem is
56 /// skipped — the caller's response is still served from the (now
57 /// stale) in-memory snapshot rather than failing the entire
58 /// request. The next operation retries.
59 ///
60 /// Cache invalidation rides on `reload_one_mem` — community
61 /// and search-index memos drop when any mem reloads.
62 pub fn reload_if_stale(&mut self, mem: Option<&str>) -> Vec<crate::ops::WarningHint> {
63 // Phase 0 — the lazy-mount first-read trigger. Every operation
64 // funnels through this check, so a deferred mem the operation's
65 // scope touches loads here, before the staleness probes — a
66 // scoped operation loads exactly its mem, a workspace-scoped one
67 // loads every deferred mem so no answer is computed over a
68 // partial store. Operations scoped to eager mems never load a
69 // lazy sibling as a side effect.
70 self.ensure_mems_loaded(mem);
71
72 // Phase 1 — pick candidate mem names that match the filter.
73 // Cloned so the immutable borrow doesn't survive into the
74 // mutation phase.
75 let candidates: Vec<String> = self
76 .mounts
77 .iter()
78 .filter(|m| mem.is_none_or(|v| m.mount.mem == v))
79 .map(|m| m.mount.mem.clone())
80 .collect();
81
82 if candidates.is_empty() {
83 return Vec::new();
84 }
85
86 // Phase 2 — probe every candidate's current head via the
87 // backend. Errors collapse to None so a transient backend
88 // hiccup doesn't surface as a warning; the next operation
89 // retries.
90 let probes: Vec<(String, Option<String>, Option<String>)> = candidates
91 .iter()
92 .filter_map(|name| {
93 let m = self.mounts.iter().find(|m| &m.mount.mem == name)?;
94 let new_head = m.backend.current_head().ok().flatten();
95 let cached = m.last_known_head.clone();
96 Some((name.clone(), cached, new_head))
97 })
98 .collect();
99
100 // Phase 3 — act on each probe. The drift case is the only
101 // one that calls `reload_one_mem`; every other arm just
102 // (for first-observation) captures the baseline head silently.
103 let mut warnings = Vec::new();
104 for (name, cached, new_head) in probes {
105 match (cached, new_head.clone()) {
106 (Some(old), Some(new)) if old != new => {
107 match self.reload_one_mem(&name) {
108 Ok(report) => {
109 warnings.push(crate::ops::WarningHint::MemReloaded {
110 mem: name.clone(),
111 old_head: old.clone(),
112 new_head: new.clone(),
113 entities_loaded: report.added.len() + report.changed.len(),
114 });
115 if let Some(state) =
116 self.mounts.iter_mut().find(|m| m.mount.mem == name)
117 {
118 state.last_known_head = Some(new.clone());
119 }
120 // Build the structured notice now — the
121 // backend's current head equals `new` and no
122 // follow-on write in this operation has
123 // committed yet, so the `old → new` delta
124 // describes only the sibling's change. Stashed
125 // for the response layer to drain.
126 let notice = self.mem_changed_notice(&name, &old, &new);
127 self.pending_mem_changed.push(notice);
128 // Emit the same change on the mem-change
129 // event channel: subscribers (SSE forwarders
130 // foremost) previously saw only this engine's
131 // own writes — a sibling process's commit,
132 // detected here as drift, is every bit as
133 // much a change. `n_commits: 1` per the
134 // watcher precedent (events batch by
135 // detection, not by commit archaeology).
136 self.emit_mem_changed(&crate::engine::events::MemChangedEvent {
137 mem: name.clone(),
138 head: new.clone(),
139 previous: old.clone(),
140 n_commits: 1,
141 });
142 }
143 Err(e) => {
144 tracing::warn!(
145 mem = %name,
146 error = %e,
147 "drift-detected reload_one_mem failed; serving \
148 stale snapshot — will retry on the next operation"
149 );
150 }
151 }
152 }
153 _ => {
154 if let Some(state) = self.mounts.iter_mut().find(|m| m.mount.mem == name)
155 && state.last_known_head.is_none()
156 {
157 state.last_known_head = new_head;
158 }
159 }
160 }
161 }
162
163 warnings
164 }
165
166 /// Mark `mount_idx`'s on-disk head as advanced by *this* engine's
167 /// own write so the next `reload_if_stale` doesn't surface
168 /// `MEM_RELOADED` for the commit we just produced. Mutation
169 /// paths call this immediately after `backend.commit` returns —
170 /// the cached `last_known_head` jumps straight to the new SHA
171 /// without going through a reload. Because every mutation runs
172 /// `reload_if_stale` for its target mem *before* committing,
173 /// the cached `last_known_head` is current at commit time, so
174 /// this advance is over a verified parent — it can never jump
175 /// the cache past an unobserved sibling commit. Cross-session and
176 /// out-of-band advances (sibling engine, manual `git pull`) that
177 /// land before the next operation still mismatch the cached value
178 /// and fire the warning as before.
179 ///
180 /// Empty SHA is a no-op (no commit landed — e.g. duplicate-add
181 /// relate). Backends that don't track a head (folder, archive)
182 /// leave `last_known_head` at `None` and still no-op via the
183 /// drift-check's `cached: None` branch.
184 pub(crate) fn record_self_write(&mut self, mount_idx: usize, commit_sha: &str) {
185 if commit_sha.is_empty() {
186 return;
187 }
188 // Capture the pre-write head + mem name so the
189 // `MemChangedEvent` we emit reflects the transition the
190 // current commit produced. We do this before mutating
191 // `last_known_head` because that field is the previous SHA
192 // from the event's point of view.
193 let (mem, previous) = match self.mounts.get(mount_idx) {
194 Some(state) => (
195 state.mount.mem.clone(),
196 state.last_known_head.clone().unwrap_or_default(),
197 ),
198 None => return,
199 };
200 // The recorded head must equal what the backend's next
201 // `current_head()` probe will report, or every self-write
202 // would look like sibling drift on the following operation.
203 // For git-branch backends the probe returns exactly the commit
204 // SHA just produced; the folder backend's drift cursor is the
205 // changelog's last-line timestamp (a different dialect from
206 // its synthetic commit id), so probe once and prefer the
207 // backend's answer. Probe errors fall back to the commit id —
208 // drift detection stays best-effort, never blocking the write.
209 let recorded = self
210 .mounts
211 .get(mount_idx)
212 .and_then(|state| state.backend.current_head().ok().flatten())
213 .unwrap_or_else(|| commit_sha.to_string());
214 if let Some(state) = self.mounts.get_mut(mount_idx) {
215 state.last_known_head = Some(recorded.clone());
216 }
217 // Skip emit when no SHA actually advanced — folder backends
218 // (and archive backends) carry `last_known_head: None` and
219 // pass `commit_sha = ""` in some paths; the early-return at
220 // the top already catches the explicit empty case, but
221 // `previous == commit_sha` covers idempotent re-writes that
222 // pass through the same write path (e.g. a relate that
223 // re-applies the same edge). Skipping keeps the event stream
224 // a stream of *changes* rather than a stream of *writes*.
225 if previous == recorded {
226 return;
227 }
228 let event = crate::engine::events::MemChangedEvent {
229 mem,
230 // The corrected head, not the raw commit id: consumers feed
231 // event heads into `changes_since`, whose folder dialect is
232 // the changelog-timestamp cursor `recorded` carries.
233 head: recorded,
234 previous,
235 n_commits: 1,
236 };
237 self.emit_mem_changed(&event);
238 }
239
240 /// Drain the reload-before-operation notices accumulated since the
241 /// last drain. The response layer calls this after an operation
242 /// completes to attach the structured `mem_changed` notice. Every
243 /// handler that can trigger a reload (directly via
244 /// [`Self::reload_if_stale`] or indirectly through a mutation) must
245 /// drain, or an undrained notice leaks into the next operation's
246 /// response.
247 pub fn take_mem_changed_notices(&mut self) -> Vec<crate::ops::MemChangedNotice> {
248 std::mem::take(&mut self.pending_mem_changed)
249 }
250
251 /// Build a [`crate::ops::MemChangedNotice`] describing the
252 /// per-entity delta a reload applied to `mem` (from `from_head`
253 /// to `to_head`). Derived from [`Self::changes_since`] so it
254 /// carries rename detection on git-branch mounts; on any backend
255 /// error (e.g. an unresolvable cursor) it falls back to an empty
256 /// delta — the heads alone still tell the agent the mem moved.
257 ///
258 /// Callers pair this with [`Self::reload_if_stale`]: a returned
259 /// [`crate::ops::WarningHint::MemReloaded`] carries the
260 /// `old_head` / `new_head` to pass here. The delta matches the
261 /// transition the reload applied (`changes_since` walks the same
262 /// `from_head → current` range).
263 pub fn mem_changed_notice(
264 &self,
265 mem: &str,
266 from_head: &str,
267 to_head: &str,
268 ) -> crate::ops::MemChangedNotice {
269 let changes = self
270 .changes_since(mem, from_head, None)
271 .map(|r| r.changes)
272 .unwrap_or_default();
273 crate::ops::MemChangedNotice::from_delta(
274 mem.to_string(),
275 from_head.to_string(),
276 to_head.to_string(),
277 changes,
278 )
279 }
280
281 /// Per-entity events for `mem` between `since` and the backend's
282 /// current state.
283 ///
284 /// 1. Resolves the mount (returns [`EngineError::UnknownMem`]
285 /// on unknown mem).
286 /// 2. Validates `rename_similarity` against
287 /// `[RENAME_SIMILARITY_MIN, RENAME_SIMILARITY_MAX]`. Out-of-range
288 /// values refuse with [`EngineError::InvalidInput`] carrying
289 /// `details.allowed_range` and `details.requested`. `None` falls
290 /// back to [`crate::ops::RENAME_SIMILARITY_DEFAULT`].
291 /// 3. Dispatches on the mount's `MountStorage`:
292 /// - Folder mounts synthesize from the JSONL changelog via
293 /// [`crate::ops::folder_changes_since`].
294 /// - Git-branch mounts call the registered
295 /// [`GitBranchOps::changes_since`] hook (real tree-diff with
296 /// rename detection); missing hook = full flavour not loaded
297 /// and the report comes back empty.
298 /// - Archive mounts return an empty report.
299 /// 4. Enriches each envelope's `title` / `entity_type` from the
300 /// in-memory store (best-effort — `Removed` envelopes always
301 /// leave both `None`; missing-from-store entities also leave
302 /// them `None`).
303 /// 5. Returns [`crate::ops::ChangesReport`] with `mem`,
304 /// `since` (echoed), `head` (backend-resolved current
305 /// cursor), enriched `changes`, and any clamping warnings.
306 pub fn changes_since(
307 &self,
308 mem: &str,
309 since: &str,
310 rename_similarity: Option<f32>,
311 ) -> Result<crate::ops::ChangesReport, EngineError> {
312 let m = self.find_mount(mem)?;
313
314 // Reject out-of-range `rename_similarity` early so CLI and MCP
315 // share one refusal surface.
316 // The prior clamp+warn shape silently accepted nonsense values
317 // (e.g. 1.5 ≡ 1.0); typed refusal gives the agent a recoverable
318 // signal.
319 if let Some(v) = rename_similarity
320 && !(crate::ops::RENAME_SIMILARITY_MIN..=crate::ops::RENAME_SIMILARITY_MAX).contains(&v)
321 {
322 return Err(EngineError::RenameSimilarityOutOfRange {
323 requested: v,
324 allowed_min: crate::ops::RENAME_SIMILARITY_MIN,
325 allowed_max: crate::ops::RENAME_SIMILARITY_MAX,
326 });
327 }
328 let clamped = rename_similarity.unwrap_or(crate::ops::RENAME_SIMILARITY_DEFAULT);
329
330 let backend_changes = match &m.mount.storage {
331 MountStorage::Folder { path } => {
332 crate::ops::folder_changes_since(path, mem, since).map_err(EngineError::Backend)?
333 }
334 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
335 Some(hook) => match (hook.changes_since)(gitdir, branch, mem, since, clamped) {
336 Ok(c) => c,
337 // Lift the backend's typed bad-`since` marker to a typed
338 // engine error carrying the untruncated SHA, parallel
339 // to the UNKNOWN_REMOTE / LOCAL_DIVERGENCE prefixes.
340 Err(BackendError::Other(msg)) if msg.starts_with("COMMIT_NOT_FOUND:") => {
341 let since = msg
342 .strip_prefix("COMMIT_NOT_FOUND:")
343 .unwrap_or_default()
344 .to_string();
345 return Err(EngineError::InvalidChangesCursor {
346 mem: mem.to_string(),
347 since,
348 });
349 }
350 Err(e) => return Err(EngineError::Backend(e)),
351 },
352 None => crate::ops::BackendChanges::empty_at(since),
353 },
354 // Archive is sealed; the in-memory backend keeps a
355 // provenance log but no cursor-addressable change history,
356 // so both yield no backend-derived changes here (the live
357 // playground stream rides the engine's event broadcast, not
358 // this path).
359 MountStorage::Archive { .. } | MountStorage::InMemory => {
360 crate::ops::BackendChanges::empty_at(since)
361 }
362 };
363
364 // Enrich each id-only envelope from the engine's store.
365 // `Removed` always leaves title / entity_type None — the
366 // entity is gone by definition; the post-reload store does
367 // not have it. Other variants populate when the lookup
368 // succeeds; missing ids stay None.
369 let enriched: Vec<crate::ops::ChangeEnvelope> = backend_changes
370 .changes
371 .into_iter()
372 .map(|env| match env {
373 crate::ops::ChangeEnvelope::Added { id, .. } => {
374 let (title, entity_type) = lookup_title_and_type(&self.store, &id);
375 crate::ops::ChangeEnvelope::Added {
376 id,
377 title,
378 entity_type,
379 }
380 }
381 crate::ops::ChangeEnvelope::Updated { id, .. } => {
382 let (title, entity_type) = lookup_title_and_type(&self.store, &id);
383 crate::ops::ChangeEnvelope::Updated {
384 id,
385 title,
386 entity_type,
387 }
388 }
389 crate::ops::ChangeEnvelope::Removed { id, .. } => {
390 crate::ops::ChangeEnvelope::Removed {
391 id,
392 title: None,
393 entity_type: None,
394 }
395 }
396 crate::ops::ChangeEnvelope::Renamed { from_id, to_id, .. } => {
397 let (title, entity_type) = lookup_title_and_type(&self.store, &to_id);
398 crate::ops::ChangeEnvelope::Renamed {
399 from_id,
400 to_id,
401 title,
402 entity_type,
403 }
404 }
405 })
406 .collect();
407
408 // Out-of-range `rename_similarity` is now a hard refusal (see
409 // early-return above); the response carries no clamping warning.
410 let warnings: Vec<crate::ops::WarningHint> = Vec::new();
411
412 // The backend populates
413 // notes + memstead_ref on every git-branch call (folder + archive
414 // backends leave them empty / None). Surface them
415 // unconditionally; the MCP `include_notes` parameter becomes
416 // a renderer-side filter rather than a separate engine call.
417 let notes = if backend_changes.notes.is_empty() && backend_changes.memstead_ref.is_none() {
418 None
419 } else {
420 Some(backend_changes.notes)
421 };
422 Ok(crate::ops::ChangesReport {
423 mem: mem.to_string(),
424 since: backend_changes.since,
425 head: backend_changes.head,
426 changes: enriched,
427 warnings,
428 notes,
429 memstead_ref: backend_changes.memstead_ref,
430 })
431 }
432
433 /// Fetch updates from `remote` into the workspace's mem-repo.
434 /// Advances remote-tracking refs only; the local branch pointer
435 /// is not moved.
436 ///
437 /// `refspecs` is forwarded verbatim to `git fetch`. An empty list
438 /// uses the remote's configured defaults.
439 ///
440 /// Refusal codes: `UNKNOWN_MEM`, `UNKNOWN_REMOTE`,
441 /// `INVALID_INPUT` (folder / archive mounts).
442 ///
443 /// V1 atomicity: schema-validation quarantine for
444 /// fetched commits is not yet wired. The remote-tracking refs
445 /// advance unconditionally on a successful fetch; downstream
446 /// schema validation runs on read via the engine's existing
447 /// reload pipeline.
448 pub fn fetch(
449 &self,
450 mem: &str,
451 remote: &str,
452 refspecs: &[String],
453 ) -> Result<crate::ops::FetchOutcome, EngineError> {
454 let m = self.find_mount(mem)?;
455 match &m.mount.storage {
456 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
457 Err(EngineError::InvalidInput(format!(
458 "mem '{mem}' is not git-backed — `memstead_fetch` requires a git-branch mount",
459 )))
460 }
461 MountStorage::GitBranch { gitdir, .. } => match self.git_branch_ops.as_ref() {
462 Some(hook) => (hook.fetch)(gitdir, remote, refspecs).map_err(|e| match e {
463 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
464 EngineError::UnknownRemote(
465 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
466 )
467 }
468 other => EngineError::Backend(other),
469 }),
470 None => Err(EngineError::Backend(BackendError::Other(
471 "git-branch fetch hook not installed (full flavour not loaded)".to_string(),
472 ))),
473 },
474 }
475 }
476
477 /// Pull updates from `remote` into the named mem's branch.
478 /// Fetches into the remote-tracking ref, runs a pre-merge schema
479 /// validation pass against the prospective state, then
480 /// fast-forwards the local branch. Refuses with
481 /// `LOCAL_DIVERGENCE` for diverged local branches and with
482 /// `SCHEMA_VIOLATION_IN_FETCH` when the prospective state fails
483 /// schema validation — in both refusal cases the local branch
484 /// pointer is untouched (the underlying fetch has updated
485 /// `refs/remotes/*` but the engine has not promoted the new
486 /// state).
487 pub fn pull(
488 &mut self,
489 mem: &str,
490 remote: &str,
491 ) -> Result<crate::ops::PullOutcome, EngineError> {
492 let mount_idx = self
493 .mounts
494 .iter()
495 .position(|m| m.mount.mem == mem)
496 .ok_or_else(|| self.unknown_mem_error(mem))?;
497
498 // Run the fetch step alone first so we can validate the
499 // prospective state against the schema before letting the
500 // pull's fast-forward land. Errors map to the typed surface
501 // just like a standalone `memstead_fetch` call.
502 let gitdir = match &self.mounts[mount_idx].mount.storage {
503 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
504 return Err(EngineError::InvalidInput(format!(
505 "mem '{mem}' is not git-backed — `memstead_pull` requires a git-branch mount",
506 )));
507 }
508 MountStorage::GitBranch { gitdir, .. } => gitdir.clone(),
509 };
510 let hook = self.git_branch_ops.ok_or_else(|| {
511 EngineError::Backend(BackendError::Other(
512 "git-branch pull hook not installed (full flavour not loaded)".to_string(),
513 ))
514 })?;
515 (hook.fetch)(&gitdir, remote, &[]).map_err(|e| match e {
516 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
517 EngineError::UnknownRemote(
518 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
519 )
520 }
521 other => EngineError::Backend(other),
522 })?;
523
524 // Pre-merge schema validation. The remote-tracking ref now
525 // points at the fetched tip; we walk it, parse every `.md`
526 // blob against the mem's pinned schema, and refuse the
527 // pull if any parse fails. The local branch pointer is still
528 // unchanged at this point — the refusal is fully atomic.
529 let remote_ref = format!("refs/remotes/{remote}/{mem}");
530 self.validate_ref_against_schema(&hook, &gitdir, mem, &remote_ref)?;
531
532 // Run the underlying pull (re-runs the fetch via git CLI, but
533 // that's a no-op cache-wise and keeps the fast-forward logic
534 // co-located with the rest of the transport implementation).
535 let outcome = (hook.pull)(&gitdir, remote, mem).map_err(|e| match e {
536 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
537 EngineError::UnknownRemote(
538 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
539 )
540 }
541 BackendError::Other(msg) if msg.starts_with("LOCAL_DIVERGENCE:") => {
542 let payload = msg.trim_start_matches("LOCAL_DIVERGENCE:");
543 let mut parts = payload.splitn(2, ':');
544 let v = parts.next().unwrap_or(mem).to_string();
545 let remote_ref = parts.next().unwrap_or("refs/remotes/?/?").to_string();
546 EngineError::LocalDivergence { mem: v, remote_ref }
547 }
548 other => EngineError::Backend(other),
549 })?;
550
551 // Rewind cached head + emit change event.
552 if outcome.previous_sha != outcome.new_sha {
553 if let Some(state) = self.mounts.get_mut(mount_idx) {
554 state.last_known_head = Some(outcome.new_sha.clone());
555 }
556 let event = crate::engine::events::MemChangedEvent {
557 mem: mem.to_string(),
558 head: outcome.new_sha.clone(),
559 previous: outcome.previous_sha.clone(),
560 n_commits: 1,
561 };
562 self.emit_mem_changed(&event);
563 }
564 Ok(outcome)
565 }
566
567 /// Push the named mem's branch to `remote`. Runs a pre-push
568 /// schema validation pass against the local branch tree; refuses
569 /// with `LOCAL_INVALID_STATE` when the local state fails schema
570 /// validation (the remote is not contacted in that case). Refuses
571 /// with `NON_FAST_FORWARD` when the push is not a fast-forward
572 /// and `force: false`; with `force: true` runs a
573 /// `--force-with-lease` push instead.
574 pub fn push(
575 &self,
576 mem: &str,
577 remote: &str,
578 force: bool,
579 ) -> Result<crate::ops::PushOutcome, EngineError> {
580 let m = self.find_mount(mem)?;
581 let gitdir = match &m.mount.storage {
582 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
583 return Err(EngineError::InvalidInput(format!(
584 "mem '{mem}' is not git-backed — `memstead_push` requires a git-branch mount",
585 )));
586 }
587 MountStorage::GitBranch { gitdir, .. } => gitdir.clone(),
588 };
589 let hook = self.git_branch_ops.ok_or_else(|| {
590 EngineError::Backend(BackendError::Other(
591 "git-branch push hook not installed (full flavour not loaded)".to_string(),
592 ))
593 })?;
594
595 // Pre-push schema validation: walk the local branch tree, run
596 // the mem's pinned schema over every `.md` blob. Any parse
597 // failure refuses the push with `LOCAL_INVALID_STATE` — the
598 // remote is not contacted.
599 let local_ref = format!("refs/heads/{mem}");
600 if let Err(EngineError::SchemaViolationInFetch { violations, .. }) =
601 self.validate_ref_against_schema(&hook, &gitdir, mem, &local_ref)
602 {
603 return Err(EngineError::LocalInvalidState {
604 mem: mem.to_string(),
605 remote: remote.to_string(),
606 detail: format!(
607 "{} violation(s) in local branch: {}",
608 violations.len(),
609 violations.join("; "),
610 ),
611 });
612 }
613
614 (hook.push)(&gitdir, remote, mem, force).map_err(|e| match e {
615 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REMOTE:") => {
616 EngineError::UnknownRemote(
617 msg.trim_start_matches("UNKNOWN_REMOTE:").trim().to_string(),
618 )
619 }
620 BackendError::Other(msg) if msg.starts_with("NON_FAST_FORWARD:") => {
621 let payload = msg.trim_start_matches("NON_FAST_FORWARD:");
622 let mut parts = payload.splitn(2, ':');
623 let v = parts.next().unwrap_or(mem).to_string();
624 let r = parts.next().unwrap_or(remote).to_string();
625 EngineError::NonFastForward { mem: v, remote: r }
626 }
627 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
628 EngineError::UnknownRef(msg.trim_start_matches("UNKNOWN_REF:").trim().to_string())
629 }
630 other => EngineError::Backend(other),
631 })
632 }
633
634 /// Configure (or re-point) a named remote on the workspace's
635 /// mem-repo, so `fetch` / `pull` / `push` have somewhere to go.
636 /// Upsert semantics — safe to re-run with a new URL. The mem-repo
637 /// is shared by every git-branch mount, so the op is
638 /// workspace-level: any git-branch mount locates it; refuses
639 /// `INVALID_INPUT` when the workspace has none.
640 pub fn remote_add(
641 &self,
642 name: &str,
643 url: &str,
644 ) -> Result<crate::ops::RemoteAddOutcome, EngineError> {
645 // Both values become git subprocess arguments — refuse shapes
646 // that would parse as flags.
647 if name.is_empty() || name.starts_with('-') || url.is_empty() || url.starts_with('-') {
648 return Err(EngineError::InvalidInput(format!(
649 "remote name and url must be non-empty and must not start with '-' \
650 (got name '{name}', url '{url}')",
651 )));
652 }
653 let gitdir = self
654 .mounts
655 .iter()
656 .find_map(|m| match &m.mount.storage {
657 MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
658 _ => None,
659 })
660 .ok_or_else(|| {
661 EngineError::InvalidInput(
662 "no git-branch mounts — `remote-add` requires a mem-repo workspace".to_string(),
663 )
664 })?;
665 let hook = self.git_branch_ops.ok_or_else(|| {
666 EngineError::Backend(BackendError::Other(
667 "git-branch remote_add hook not installed (full flavour not loaded)".to_string(),
668 ))
669 })?;
670 (hook.remote_add)(&gitdir, name, url).map_err(EngineError::Backend)
671 }
672
673 /// Pre-merge schema validation pass: walks every `.md` blob at
674 /// `ref_name` and runs `parse_entries` with the mem's pinned
675 /// schema. Returns `Ok(())` when the tree is schema-clean;
676 /// returns `EngineError::SchemaViolationInFetch` with the list of
677 /// per-entity violation messages otherwise. The validation is
678 /// strict on parse-time errors — any `(path, error)` pair from
679 /// `parse_entries` triggers a refusal.
680 ///
681 /// `ref_name` is the prospective state (a `refs/remotes/*` ref
682 /// for pull, `refs/heads/*` for push). The engine layer maps the
683 /// returned error into the surface code it needs
684 /// (`SCHEMA_VIOLATION_IN_FETCH` for pull, `LOCAL_INVALID_STATE`
685 /// for push).
686 fn validate_ref_against_schema(
687 &self,
688 hook: &crate::engine::GitBranchOps,
689 gitdir: &std::path::Path,
690 mem: &str,
691 ref_name: &str,
692 ) -> Result<(), EngineError> {
693 let schema = self
694 .schemas
695 .get(mem)
696 .ok_or_else(|| EngineError::SchemaNotFound {
697 mem: mem.to_string(),
698 pin: "<missing engine-side resolution>".to_string(),
699 // Internal invariant breach (an already-resolved schema
700 // absent from the per-mem map), not a source-resolution
701 // failure — no per-source diagnostics apply.
702 sources: Vec::new(),
703 install_hint: None,
704 })?
705 .clone();
706
707 let blobs = (hook.read_tree)(gitdir, ref_name).map_err(|e| match e {
708 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
709 EngineError::UnknownRef(msg.trim_start_matches("UNKNOWN_REF:").trim().to_string())
710 }
711 other => EngineError::Backend(other),
712 })?;
713
714 let mut source_entries: Vec<crate::entity::source::SourceEntry> = Vec::new();
715 for (rel_path, content) in blobs {
716 source_entries.push(crate::entity::source::SourceEntry {
717 relative_path: rel_path.clone(),
718 source_path: std::path::PathBuf::from(rel_path),
719 content,
720 });
721 }
722
723 // First pass: permissive parse via the engine's loader so we
724 // can build Entity values for the strict validator. The
725 // loader silently absorbs frontmatter / title / section
726 // drift; the strict pass below is what catches it.
727 let load_result = crate::entity::loader::parse_entries(
728 source_entries.clone(),
729 Vec::new(),
730 mem,
731 schema.as_ref(),
732 );
733 let mut violations: Vec<String> = load_result
734 .errors
735 .iter()
736 .map(|(path, msg)| format!("{}: {msg}", path.display()))
737 .collect();
738
739 // Strict per-entity validator: enforces "looks like a mem
740 // entity" invariants (frontmatter shape, title presence,
741 // required sections, unknown sections, relationship syntax,
742 // wiki-link shape) that the permissive loader doesn't refuse.
743 // Re-runs against the same source bytes so unparseable
744 // frontmatter surfaces here even when the loader's tolerant
745 // path produces an Entity stub.
746 let entities_by_path: std::collections::HashMap<String, &crate::entity::Entity> =
747 load_result
748 .entities
749 .iter()
750 .map(|p| (p.entity.file_path.clone(), &p.entity))
751 .collect();
752 for source in &source_entries {
753 let Some(entity) = entities_by_path.get(&source.relative_path) else {
754 continue;
755 };
756 let type_def = match schema.get_type(&entity.entity_type) {
757 Some(t) => t,
758 None => {
759 violations.push(format!(
760 "{}: unknown entity_type '{}' in schema",
761 source.relative_path, entity.entity_type,
762 ));
763 continue;
764 }
765 };
766 if let Err(e) = crate::validator::strict::validate_strict(
767 &source.content,
768 entity,
769 type_def.as_ref(),
770 &source.relative_path,
771 ) {
772 violations.push(format!("{}: {e}", source.relative_path));
773 }
774 }
775
776 if violations.is_empty() {
777 Ok(())
778 } else {
779 Err(EngineError::SchemaViolationInFetch {
780 mem: mem.to_string(),
781 ref_name: ref_name.to_string(),
782 violations,
783 })
784 }
785 }
786
787 /// Reset a mem's branch pointer to `target_sha`. The only
788 /// engine surface that moves a branch pointer over existing
789 /// commits — every other mutation appends. Refuses if any commit
790 /// that would be discarded by the reset is already reachable from
791 /// a `refs/remotes/*` ref (the engine's definition of "pushed").
792 ///
793 /// `target_sha` accepts anything `gix::rev_parse_single` admits:
794 /// a SHA, an abbreviated SHA, a branch name, a tag. The branch
795 /// itself (`refs/heads/<mem>`) must exist.
796 ///
797 /// Refusal codes:
798 /// - [`EngineError::UnknownMem`] (`UNKNOWN_MEM`)
799 /// - [`EngineError::UnknownRef`] (`UNKNOWN_REF`) — branch or
800 /// target ref does not resolve.
801 /// - [`EngineError::PushedCommitsProtected`]
802 /// (`PUSHED_COMMITS_PROTECTED`) — at least one discarded commit
803 /// is pushed. The error carries the offending SHAs verbatim.
804 /// - [`EngineError::InvalidInput`] (`INVALID_INPUT`) — mem is
805 /// folder / archive-backed (history rewriting only makes sense
806 /// for git-branch mounts).
807 ///
808 /// Emits a [`crate::engine::events::MemChangedEvent`] on
809 /// success when the SHA actually changed; the reset's effect is
810 /// observable through the same change-event surface every commit
811 /// flows through. Engine's cached `last_known_head` for the
812 /// affected mount is rewound to the new SHA so the next drift
813 /// probe doesn't flag the reset as a sibling-writer surprise.
814 pub fn branch_reset(
815 &mut self,
816 mem: &str,
817 target_sha: &str,
818 expected_head: Option<&str>,
819 ) -> Result<crate::ops::BranchResetOutcome, EngineError> {
820 let mount_idx = self
821 .mounts
822 .iter()
823 .position(|m| m.mount.mem == mem)
824 .ok_or_else(|| self.unknown_mem_error(mem))?;
825
826 // History rewriting is a write — read-only and archive mounts
827 // refuse before any dispatch (parity with the mutation surface).
828 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
829 return Err(EngineError::ReadOnlyMount(mem.to_string()));
830 }
831
832 let outcome = match &self.mounts[mount_idx].mount.storage {
833 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
834 return Err(EngineError::InvalidInput(format!(
835 "mem '{mem}' is not git-backed — `memstead_branch_reset` requires a git-branch mount",
836 )));
837 }
838 MountStorage::GitBranch { gitdir, branch } => match self.git_branch_ops.as_ref() {
839 Some(hook) => (hook.branch_reset)(gitdir, branch, target_sha, expected_head)
840 .map_err(|e| match e {
841 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
842 let raw = msg.trim_start_matches("UNKNOWN_REF:").trim().to_string();
843 EngineError::UnknownRef(raw)
844 }
845 BackendError::Other(msg) if msg.starts_with("EXPECTED_HEAD_MISMATCH:") => {
846 let current = msg
847 .trim_start_matches("EXPECTED_HEAD_MISMATCH:")
848 .trim()
849 .to_string();
850 EngineError::BranchResetHeadMoved {
851 mem: mem.to_string(),
852 expected: expected_head.unwrap_or_default().to_string(),
853 current,
854 }
855 }
856 BackendError::Other(msg)
857 if msg.starts_with("PUSHED_COMMITS_PROTECTED:") =>
858 {
859 let payload =
860 msg.trim_start_matches("PUSHED_COMMITS_PROTECTED:").trim();
861 let pushed_shas = payload
862 .split(',')
863 .map(|s| s.trim().to_string())
864 .filter(|s| !s.is_empty())
865 .collect();
866 EngineError::PushedCommitsProtected {
867 mem: mem.to_string(),
868 target_sha: target_sha.to_string(),
869 pushed_shas,
870 }
871 }
872 other => EngineError::Backend(other),
873 })?,
874 None => {
875 return Err(EngineError::Backend(BackendError::Other(
876 "git-branch branch_reset hook not installed (full flavour not loaded)"
877 .to_string(),
878 )));
879 }
880 },
881 };
882
883 // Rewind the engine's cached HEAD so subsequent drift probes
884 // don't surface MEM_RELOADED for the reset we just made.
885 // Then emit a change event so subscribers see the transition
886 // (skipping the no-op case where previous == new).
887 if outcome.previous_sha != outcome.new_sha {
888 if let Some(state) = self.mounts.get_mut(mount_idx) {
889 state.last_known_head = Some(outcome.new_sha.clone());
890 }
891 let event = crate::engine::events::MemChangedEvent {
892 mem: mem.to_string(),
893 head: outcome.new_sha.clone(),
894 previous: outcome.previous_sha.clone(),
895 // n_commits stays at 1 for reset events. The wire
896 // shape is the same `MemChangedEvent` consumers
897 // already key on; semantics: "the head moved by this
898 // operation". Replay-aware consumers branch on the
899 // commit-vs-reset distinction by inspecting the
900 // produced commit (a reset's new head is an existing
901 // commit, not a freshly minted one).
902 n_commits: 1,
903 };
904 self.emit_mem_changed(&event);
905 }
906 Ok(outcome)
907 }
908
909 /// Cross-mem references that a reset of `mem` to `target_sha` would
910 /// strand: incoming edges from entities in *other* mems whose target
911 /// exists at the current head but would not exist at the target
912 /// commit — entities created after the target, or renamed to their
913 /// current id after it (the reset re-materialises the old id, so
914 /// references to the new id dangle either way).
915 ///
916 /// A read — computes against the live store and the commit history,
917 /// moves nothing. The human surface calls this fresh at
918 /// confirmation-dialog time and warns before `branch_reset`. Sorted
919 /// (from_id, to_id, rel_type) for stable rendering.
920 ///
921 /// Refusals mirror `changes_since`: `UnknownMem`, `InvalidCursor`
922 /// for an unresolvable `target_sha`, `InvalidInput` for
923 /// non-git-backed mounts.
924 pub fn branch_reset_stranded_refs(
925 &self,
926 mem: &str,
927 target_sha: &str,
928 ) -> Result<Vec<crate::ops::StrandedCrossMemRef>, EngineError> {
929 use crate::ops::ChangeEnvelope;
930
931 let report = self.changes_since(mem, target_sha, None)?;
932 let mut discarded: std::collections::HashSet<String> = std::collections::HashSet::new();
933 for change in &report.changes {
934 match change {
935 ChangeEnvelope::Added { id, .. } => {
936 discarded.insert(id.to_string());
937 }
938 ChangeEnvelope::Renamed { to_id, .. } => {
939 discarded.insert(to_id.to_string());
940 }
941 ChangeEnvelope::Updated { .. } | ChangeEnvelope::Removed { .. } => {}
942 }
943 }
944 if discarded.is_empty() {
945 return Ok(Vec::new());
946 }
947
948 let mut stranded: Vec<crate::ops::StrandedCrossMemRef> = self
949 .store
950 .all_entities()
951 .filter(|e| e.mem != mem)
952 .flat_map(|e| {
953 e.relationships
954 .iter()
955 .filter(|r| discarded.contains(&r.target.to_string()))
956 .map(|r| crate::ops::StrandedCrossMemRef {
957 from_id: e.id.to_string(),
958 from_mem: e.mem.clone(),
959 to_id: r.target.to_string(),
960 rel_type: r.rel_type.clone(),
961 })
962 .collect::<Vec<_>>()
963 })
964 .collect();
965 stranded.sort_by(|a, b| {
966 (&a.from_id, &a.to_id, &a.rel_type).cmp(&(&b.from_id, &b.to_id, &b.rel_type))
967 });
968 Ok(stranded)
969 }
970
971 /// Two-ref structural diff. Produces a per-entity [`crate::ops::Diff`]
972 /// comparing the trees at `ref_a` and `ref_b` for the named
973 /// mem's storage. Folder and archive backends carry no git
974 /// refs and refuse via [`EngineError::InvalidInput`]; the
975 /// git-branch backend routes through [`GitBranchOps::diff`] when
976 /// the full flavour is loaded.
977 ///
978 /// `mem` selects the storage context (the gitdir, for
979 /// git-branch mounts). `ref_a` / `ref_b` are arbitrary refs the
980 /// underlying git layer accepts — branch names, commit SHAs, tag
981 /// names — so cross-mem diffs work via fully-qualified refs
982 /// (`refs/heads/<other-mem>`) without a separate API.
983 ///
984 /// Refusal codes:
985 /// - [`EngineError::UnknownMem`] (`UNKNOWN_MEM`) — no mount
986 /// for `mem`.
987 /// - [`EngineError::UnknownRef`] (`UNKNOWN_REF`) — either ref
988 /// does not resolve. Surfaces verbatim from the git layer's
989 /// `rev_parse` refusal.
990 /// - [`EngineError::RenameSimilarityOutOfRange`] (`INVALID_INPUT`)
991 /// — `config.rename_similarity` outside `[0.1, 1.0]`.
992 /// - [`EngineError::InvalidInput`] (`INVALID_INPUT`) — mem is
993 /// folder or archive-backed (no refs to diff).
994 pub fn diff(
995 &self,
996 mem: &str,
997 ref_a: &str,
998 ref_b: &str,
999 config: Option<crate::ops::DiffConfig>,
1000 ) -> Result<crate::ops::Diff, EngineError> {
1001 let m = self.find_mount(mem)?;
1002 let config = config.unwrap_or_default();
1003
1004 if config.rename_similarity < crate::ops::RENAME_SIMILARITY_MIN
1005 || config.rename_similarity > crate::ops::RENAME_SIMILARITY_MAX
1006 {
1007 return Err(EngineError::RenameSimilarityOutOfRange {
1008 requested: config.rename_similarity,
1009 allowed_min: crate::ops::RENAME_SIMILARITY_MIN,
1010 allowed_max: crate::ops::RENAME_SIMILARITY_MAX,
1011 });
1012 }
1013
1014 match &m.mount.storage {
1015 MountStorage::Folder { .. } | MountStorage::Archive { .. } | MountStorage::InMemory => {
1016 Err(EngineError::InvalidInput(format!(
1017 "mem '{mem}' is not git-backed — `memstead_diff` requires a git-branch mount",
1018 )))
1019 }
1020 MountStorage::GitBranch { gitdir, .. } => match self.git_branch_ops.as_ref() {
1021 Some(hook) => {
1022 (hook.diff)(gitdir, mem, ref_a, ref_b, &config).map_err(|e| match e {
1023 // Map the standard backend-side "ref not found" shape into the
1024 // typed engine-level refusal. The git-branch dispatcher uses
1025 // `BackendError::Other` with a leading marker so the engine can
1026 // recover the typed code without re-parsing the message.
1027 BackendError::Other(msg) if msg.starts_with("UNKNOWN_REF:") => {
1028 let raw = msg.trim_start_matches("UNKNOWN_REF:").trim().to_string();
1029 EngineError::UnknownRef(raw)
1030 }
1031 other => EngineError::Backend(other),
1032 })
1033 }
1034 None => Err(EngineError::Backend(BackendError::Other(
1035 "git-branch diff hook not installed (full flavour not loaded)".to_string(),
1036 ))),
1037 },
1038 }
1039 }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use std::path::{Path, PathBuf};
1045
1046 use tempfile::TempDir;
1047
1048 use crate::backend::{BackendError, MemBackend};
1049 use crate::engine::test_helpers::*;
1050 use crate::engine::{DeleteEntityArgs, Engine, EngineError};
1051 use crate::entity::EntityId;
1052
1053 use crate::provenance::Provenance;
1054 use crate::storage::ArchiveBackend;
1055 use crate::vcs::CommitContext;
1056 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1057
1058 #[test]
1059 fn engine_diff_unknown_mem_returns_typed_error() {
1060 let tmp = TempDir::new().unwrap();
1061 let engine = build_demo_engine(&tmp);
1062 let err = engine.diff("nope", "a", "b", None).unwrap_err();
1063 assert!(matches!(err, EngineError::UnknownMem(v) if v == "nope"));
1064 }
1065
1066 #[test]
1067 fn engine_diff_folder_mount_refuses_with_invalid_input() {
1068 let tmp = TempDir::new().unwrap();
1069 let engine = build_demo_engine(&tmp);
1070 // Folder backend has no git refs — refuse cleanly via the
1071 // typed `INVALID_INPUT` code rather than collapsing through
1072 // the backend layer.
1073 let err = engine.diff("specs", "a", "b", None).unwrap_err();
1074 match err {
1075 EngineError::InvalidInput(msg) => {
1076 assert!(msg.contains("not git-backed"), "unexpected msg: {msg}");
1077 }
1078 other => panic!("expected InvalidInput, got {other:?}"),
1079 }
1080 }
1081
1082 #[test]
1083 fn engine_diff_rename_similarity_out_of_range_refuses() {
1084 let tmp = TempDir::new().unwrap();
1085 let engine = build_demo_engine(&tmp);
1086 let bad = crate::ops::DiffConfig {
1087 rename_similarity: 2.0,
1088 ..Default::default()
1089 };
1090 let err = engine.diff("specs", "a", "b", Some(bad)).unwrap_err();
1091 assert!(matches!(
1092 err,
1093 EngineError::RenameSimilarityOutOfRange { .. }
1094 ));
1095 }
1096
1097 #[test]
1098 fn engine_changes_since_archive_mount_returns_empty_report() {
1099 // Archive backends have no diff surface; the engine wrapper
1100 // produces an empty `ChangesReport` with the cursor echoed.
1101 let tmp = TempDir::new().unwrap();
1102 let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"a")]);
1103 let mount = archive_mount("ext", archive_path.clone());
1104 let engine = Engine::from_mounts(vec![(
1105 mount,
1106 Box::new(ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1107 )])
1108 .unwrap();
1109 let report = engine.changes_since("ext", "abc", None).expect("known mem");
1110 assert_eq!(report.mem, "ext");
1111 assert_eq!(report.since, "abc");
1112 assert_eq!(report.head, "abc");
1113 assert!(report.changes.is_empty());
1114 assert!(report.warnings.is_empty());
1115 }
1116
1117 #[test]
1118 fn engine_changes_since_unknown_mem_returns_typed_error() {
1119 let tmp = TempDir::new().unwrap();
1120 let engine = build_demo_engine(&tmp);
1121 let err = engine
1122 .changes_since("does-not-exist", "abc", None)
1123 .unwrap_err();
1124 assert!(matches!(err, EngineError::UnknownMem(_)));
1125 }
1126
1127 #[test]
1128 fn engine_changes_since_refuses_rename_similarity_below_min() {
1129 let tmp = TempDir::new().unwrap();
1130 let engine = build_demo_engine(&tmp);
1131 // 0.05 is below RENAME_SIMILARITY_MIN (0.1); typed refusal,
1132 // not a silent clamp.
1133 let err = engine
1134 .changes_since("specs", "abc", Some(0.05))
1135 .expect_err("out-of-range refuses");
1136 match err {
1137 EngineError::RenameSimilarityOutOfRange {
1138 requested,
1139 allowed_min,
1140 allowed_max,
1141 } => {
1142 assert!((requested - 0.05).abs() < f32::EPSILON);
1143 assert!((allowed_min - crate::ops::RENAME_SIMILARITY_MIN).abs() < f32::EPSILON);
1144 assert!((allowed_max - crate::ops::RENAME_SIMILARITY_MAX).abs() < f32::EPSILON);
1145 }
1146 other => panic!("expected RenameSimilarityOutOfRange, got {other:?}"),
1147 }
1148 }
1149
1150 #[test]
1151 fn engine_changes_since_refuses_rename_similarity_above_max() {
1152 let tmp = TempDir::new().unwrap();
1153 let engine = build_demo_engine(&tmp);
1154 // 1.5 is above RENAME_SIMILARITY_MAX (1.0); typed refusal.
1155 let err = engine
1156 .changes_since("specs", "abc", Some(1.5))
1157 .expect_err("out-of-range refuses");
1158 match err {
1159 EngineError::RenameSimilarityOutOfRange { requested, .. } => {
1160 assert!((requested - 1.5).abs() < f32::EPSILON);
1161 }
1162 other => panic!("expected RenameSimilarityOutOfRange, got {other:?}"),
1163 }
1164 }
1165
1166 #[test]
1167 fn engine_changes_since_no_warning_when_rename_similarity_in_range() {
1168 let tmp = TempDir::new().unwrap();
1169 let engine = build_demo_engine(&tmp);
1170 // 0.5 is comfortably inside the valid range; no warning.
1171 let report = engine
1172 .changes_since("specs", "abc", Some(0.5))
1173 .expect("known mem");
1174 assert!(report.warnings.is_empty());
1175 }
1176
1177 #[test]
1178 fn engine_changes_since_no_warning_when_rename_similarity_omitted() {
1179 // Caller passes None → wrapper falls back to the default;
1180 // no clamping, no warning.
1181 let tmp = TempDir::new().unwrap();
1182 let engine = build_demo_engine(&tmp);
1183 let report = engine
1184 .changes_since("specs", "abc", None)
1185 .expect("known mem");
1186 assert!(report.warnings.is_empty());
1187 }
1188
1189 #[test]
1190 fn engine_changes_since_enriches_envelope_title_and_type_from_store() {
1191 // `build_demo_engine` creates three entities via the engine's
1192 // mutation pipeline, which appends Create events to the folder
1193 // backend's changelog. `Engine::changes_since` synthesises
1194 // BackendChanges from the changelog (id-only envelopes), then
1195 // enriches title / entity_type from the in-memory store.
1196 let tmp = TempDir::new().unwrap();
1197 let engine = build_demo_engine(&tmp);
1198 let report = engine
1199 .changes_since("specs", crate::ops::EMPTY_TREE_SHA, None)
1200 .expect("known mem");
1201
1202 // Three Create events → three Added envelopes, each enriched.
1203 assert_eq!(report.changes.len(), 3);
1204 for env in &report.changes {
1205 match env {
1206 crate::ops::ChangeEnvelope::Added {
1207 id,
1208 title,
1209 entity_type,
1210 } => {
1211 assert!(title.is_some(), "title enriched for {id}");
1212 assert_eq!(entity_type.as_deref(), Some("spec"), "type for {id}");
1213 }
1214 other => panic!("expected Added envelope, got {other:?}"),
1215 }
1216 }
1217 }
1218
1219 #[test]
1220 fn engine_changes_since_removed_envelope_keeps_title_and_type_none() {
1221 // Create-then-delete net effect = Removed. Even though the
1222 // store may still know the entity, the engine wrapper
1223 // unconditionally strips title / entity_type on Removed.
1224 let tmp = TempDir::new().unwrap();
1225 let mut engine = build_demo_engine(&tmp);
1226 let (actor, client) = cli_actor();
1227 let id = EntityId::new("specs", "lonely-three");
1228 let hash = engine
1229 .get_entity(&id)
1230 .expect("seeded entity present")
1231 .content_hash
1232 .clone();
1233 engine
1234 .delete_entity(
1235 DeleteEntityArgs {
1236 id: id.clone(),
1237 expected_hash: Some(hash),
1238 },
1239 actor,
1240 Some(&client),
1241 None,
1242 )
1243 .unwrap();
1244 let report = engine
1245 .changes_since("specs", crate::ops::EMPTY_TREE_SHA, None)
1246 .unwrap();
1247 let removed = report
1248 .changes
1249 .iter()
1250 .find(|e| {
1251 matches!(e,
1252 crate::ops::ChangeEnvelope::Removed { id: rid, .. } if rid == &id)
1253 })
1254 .expect("removed envelope for lonely-three");
1255 match removed {
1256 crate::ops::ChangeEnvelope::Removed {
1257 title, entity_type, ..
1258 } => {
1259 assert!(title.is_none());
1260 assert!(entity_type.is_none());
1261 }
1262 other => panic!("expected Removed, got {other:?}"),
1263 }
1264 }
1265
1266 // ---- Engine::cross_mem_link_allowed ---------------------------
1267
1268 #[test]
1269 fn reload_if_stale_returns_empty_for_folder_only_engine() {
1270 // Folder mems now carry a changelog-derived drift cursor, so
1271 // this pins the QUIET case: no sibling wrote between probes,
1272 // so repeated checks stay warning-free (the first probe
1273 // captures the baseline silently, the second sees no advance).
1274 let tmp = TempDir::new().unwrap();
1275 let mut engine = build_demo_engine(&tmp);
1276 let warnings = engine.reload_if_stale(None);
1277 assert!(warnings.is_empty());
1278 let warnings = engine.reload_if_stale(Some("specs"));
1279 assert!(warnings.is_empty());
1280 }
1281
1282 #[test]
1283 fn reload_if_stale_short_circuits_for_unknown_mem_filter() {
1284 // Filtering by an unknown mem produces zero candidates;
1285 // the method returns an empty Vec without panicking.
1286 let tmp = TempDir::new().unwrap();
1287 let mut engine = build_demo_engine(&tmp);
1288 let warnings = engine.reload_if_stale(Some("does-not-exist"));
1289 assert!(warnings.is_empty());
1290 }
1291
1292 /// Test fixture: a `MemBackend` whose `current_head` and
1293 /// (read-side) entity surface are externally mutable so a test
1294 /// can simulate a sibling writer advancing the head between
1295 /// drift-check probes. Write methods are no-ops; the engine's
1296 /// drift-check path never invokes them.
1297 struct ManualHeadBackend {
1298 head: std::sync::Mutex<Option<String>>,
1299 entities: std::sync::Mutex<Vec<(PathBuf, Vec<u8>)>>,
1300 }
1301
1302 impl ManualHeadBackend {
1303 fn new(initial_head: Option<&str>) -> Self {
1304 Self {
1305 head: std::sync::Mutex::new(initial_head.map(String::from)),
1306 entities: std::sync::Mutex::new(Vec::new()),
1307 }
1308 }
1309
1310 fn set_head(&self, head: Option<&str>) {
1311 *self.head.lock().unwrap() = head.map(String::from);
1312 }
1313 }
1314
1315 impl MemBackend for ManualHeadBackend {
1316 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1317 Ok(self
1318 .entities
1319 .lock()
1320 .unwrap()
1321 .iter()
1322 .map(|(p, _)| p.clone())
1323 .collect())
1324 }
1325 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1326 Ok(self
1327 .entities
1328 .lock()
1329 .unwrap()
1330 .iter()
1331 .find(|(p, _)| p == rel)
1332 .map(|(_, b)| b.clone()))
1333 }
1334 fn write_entity(&self, _: &Path, _: &[u8]) -> Result<(), BackendError> {
1335 Ok(())
1336 }
1337 fn delete_entity(&self, _: &Path) -> Result<(), BackendError> {
1338 Ok(())
1339 }
1340 fn move_entity(&self, _: &Path, _: &Path) -> Result<(), BackendError> {
1341 Ok(())
1342 }
1343 fn commit(
1344 &self,
1345 _: &str,
1346 _: &CommitContext<'_>,
1347 ) -> Result<crate::storage::CommitId, BackendError> {
1348 Ok("synthetic".to_string())
1349 }
1350 fn append_provenance(&self, _: &Provenance) -> Result<(), BackendError> {
1351 Ok(())
1352 }
1353 fn read_provenance(&self, _: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1354 Ok(Vec::new())
1355 }
1356 fn current_head(&self) -> Result<Option<String>, BackendError> {
1357 Ok(self.head.lock().unwrap().clone())
1358 }
1359 }
1360
1361 #[test]
1362 fn reload_if_stale_emits_mem_reloaded_when_head_advances() {
1363 // Use an Arc<ManualHeadBackend> so the test retains a handle
1364 // for mutation after the engine has taken ownership of a
1365 // Box<dyn MemBackend> wrapper around it.
1366 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1367 impl MemBackend for ArcBackend {
1368 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1369 self.0.list_entities()
1370 }
1371 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1372 self.0.read_entity(rel)
1373 }
1374 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1375 self.0.write_entity(p, b)
1376 }
1377 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1378 self.0.delete_entity(p)
1379 }
1380 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1381 self.0.move_entity(f, t)
1382 }
1383 fn commit(
1384 &self,
1385 m: &str,
1386 c: &CommitContext<'_>,
1387 ) -> Result<crate::storage::CommitId, BackendError> {
1388 self.0.commit(m, c)
1389 }
1390 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1391 self.0.append_provenance(r)
1392 }
1393 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1394 self.0.read_provenance(c)
1395 }
1396 fn current_head(&self) -> Result<Option<String>, BackendError> {
1397 self.0.current_head()
1398 }
1399 }
1400
1401 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1402 let backend = Box::new(ArcBackend(shared.clone()));
1403 let mount = Mount {
1404 mem: "specs".to_string(),
1405 schema: Some(pin("default")),
1406 storage: MountStorage::Folder {
1407 path: PathBuf::from("/dev/null"),
1408 },
1409 capability: MountCapability::Write,
1410 lifecycle: MountLifecycle::Eager,
1411 cross_linkable: true,
1412 migration_target: None,
1413 };
1414 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1415
1416 // No drift on first probe — cached==new.
1417 let warnings = engine.reload_if_stale(Some("specs"));
1418 assert!(warnings.is_empty());
1419
1420 // Sibling writer advances the head.
1421 shared.set_head(Some("bbb"));
1422
1423 let warnings = engine.reload_if_stale(Some("specs"));
1424 assert_eq!(warnings.len(), 1);
1425 match &warnings[0] {
1426 crate::ops::WarningHint::MemReloaded {
1427 mem,
1428 old_head,
1429 new_head,
1430 ..
1431 } => {
1432 assert_eq!(mem, "specs");
1433 assert_eq!(old_head, "aaa");
1434 assert_eq!(new_head, "bbb");
1435 }
1436 other => panic!("expected MemReloaded, got {other:?}"),
1437 }
1438
1439 // Drift cleared — the engine's cached head now matches the
1440 // backend's current head; another probe is a no-op.
1441 let warnings = engine.reload_if_stale(Some("specs"));
1442 assert!(warnings.is_empty());
1443 }
1444
1445 #[test]
1446 fn mem_drifted_tracks_sibling_advance_until_reload() {
1447 // The read-only drift probe (built for the retired macOS app's roster): it reports
1448 // `true` once a sibling writer advances the backend past the
1449 // engine's cached head, *without* itself reloading, and clears
1450 // after the engine re-reads.
1451 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1452 impl MemBackend for ArcBackend {
1453 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1454 self.0.list_entities()
1455 }
1456 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1457 self.0.read_entity(rel)
1458 }
1459 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1460 self.0.write_entity(p, b)
1461 }
1462 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1463 self.0.delete_entity(p)
1464 }
1465 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1466 self.0.move_entity(f, t)
1467 }
1468 fn commit(
1469 &self,
1470 m: &str,
1471 c: &CommitContext<'_>,
1472 ) -> Result<crate::storage::CommitId, BackendError> {
1473 self.0.commit(m, c)
1474 }
1475 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1476 self.0.append_provenance(r)
1477 }
1478 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1479 self.0.read_provenance(c)
1480 }
1481 fn current_head(&self) -> Result<Option<String>, BackendError> {
1482 self.0.current_head()
1483 }
1484 }
1485
1486 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1487 let backend = Box::new(ArcBackend(shared.clone()));
1488 let mount = Mount {
1489 mem: "specs".to_string(),
1490 schema: Some(pin("default")),
1491 storage: MountStorage::Folder {
1492 path: PathBuf::from("/dev/null"),
1493 },
1494 capability: MountCapability::Write,
1495 lifecycle: MountLifecycle::Eager,
1496 cross_linkable: true,
1497 migration_target: None,
1498 };
1499 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1500
1501 // Fresh boot: cached == live, no drift.
1502 assert!(!engine.mem_drifted("specs").unwrap());
1503
1504 // Sibling writer advances the head — drift is visible WITHOUT a reload.
1505 shared.set_head(Some("bbb"));
1506 assert!(engine.mem_drifted("specs").unwrap());
1507 // Probing did not reload — still drifted on a second read.
1508 assert!(engine.mem_drifted("specs").unwrap());
1509
1510 // Re-reading through the engine clears it.
1511 let _ = engine.reload_if_stale(Some("specs"));
1512 assert!(!engine.mem_drifted("specs").unwrap());
1513
1514 // Unknown mem errors rather than reporting a bogus `false`.
1515 assert!(matches!(
1516 engine.mem_drifted("nope"),
1517 Err(EngineError::UnknownMem(_))
1518 ));
1519 }
1520
1521 #[test]
1522 fn reload_one_mem_report_head_before_is_prior_cursor_and_advances() {
1523 // Regression for the reload→changes_since recipe. `head_before`
1524 // must report the engine's PRIOR cursor (the SHA it last knew),
1525 // not the post-drift on-disk tip — otherwise
1526 // `changes_since(since=head_before)` spans an empty range in
1527 // exactly the sibling-drift case the recipe targets. The reload
1528 // must also advance the cursor to the new tip so the next
1529 // staleness probe is a no-op rather than a spurious reload.
1530 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1531 impl MemBackend for ArcBackend {
1532 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1533 self.0.list_entities()
1534 }
1535 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1536 self.0.read_entity(rel)
1537 }
1538 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1539 self.0.write_entity(p, b)
1540 }
1541 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1542 self.0.delete_entity(p)
1543 }
1544 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1545 self.0.move_entity(f, t)
1546 }
1547 fn commit(
1548 &self,
1549 m: &str,
1550 c: &CommitContext<'_>,
1551 ) -> Result<crate::storage::CommitId, BackendError> {
1552 self.0.commit(m, c)
1553 }
1554 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1555 self.0.append_provenance(r)
1556 }
1557 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1558 self.0.read_provenance(c)
1559 }
1560 fn current_head(&self) -> Result<Option<String>, BackendError> {
1561 self.0.current_head()
1562 }
1563 }
1564
1565 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1566 let backend = Box::new(ArcBackend(shared.clone()));
1567 let mount = Mount {
1568 mem: "specs".to_string(),
1569 schema: Some(pin("default")),
1570 storage: MountStorage::Folder {
1571 path: PathBuf::from("/dev/null"),
1572 },
1573 capability: MountCapability::Write,
1574 lifecycle: MountLifecycle::Eager,
1575 cross_linkable: true,
1576 migration_target: None,
1577 };
1578 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1579
1580 // Sibling writer advances the head past the engine's cursor.
1581 shared.set_head(Some("bbb"));
1582
1583 let report = engine.reload_one_mem_report("specs").unwrap();
1584 // head_before is the prior cursor "aaa", not the drifted tip.
1585 assert_eq!(report.head_before, "aaa");
1586 assert_eq!(report.head_after, "bbb");
1587
1588 // Cursor advanced to "bbb": a follow-up staleness probe is a
1589 // no-op, not a spurious MEM_RELOADED.
1590 let warnings = engine.reload_if_stale(Some("specs"));
1591 assert!(
1592 warnings.is_empty(),
1593 "cursor should have advanced to bbb, got {warnings:?}"
1594 );
1595 }
1596
1597 #[test]
1598 fn reload_if_stale_fires_every_call_no_throttle() {
1599 // Two back-to-back probes with the head advancing between
1600 // them: the second must reload and warn. There is no throttle
1601 // window — the ref check is the correctness floor.
1602 let shared = std::sync::Arc::new(ManualHeadBackend::new(Some("aaa")));
1603 struct ArcBackend(std::sync::Arc<ManualHeadBackend>);
1604 impl MemBackend for ArcBackend {
1605 fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
1606 self.0.list_entities()
1607 }
1608 fn read_entity(&self, rel: &Path) -> Result<Option<Vec<u8>>, BackendError> {
1609 self.0.read_entity(rel)
1610 }
1611 fn write_entity(&self, p: &Path, b: &[u8]) -> Result<(), BackendError> {
1612 self.0.write_entity(p, b)
1613 }
1614 fn delete_entity(&self, p: &Path) -> Result<(), BackendError> {
1615 self.0.delete_entity(p)
1616 }
1617 fn move_entity(&self, f: &Path, t: &Path) -> Result<(), BackendError> {
1618 self.0.move_entity(f, t)
1619 }
1620 fn commit(
1621 &self,
1622 m: &str,
1623 c: &CommitContext<'_>,
1624 ) -> Result<crate::storage::CommitId, BackendError> {
1625 self.0.commit(m, c)
1626 }
1627 fn append_provenance(&self, r: &Provenance) -> Result<(), BackendError> {
1628 self.0.append_provenance(r)
1629 }
1630 fn read_provenance(&self, c: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
1631 self.0.read_provenance(c)
1632 }
1633 fn current_head(&self) -> Result<Option<String>, BackendError> {
1634 self.0.current_head()
1635 }
1636 }
1637
1638 let backend = Box::new(ArcBackend(shared.clone()));
1639 let mount = Mount {
1640 mem: "specs".to_string(),
1641 schema: Some(pin("default")),
1642 storage: MountStorage::Folder {
1643 path: PathBuf::from("/dev/null"),
1644 },
1645 capability: MountCapability::Write,
1646 lifecycle: MountLifecycle::Eager,
1647 cross_linkable: true,
1648 migration_target: None,
1649 };
1650 let mut engine = Engine::from_mounts(vec![(mount, backend)]).unwrap();
1651
1652 // First probe observes cached==new; no warning.
1653 let warnings = engine.reload_if_stale(Some("specs"));
1654 assert!(warnings.is_empty());
1655
1656 // Sibling advances head — the very next probe reloads and
1657 // warns, with no throttle window to mask it.
1658 shared.set_head(Some("bbb"));
1659 let warnings = engine.reload_if_stale(Some("specs"));
1660 assert_eq!(
1661 warnings.len(),
1662 1,
1663 "no throttle window — the moved ref reloads on the next probe"
1664 );
1665 }
1666}