Skip to main content

memstead_base/storage/
filesystem.rs

1//! Filesystem-backed [`MemWriter`](super::MemWriter) — the gix-free
2//! companion to [`memstead_git_branch::storage::git_tree::GitTreeMemWriter`].
3//! Used by filesystem mems, where entities live as plain files under a
4//! workspace root and there is no commit history.
5//!
6//! ## Buffer + commit
7//!
8//! Mutations buffer in memory until [`FilesystemMemWriter::commit`].
9//! Per-path final-state collapse mirrors the git-tree adapter: a chain
10//! of write/delete ops on the same path collapses to a single terminal
11//! state by commit time. Move resolves at call-time into a
12//! `Delete(from)` + `Upsert(to, bytes)` pair — bytes come from the
13//! pending buffer when present, otherwise from the live file on disk.
14//!
15//! ## Atomicity
16//!
17//! Per-file writes are atomic via write-to-temp + rename. Multi-op
18//! commits are *not* transactional: a failure partway through leaves
19//! earlier ops landed and later ops untouched. Single-writer is
20//! assumed; concurrent writers against the same mem are out of scope.
21//!
22//! ## CommitId
23//!
24//! There is no commit history. [`Self::commit`] returns a synthetic
25//! opaque id (UNIX-nanos + counter, hex) to satisfy the trait surface.
26//! Callers that pass this through `_hash` envelopes get a unique
27//! per-commit token but no CAS guarantee — there is no parent state to
28//! compare against.
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32use std::sync::Mutex;
33use std::sync::atomic::{AtomicU64, Ordering};
34
35use super::{CommitId, MemWriter, MemWriterError};
36// `MemBackend` is referenced via fully-qualified path in the impl
37// declaration below so it does NOT enter this module's name lookup.
38// Both traits expose `write_entity` / `delete_entity` / etc.; importing
39// both at module scope would make every dot-syntax call on a
40// `FilesystemMemWriter` ambiguous (existing tests included). Tests
41// that exercise the `MemBackend` impl pull it in via a local `use`
42// at the function level.
43use crate::backend::BackendError;
44use crate::filesystem::changelog::{
45    self, ChangeEntry, MutationKind, changelog_path, parse_rfc3339_utc,
46};
47use crate::provenance::{Provenance, ProvenanceKind};
48use crate::vcs::{Actor, CommitContext, parse_client_id};
49
50/// Per-path final state for the buffered op log. Move operations
51/// resolve at call time into a `Delete(from)` + `Upsert(to, bytes)`
52/// pair, mirroring the git-tree adapter so commit-time replay only
53/// ever sees these two terminal states.
54enum PendingState {
55    Upsert(Vec<u8>),
56    Delete,
57}
58
59/// In-flight mutation buffer. Cleared on a successful commit.
60struct Pending {
61    ops: HashMap<String, PendingState>,
62}
63
64impl Pending {
65    fn new() -> Self {
66        Self {
67            ops: HashMap::new(),
68        }
69    }
70
71    fn clear(&mut self) {
72        self.ops.clear();
73    }
74}
75
76/// Filesystem-backed [`MemWriter`]. Mutations buffer in memory until
77/// [`Self::commit`]; commit replays them against the directory at
78/// `root` with per-file write-to-temp + rename atomicity.
79pub struct FilesystemMemWriter {
80    root: PathBuf,
81    pending: Mutex<Pending>,
82}
83
84impl FilesystemMemWriter {
85    /// Build a writer rooted at `root`. The directory must already
86    /// exist; sub-directories are created lazily as commits run.
87    pub fn new(root: PathBuf) -> Self {
88        Self {
89            root,
90            pending: Mutex::new(Pending::new()),
91        }
92    }
93
94    /// Read the current bytes at `rel_key` from the buffered op log if
95    /// present, otherwise from disk. Used by `move_entity` to resolve
96    /// the source content.
97    fn read_source(
98        &self,
99        pending: &Pending,
100        rel_key: &str,
101    ) -> Result<Option<Vec<u8>>, MemWriterError> {
102        if let Some(PendingState::Upsert(bytes)) = pending.ops.get(rel_key) {
103            return Ok(Some(bytes.clone()));
104        }
105        if let Some(PendingState::Delete) = pending.ops.get(rel_key) {
106            return Ok(None);
107        }
108        let full = self.root.join(rel_key);
109        match std::fs::read(&full) {
110            Ok(bytes) => Ok(Some(bytes)),
111            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
112            Err(e) => Err(MemWriterError::Io(e)),
113        }
114    }
115}
116
117/// Normalise a mem-relative path to a forward-slash key. Rejects
118/// empty paths, absolute paths, and any path that escapes the mem
119/// root via `..`. Mirrors `git_tree::normalise_rel_path` so the two
120/// adapters reject the same inputs.
121///
122/// `pub(crate)` so the in-memory backend reuses the exact same
123/// rejection rules — a third hand-rolled copy would be free to drift.
124pub(crate) fn normalise_rel_path(rel_path: &Path) -> Result<String, MemWriterError> {
125    if rel_path.as_os_str().is_empty() {
126        return Err(MemWriterError::Path(
127            "mem-relative path is empty".to_string(),
128        ));
129    }
130    let mut parts: Vec<String> = Vec::new();
131    for component in rel_path.components() {
132        use std::path::Component;
133        match component {
134            Component::Normal(s) => match s.to_str() {
135                Some(p) if !p.is_empty() => parts.push(p.to_string()),
136                _ => {
137                    return Err(MemWriterError::Path(format!(
138                        "non-utf-8 or empty path component in {}",
139                        rel_path.display()
140                    )));
141                }
142            },
143            Component::CurDir => continue,
144            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
145                return Err(MemWriterError::Path(format!(
146                    "path traversal or absolute component in {}",
147                    rel_path.display()
148                )));
149            }
150        }
151    }
152    if parts.is_empty() {
153        return Err(MemWriterError::Path(
154            "mem-relative path is empty after normalisation".to_string(),
155        ));
156    }
157    Ok(parts.join("/"))
158}
159
160impl MemWriter for FilesystemMemWriter {
161    fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), MemWriterError> {
162        let key = normalise_rel_path(rel_path)?;
163        let mut pending = self.pending.lock().map_err(|_| {
164            MemWriterError::Path("filesystem writer pending state poisoned".to_string())
165        })?;
166        pending
167            .ops
168            .insert(key, PendingState::Upsert(content.to_vec()));
169        Ok(())
170    }
171
172    fn delete_entity(&self, rel_path: &Path) -> Result<(), MemWriterError> {
173        let key = normalise_rel_path(rel_path)?;
174        let mut pending = self.pending.lock().map_err(|_| {
175            MemWriterError::Path("filesystem writer pending state poisoned".to_string())
176        })?;
177        pending.ops.insert(key, PendingState::Delete);
178        Ok(())
179    }
180
181    fn move_entity(&self, from: &Path, to: &Path) -> Result<(), MemWriterError> {
182        let from_key = normalise_rel_path(from)?;
183        let to_key = normalise_rel_path(to)?;
184        let mut pending = self.pending.lock().map_err(|_| {
185            MemWriterError::Path("filesystem writer pending state poisoned".to_string())
186        })?;
187
188        let bytes = match pending.ops.remove(&from_key) {
189            Some(PendingState::Upsert(b)) => b,
190            Some(PendingState::Delete) => {
191                pending.ops.insert(from_key, PendingState::Delete);
192                return Err(MemWriterError::Path(format!(
193                    "move source {} is already pending deletion",
194                    from.display()
195                )));
196            }
197            None => match self.read_source(&pending, &from_key)? {
198                Some(b) => b,
199                None => {
200                    return Err(MemWriterError::Path(format!(
201                        "move source {} does not exist",
202                        from.display()
203                    )));
204                }
205            },
206        };
207
208        if matches!(pending.ops.get(&to_key), Some(PendingState::Upsert(_))) {
209            return Err(MemWriterError::Path(format!(
210                "move target {} already has a pending write",
211                to.display()
212            )));
213        }
214        pending.ops.insert(from_key, PendingState::Delete);
215        pending.ops.insert(to_key, PendingState::Upsert(bytes));
216        Ok(())
217    }
218
219    fn commit(&self, _message: &str, _ctx: &CommitContext<'_>) -> Result<CommitId, MemWriterError> {
220        let mut pending = self.pending.lock().map_err(|_| {
221            MemWriterError::Path("filesystem writer pending state poisoned".to_string())
222        })?;
223
224        for (key, state) in pending.ops.iter() {
225            let target = self.root.join(key);
226            match state {
227                PendingState::Upsert(bytes) => atomic_write(&target, bytes)?,
228                PendingState::Delete => match std::fs::remove_file(&target) {
229                    Ok(()) => {}
230                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
231                    Err(e) => return Err(MemWriterError::Io(e)),
232                },
233            }
234        }
235
236        pending.clear();
237        Ok(make_commit_id())
238    }
239}
240
241impl crate::backend::MemBackend for FilesystemMemWriter {
242    fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
243        let mut out = Vec::new();
244        if !self.root.exists() {
245            return Ok(out);
246        }
247        walk_for_md(&self.root, &self.root, &mut out)?;
248        Ok(out)
249    }
250
251    /// Folder-mem drift cursor: the changelog's last-line `ts` — the
252    /// same RFC3339-millisecond dialect `folder_changes_since` accepts
253    /// as its cursor, so drift heads feed straight into delta reads.
254    /// Every mutation appends a changelog line (in this process or a
255    /// sibling's), advancing the cursor; the drift check then treats
256    /// the advance exactly like a git-branch tip move. Absent
257    /// changelog (a mem never mutated through the engine) keeps the
258    /// historical no-drift-signal `None`. Appends go through
259    /// `append_change_monotonic`, so the cursor strictly advances even
260    /// for same-millisecond commits; only a read-append race between
261    /// separate processes can momentarily share a cursor value —
262    /// detection then rides the next append.
263    fn current_head(&self) -> Result<Option<String>, BackendError> {
264        let log_path = crate::filesystem::changelog::changelog_path(&self.root);
265        let raw = match std::fs::read_to_string(&log_path) {
266            Ok(s) => s,
267            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
268            Err(e) => {
269                return Err(BackendError::Other(format!(
270                    "reading folder changelog {}: {e}",
271                    log_path.display()
272                )));
273            }
274        };
275        let last_ts = raw
276            .lines()
277            .rev()
278            .filter_map(|line| {
279                let trimmed = line.trim();
280                if trimmed.is_empty() {
281                    return None;
282                }
283                serde_json::from_str::<serde_json::Value>(trimmed)
284                    .ok()?
285                    .get("ts")?
286                    .as_str()
287                    .map(str::to_string)
288            })
289            .next();
290        Ok(last_ts)
291    }
292
293    fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
294        let key = normalise_rel_path(rel_path)?;
295        let pending = self.pending.lock().map_err(|_| {
296            BackendError::Other("filesystem writer pending state poisoned".to_string())
297        })?;
298        if let Some(state) = pending.ops.get(&key) {
299            return Ok(match state {
300                PendingState::Upsert(bytes) => Some(bytes.clone()),
301                PendingState::Delete => None,
302            });
303        }
304        let full = self.root.join(&key);
305        match std::fs::read(&full) {
306            Ok(bytes) => Ok(Some(bytes)),
307            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
308            Err(e) => Err(BackendError::Io(e)),
309        }
310    }
311
312    /// Metadata-class existence probe: pending buffer first (same
313    /// precedence as `read_entity`), then one `symlink_metadata` call —
314    /// no file open, no byte read.
315    fn entity_exists(&self, rel_path: &Path) -> Result<bool, BackendError> {
316        let key = normalise_rel_path(rel_path)?;
317        let pending = self.pending.lock().map_err(|_| {
318            BackendError::Other("filesystem writer pending state poisoned".to_string())
319        })?;
320        if let Some(state) = pending.ops.get(&key) {
321            return Ok(matches!(state, PendingState::Upsert(_)));
322        }
323        drop(pending);
324        match std::fs::symlink_metadata(self.root.join(&key)) {
325            Ok(md) => Ok(md.is_file()),
326            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
327            Err(e) => Err(BackendError::Io(e)),
328        }
329    }
330
331    fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError> {
332        <Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
333    }
334
335    fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError> {
336        <Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
337    }
338
339    fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError> {
340        <Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
341    }
342
343    fn discard_pending(&self) -> Result<(), BackendError> {
344        // Drop the in-memory op buffer without replaying it. The
345        // atomic batch path calls this to roll back staged writes when
346        // a later item refuses the whole batch.
347        let mut pending = self.pending.lock().map_err(|_| {
348            BackendError::Other("filesystem writer pending state poisoned".to_string())
349        })?;
350        pending.clear();
351        Ok(())
352    }
353
354    fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
355        <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
356    }
357
358    fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
359        // Folder backend reads `<root>/.memstead/config.json`.
360        // Missing file → Ok(None); other IO errors propagate as
361        // BackendError.
362        let config_path = self.root.join(crate::mem::MEM_META_DIR).join("config.json");
363        match std::fs::read(&config_path) {
364            Ok(bytes) => Ok(Some(bytes)),
365            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
366            Err(e) => Err(BackendError::Io(e)),
367        }
368    }
369
370    fn write_mem_config(&self, bytes: &[u8]) -> Result<(), BackendError> {
371        // Folder backend writes `<root>/.memstead/config.json` to disk.
372        // Creates the `.memstead/` directory if missing. Existing config
373        // is overwritten — caller's responsibility to gate against
374        // overwrites if that's the contract (the unified
375        // `mem_management::create_mem` Step 4 already does the
376        // refusal-to-overwrite check upstream).
377        let memstead_dir = self.root.join(crate::mem::MEM_META_DIR);
378        std::fs::create_dir_all(&memstead_dir).map_err(BackendError::Io)?;
379        let config_path = memstead_dir.join("config.json");
380        std::fs::write(&config_path, bytes).map_err(BackendError::Io)
381    }
382
383    fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
384        // Read via the entity path so a staged (pending) sidecar write is
385        // visible before its commit, symmetric with the other backends.
386        self.read_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH))
387    }
388
389    fn write_anchors_sidecar(&self, bytes: &[u8]) -> Result<(), BackendError> {
390        // Stage into the same op buffer entity writes use so the sidecar
391        // rides the entity mutation's commit. `list_entities`
392        // (`walk_for_md`) skips `.memstead/`, so it never lists as an
393        // entity.
394        <Self as MemWriter>::write_entity(
395            self,
396            Path::new(crate::anchor::ANCHOR_SIDECAR_PATH),
397            bytes,
398        )
399        .map_err(Into::into)
400    }
401
402    fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
403        let kind: MutationKind = record.kind.into();
404        let entry = ChangeEntry {
405            kind,
406            entity: record.entity.as_deref(),
407            actor: record.actor,
408            client: record.client.as_ref(),
409            note: record.note.as_deref(),
410            logical_operation_id: record.logical_operation_id.as_deref(),
411            role: record.role,
412        };
413        // Monotonic variant: the last-line `ts` is this backend's
414        // drift cursor (`current_head()`), so same-millisecond commits
415        // must still advance it — see `append_change_monotonic`.
416        changelog::append_change_monotonic(&self.root, &entry, record.timestamp)
417            .map_err(|e| BackendError::Other(format!("changelog append: {e}")))
418    }
419
420    fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
421        let log_path = changelog_path(&self.root);
422        let raw = match std::fs::read_to_string(&log_path) {
423            Ok(s) => s,
424            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
425            Err(e) => return Err(BackendError::Io(e)),
426        };
427        let mut out = Vec::new();
428        for line in raw.lines() {
429            let trimmed = line.trim();
430            if trimmed.is_empty() {
431                continue;
432            }
433            let value: serde_json::Value = match serde_json::from_str(trimmed) {
434                Ok(v) => v,
435                Err(_) => continue,
436            };
437            let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
438            if let Some(c) = cursor
439                && ts_str <= c
440            {
441                continue;
442            }
443            let timestamp = parse_rfc3339_utc(ts_str).unwrap_or(std::time::UNIX_EPOCH);
444            let kind = value
445                .get("kind")
446                .and_then(|v| v.as_str())
447                .and_then(ProvenanceKind::parse)
448                .unwrap_or(ProvenanceKind::Update);
449            let entity = value
450                .get("entity")
451                .and_then(|v| v.as_str())
452                .map(|s| s.to_string());
453            let actor = value
454                .get("actor")
455                .and_then(|v| v.as_str())
456                .and_then(Actor::from_trailer)
457                .unwrap_or(Actor::Unknown);
458            let client = value
459                .get("client")
460                .and_then(|v| v.as_str())
461                .and_then(parse_client_id);
462            let note = value
463                .get("note")
464                .and_then(|v| v.as_str())
465                .map(|s| s.to_string());
466            let logical_operation_id = value
467                .get("logical_op")
468                .and_then(|v| v.as_str())
469                .map(|s| s.to_string());
470            let mut record = Provenance::new(timestamp, kind, entity, actor, client, note);
471            if let Some(id) = logical_operation_id {
472                record = record.with_logical_operation_id(id);
473            }
474            if let Some(role) = value
475                .get("role")
476                .and_then(|v| v.as_str())
477                .and_then(crate::vcs::Role::from_wire)
478            {
479                record = record.with_role(role);
480            }
481            out.push(record);
482        }
483        Ok(out)
484    }
485}
486
487/// Walk `dir` for `.md` files, accumulating mem-relative paths in
488/// `out`. Skips the mem's `.memstead/` umbrella so the engine never
489/// confuses changelog / config / schema files with entity-bearing
490/// markdown, and `README.md` — repository documentation beside the
491/// entity files, never an entity (mirrors the entity-source walker's
492/// skip; entities are slug-named after their titles, so no legitimate
493/// entity file carries this name).
494fn walk_for_md(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BackendError> {
495    let entries = match std::fs::read_dir(dir) {
496        Ok(e) => e,
497        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
498        Err(e) => return Err(BackendError::Io(e)),
499    };
500    for entry in entries {
501        let entry = entry.map_err(BackendError::Io)?;
502        let path = entry.path();
503        let file_type = entry.file_type().map_err(BackendError::Io)?;
504        if file_type.is_dir() {
505            let name = entry.file_name();
506            if name == crate::mem::MEM_META_DIR {
507                continue;
508            }
509            walk_for_md(root, &path, out)?;
510        } else if file_type.is_file()
511            && path.extension().and_then(|s| s.to_str()) == Some("md")
512            && entry.file_name() != "README.md"
513            && let Ok(rel) = path.strip_prefix(root)
514        {
515            out.push(rel.to_path_buf());
516        }
517    }
518    Ok(())
519}
520
521/// Write `bytes` to `target` atomically: write to a sibling temp file
522/// then rename. Creates parent directories as needed. The temp file
523/// shares the target's parent so the rename is same-fs (atomic on
524/// POSIX). On rename failure, the temp file is best-effort removed.
525fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), MemWriterError> {
526    if let Some(parent) = target.parent()
527        && !parent.as_os_str().is_empty()
528    {
529        std::fs::create_dir_all(parent).map_err(MemWriterError::Io)?;
530    }
531    let tmp = make_tmp_path(target);
532    std::fs::write(&tmp, bytes).map_err(MemWriterError::Io)?;
533    if let Err(e) = std::fs::rename(&tmp, target) {
534        let _ = std::fs::remove_file(&tmp);
535        return Err(MemWriterError::Io(e));
536    }
537    Ok(())
538}
539
540/// Build a sibling temp path of the form `.<name>.tmp.<suffix>` next
541/// to `target`. The leading dot keeps the temp file out of the way of
542/// directory listings; the suffix combines UNIX-nanos with a process-
543/// scoped counter so concurrent writes never collide.
544fn make_tmp_path(target: &Path) -> PathBuf {
545    let name = target
546        .file_name()
547        .map(|n| n.to_string_lossy().to_string())
548        .unwrap_or_else(|| "_".to_string());
549    let suffix = unique_suffix();
550    let tmp_name = format!(".{name}.tmp.{suffix}");
551    target.with_file_name(tmp_name)
552}
553
554static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
555static COMMIT_COUNTER: AtomicU64 = AtomicU64::new(0);
556
557fn unique_suffix() -> String {
558    let nanos = std::time::SystemTime::now()
559        .duration_since(std::time::UNIX_EPOCH)
560        .map(|d| d.as_nanos())
561        .unwrap_or(0);
562    let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
563    format!("{nanos:x}-{counter:x}")
564}
565
566/// `pub(crate)` so the in-memory backend mints the same synthetic
567/// commit-id shape (UNIX-nanos + counter, hex) the folder backend
568/// produces — both are history-free backends and must hand callers an
569/// identically-shaped opaque cursor.
570pub(crate) fn make_commit_id() -> CommitId {
571    let nanos = std::time::SystemTime::now()
572        .duration_since(std::time::UNIX_EPOCH)
573        .map(|d| d.as_nanos())
574        .unwrap_or(0);
575    let counter = COMMIT_COUNTER.fetch_add(1, Ordering::Relaxed);
576    format!("{nanos:032x}{counter:016x}")
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::vcs::{Actor, ClientId, CommitContext};
583    use tempfile::TempDir;
584
585    fn ctx_for_test<'a>() -> CommitContext<'a> {
586        CommitContext {
587            actor: Actor::Cli,
588            client: Some(ClientId {
589                name: "claude-code".to_string(),
590                version: "0.1.0".to_string(),
591            }),
592            tool: Some("test"),
593            note: None,
594            role: Default::default(),
595            logical_operation_id: None,
596            entity_ids: None,
597        }
598    }
599
600    /// `entity_exists` observes the same pending-buffer precedence as
601    /// `read_entity`: staged upsert → true before any commit, staged
602    /// delete → false while the file still sits on disk, and the
603    /// committed state answers between transactions — all without
604    /// reading bytes (flywheel W7/02 primitive).
605    #[test]
606    fn entity_exists_metadata_probe_and_pending_precedence() {
607        use crate::backend::MemBackend;
608        let tmp = TempDir::new().unwrap();
609        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
610
611        assert!(!MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
612
613        MemBackend::write_entity(
614            &writer,
615            Path::new("notes/a.md"),
616            b"# a
617",
618        )
619        .unwrap();
620        assert!(
621            MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
622            "staged upsert answers true before commit"
623        );
624
625        MemWriter::commit(&writer, "land a", &ctx_for_test()).unwrap();
626        assert!(MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
627
628        MemBackend::delete_entity(&writer, Path::new("notes/a.md")).unwrap();
629        assert!(
630            !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
631            "staged delete answers false while the file is still on disk"
632        );
633    }
634
635    #[test]
636    fn write_then_commit_round_trip() {
637        let tmp = TempDir::new().unwrap();
638        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
639
640        writer
641            .write_entity(Path::new("notes/hello.md"), b"# hi\n")
642            .unwrap();
643        let id = writer.commit("first commit", &ctx_for_test()).unwrap();
644        assert!(!id.is_empty());
645
646        let bytes = std::fs::read(tmp.path().join("notes/hello.md")).unwrap();
647        assert_eq!(bytes, b"# hi\n");
648    }
649
650    #[test]
651    fn delete_removes_path() {
652        let tmp = TempDir::new().unwrap();
653        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
654
655        writer.write_entity(Path::new("a.md"), b"a").unwrap();
656        writer.write_entity(Path::new("b.md"), b"b").unwrap();
657        writer.commit("seed", &ctx_for_test()).unwrap();
658
659        writer.delete_entity(Path::new("a.md")).unwrap();
660        writer.commit("drop a", &ctx_for_test()).unwrap();
661
662        assert!(!tmp.path().join("a.md").exists());
663        assert!(tmp.path().join("b.md").exists());
664    }
665
666    #[test]
667    fn delete_of_missing_path_is_idempotent() {
668        let tmp = TempDir::new().unwrap();
669        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
670
671        writer.delete_entity(Path::new("never-existed.md")).unwrap();
672        writer.commit("noop delete", &ctx_for_test()).unwrap();
673    }
674
675    #[test]
676    fn move_renames_path() {
677        let tmp = TempDir::new().unwrap();
678        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
679
680        writer
681            .write_entity(Path::new("from.md"), b"payload")
682            .unwrap();
683        writer.commit("seed", &ctx_for_test()).unwrap();
684
685        writer
686            .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
687            .unwrap();
688        writer.commit("rename", &ctx_for_test()).unwrap();
689
690        assert!(!tmp.path().join("from.md").exists());
691        let moved = std::fs::read(tmp.path().join("nested/to.md")).unwrap();
692        assert_eq!(moved, b"payload");
693    }
694
695    #[test]
696    fn move_with_pending_upsert_carries_bytes() {
697        let tmp = TempDir::new().unwrap();
698        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
699
700        writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
701        writer
702            .move_entity(Path::new("a.md"), Path::new("b.md"))
703            .unwrap();
704        writer.commit("write+move", &ctx_for_test()).unwrap();
705
706        assert!(!tmp.path().join("a.md").exists());
707        assert_eq!(std::fs::read(tmp.path().join("b.md")).unwrap(), b"alpha");
708    }
709
710    #[test]
711    fn move_missing_source_errors() {
712        let tmp = TempDir::new().unwrap();
713        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
714
715        let err = writer
716            .move_entity(Path::new("ghost.md"), Path::new("here.md"))
717            .unwrap_err();
718        assert!(matches!(err, MemWriterError::Path(_)));
719    }
720
721    #[test]
722    fn move_with_pending_target_upsert_errors() {
723        let tmp = TempDir::new().unwrap();
724        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
725
726        writer.write_entity(Path::new("from.md"), b"x").unwrap();
727        writer.write_entity(Path::new("to.md"), b"y").unwrap();
728        let err = writer
729            .move_entity(Path::new("from.md"), Path::new("to.md"))
730            .unwrap_err();
731        assert!(matches!(err, MemWriterError::Path(_)));
732    }
733
734    #[test]
735    fn multi_op_commit() {
736        let tmp = TempDir::new().unwrap();
737        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
738
739        writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
740        writer.commit("seed", &ctx_for_test()).unwrap();
741
742        writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
743        writer
744            .write_entity(Path::new("nested/b.md"), b"beta")
745            .unwrap();
746        writer.delete_entity(Path::new("doomed.md")).unwrap();
747        writer.commit("multi-op", &ctx_for_test()).unwrap();
748
749        assert!(!tmp.path().join("doomed.md").exists());
750        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
751        assert_eq!(
752            std::fs::read(tmp.path().join("nested/b.md")).unwrap(),
753            b"beta"
754        );
755    }
756
757    #[test]
758    fn rejects_path_traversal() {
759        let tmp = TempDir::new().unwrap();
760        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
761
762        let err = writer
763            .write_entity(Path::new("../escape.md"), b"x")
764            .unwrap_err();
765        assert!(matches!(err, MemWriterError::Path(_)));
766    }
767
768    #[test]
769    fn rejects_absolute_path() {
770        let tmp = TempDir::new().unwrap();
771        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
772
773        let err = writer
774            .write_entity(Path::new("/etc/passwd"), b"x")
775            .unwrap_err();
776        assert!(matches!(err, MemWriterError::Path(_)));
777    }
778
779    #[test]
780    fn rejects_empty_path() {
781        let tmp = TempDir::new().unwrap();
782        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
783
784        let err = writer.write_entity(Path::new(""), b"x").unwrap_err();
785        assert!(matches!(err, MemWriterError::Path(_)));
786    }
787
788    #[test]
789    fn write_overwrites_existing_file() {
790        let tmp = TempDir::new().unwrap();
791        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
792
793        writer.write_entity(Path::new("a.md"), b"first").unwrap();
794        writer.commit("c1", &ctx_for_test()).unwrap();
795        writer.write_entity(Path::new("a.md"), b"second").unwrap();
796        writer.commit("c2", &ctx_for_test()).unwrap();
797
798        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"second");
799    }
800
801    #[test]
802    fn no_temp_files_left_after_commit() {
803        let tmp = TempDir::new().unwrap();
804        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
805
806        writer.write_entity(Path::new("a.md"), b"a").unwrap();
807        writer.write_entity(Path::new("nested/b.md"), b"b").unwrap();
808        writer.commit("c", &ctx_for_test()).unwrap();
809
810        // Walk the tree and ensure no `.tmp.` artefacts survived.
811        fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
812            for entry in std::fs::read_dir(dir).unwrap() {
813                let p = entry.unwrap().path();
814                if p.is_dir() {
815                    walk(&p, out);
816                } else {
817                    out.push(p);
818                }
819            }
820        }
821        let mut files = Vec::new();
822        walk(tmp.path(), &mut files);
823        for f in &files {
824            let name = f.file_name().unwrap().to_string_lossy();
825            assert!(
826                !name.contains(".tmp."),
827                "stray temp file after commit: {}",
828                f.display()
829            );
830        }
831    }
832
833    #[test]
834    fn commit_id_is_unique_across_calls() {
835        let tmp = TempDir::new().unwrap();
836        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
837
838        writer.write_entity(Path::new("a.md"), b"a").unwrap();
839        let id1 = writer.commit("c1", &ctx_for_test()).unwrap();
840        writer.write_entity(Path::new("b.md"), b"b").unwrap();
841        let id2 = writer.commit("c2", &ctx_for_test()).unwrap();
842
843        assert_ne!(id1, id2);
844    }
845
846    #[test]
847    fn pending_buffer_clears_on_commit() {
848        let tmp = TempDir::new().unwrap();
849        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
850
851        writer.write_entity(Path::new("a.md"), b"a").unwrap();
852        writer.commit("c1", &ctx_for_test()).unwrap();
853        // A second commit with no mutations writes nothing new but
854        // still returns a fresh id.
855        let id2 = writer.commit("noop", &ctx_for_test()).unwrap();
856        assert!(!id2.is_empty());
857        // a.md still has its original content (no zombie pending op).
858        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"a");
859    }
860
861    // --- MemBackend impl ----------------------------------------
862
863    /// Folder backend's `write_mem_config` writes
864    /// `<root>/.memstead/config.json`, creating the umbrella directory
865    /// if needed. The subsequent `read_mem_config` round-trips the
866    /// bytes.
867    #[test]
868    fn backend_write_mem_config_round_trips_via_read() {
869        use crate::backend::MemBackend;
870
871        let tmp = TempDir::new().unwrap();
872        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
873        let backend: &dyn MemBackend = &writer;
874
875        // No config yet — read returns None.
876        assert!(backend.read_mem_config().unwrap().is_none());
877
878        // Write — creates `.memstead/` umbrella + the config blob.
879        let bytes = br#"{"version":"0.1.0","schema":"default@1.0.0"}"#.to_vec();
880        backend.write_mem_config(&bytes).unwrap();
881
882        // Read returns the same bytes.
883        let read_back = backend.read_mem_config().unwrap();
884        assert_eq!(read_back, Some(bytes.clone()));
885        // Config file lands at `<root>/.memstead/config.json`.
886        let on_disk = std::fs::read(tmp.path().join(".memstead/config.json")).unwrap();
887        assert_eq!(on_disk, bytes);
888    }
889
890    #[test]
891    fn backend_list_entities_returns_only_md_outside_meta_dirs() {
892        use crate::backend::MemBackend;
893
894        let tmp = TempDir::new().unwrap();
895        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
896
897        // With both traits in scope, dot-syntax `writer.foo(...)`
898        // is ambiguous — seed via fully-qualified MemWriter calls.
899        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
900        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"b")
901            .unwrap();
902        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
903            .unwrap();
904        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
905        // The current `.memstead/` meta dir is skipped by the walker.
906        // An ordinary dot-dir (`.other/`) is not special, so markdown
907        // under it is walked like any other non-meta path.
908        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
909        std::fs::write(tmp.path().join(".memstead/config.json"), b"{}").unwrap();
910        std::fs::write(tmp.path().join(".memstead/notes.md"), b"#").unwrap();
911        std::fs::create_dir_all(tmp.path().join(".other")).unwrap();
912        std::fs::write(tmp.path().join(".other/notes.md"), b"#").unwrap();
913
914        let backend: &dyn MemBackend = &writer;
915        let mut paths: Vec<String> = backend
916            .list_entities()
917            .unwrap()
918            .into_iter()
919            .map(|p| p.to_string_lossy().into_owned())
920            .collect();
921        paths.sort();
922        assert_eq!(
923            paths,
924            vec![
925                ".other/notes.md".to_string(),
926                "a.md".to_string(),
927                "nested/b.md".to_string(),
928            ]
929        );
930    }
931
932    #[test]
933    fn backend_read_entity_consults_pending_then_disk() {
934        let tmp = TempDir::new().unwrap();
935        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
936
937        // Seed via the legacy MemWriter trait — both traits expose
938        // `write_entity`; importing `MemBackend` later in the test
939        // makes the dot-syntax ambiguous, so we route the seed
940        // through the trait that's still implicitly in scope here.
941        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("on_disk.md"), b"disk")
942            .unwrap();
943        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
944
945        use crate::backend::MemBackend;
946        let backend: &dyn MemBackend = &writer;
947        // Disk path → reads from disk.
948        assert_eq!(
949            backend.read_entity(Path::new("on_disk.md")).unwrap(),
950            Some(b"disk".to_vec())
951        );
952        // Buffered upsert wins over disk.
953        backend
954            .write_entity(Path::new("on_disk.md"), b"buffered")
955            .unwrap();
956        assert_eq!(
957            backend.read_entity(Path::new("on_disk.md")).unwrap(),
958            Some(b"buffered".to_vec())
959        );
960        // Buffered delete masks disk.
961        backend.delete_entity(Path::new("on_disk.md")).unwrap();
962        assert_eq!(backend.read_entity(Path::new("on_disk.md")).unwrap(), None);
963        // Unknown path → None (idempotent).
964        assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
965    }
966
967    #[test]
968    fn backend_provenance_round_trips_through_jsonl() {
969        let tmp = TempDir::new().unwrap();
970        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
971        use crate::backend::MemBackend;
972        let backend: &dyn MemBackend = &writer;
973
974        let client = ClientId {
975            name: "claude-code".into(),
976            version: "2.1.0".into(),
977        };
978        let earlier = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
979        let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_777_077_296);
980
981        backend
982            .append_provenance(&Provenance::new(
983                earlier,
984                ProvenanceKind::Create,
985                Some("v:e1".into()),
986                Actor::Agent,
987                Some(client.clone()),
988                Some("first".into()),
989            ))
990            .unwrap();
991        backend
992            .append_provenance(&Provenance::new(
993                later,
994                ProvenanceKind::Update,
995                Some("v:e1".into()),
996                Actor::Cli,
997                None,
998                None,
999            ))
1000            .unwrap();
1001
1002        let all = backend.read_provenance(None).unwrap();
1003        assert_eq!(all.len(), 2);
1004        assert_eq!(all[0].kind, ProvenanceKind::Create);
1005        assert_eq!(all[0].entity.as_deref(), Some("v:e1"));
1006        assert_eq!(all[0].actor, Actor::Agent);
1007        assert_eq!(all[0].note.as_deref(), Some("first"));
1008        assert_eq!(
1009            all[0]
1010                .client
1011                .as_ref()
1012                .map(|c| (c.name.as_str(), c.version.as_str())),
1013            Some(("claude-code", "2.1.0"))
1014        );
1015        assert_eq!(all[0].timestamp, earlier);
1016        assert_eq!(all[1].kind, ProvenanceKind::Update);
1017        assert_eq!(all[1].timestamp, later);
1018        assert!(all[1].note.is_none());
1019        assert!(all[1].client.is_none());
1020    }
1021
1022    #[test]
1023    fn backend_provenance_cursor_filters_by_timestamp() {
1024        let tmp = TempDir::new().unwrap();
1025        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1026        use crate::backend::MemBackend;
1027        let backend: &dyn MemBackend = &writer;
1028
1029        for (secs, label) in [
1030            (1_700_000_000u64, "first"),
1031            (1_750_000_000, "middle"),
1032            (1_800_000_000, "last"),
1033        ] {
1034            backend
1035                .append_provenance(&Provenance::new(
1036                    std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1037                    ProvenanceKind::Create,
1038                    Some(format!("v:{label}")),
1039                    Actor::Cli,
1040                    None,
1041                    None,
1042                ))
1043                .unwrap();
1044        }
1045        // Cursor between first and middle should drop the first entry.
1046        let cursor = changelog::format_rfc3339_utc(
1047            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1048        );
1049        let after = backend.read_provenance(Some(&cursor)).unwrap();
1050        let entities: Vec<_> = after.iter().filter_map(|p| p.entity.clone()).collect();
1051        assert_eq!(entities, vec!["v:middle".to_string(), "v:last".to_string()]);
1052    }
1053
1054    #[test]
1055    fn backend_read_provenance_handles_missing_log() {
1056        let tmp = TempDir::new().unwrap();
1057        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1058        use crate::backend::MemBackend;
1059        let backend: &dyn MemBackend = &writer;
1060        // No mutations yet → no `.memstead/changes.jsonl` → empty result, no error.
1061        assert!(backend.read_provenance(None).unwrap().is_empty());
1062    }
1063
1064    // ---- folder JSONL synthesis (memstead_base::ops::folder_changes_since) ----
1065
1066    /// Helper: append a single Provenance event with an explicit
1067    /// timestamp, so tests get deterministic JSONL ordering.
1068    fn append_at(
1069        backend: &dyn crate::backend::MemBackend,
1070        secs: u64,
1071        kind: ProvenanceKind,
1072        entity: &str,
1073    ) {
1074        backend
1075            .append_provenance(&Provenance::new(
1076                std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1077                kind,
1078                Some(entity.to_string()),
1079                Actor::Cli,
1080                None,
1081                None,
1082            ))
1083            .unwrap();
1084    }
1085
1086    #[test]
1087    fn folder_changes_since_no_log_returns_empty_at_cursor() {
1088        // Fresh mem, no `.memstead/changes.jsonl` → empty BackendChanges
1089        // with `head` echoing the cursor.
1090        let tmp = TempDir::new().unwrap();
1091        let result =
1092            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1093                .unwrap();
1094        assert_eq!(result.since, crate::ops::EMPTY_TREE_SHA);
1095        assert_eq!(result.head, crate::ops::EMPTY_TREE_SHA);
1096        assert!(result.changes.is_empty());
1097    }
1098
1099    #[test]
1100    fn folder_changes_since_create_only_yields_added_envelope() {
1101        let tmp = TempDir::new().unwrap();
1102        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1103        append_at(
1104            &writer,
1105            1_700_000_000,
1106            ProvenanceKind::Create,
1107            "specs--alpha",
1108        );
1109
1110        let result =
1111            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1112                .unwrap();
1113        assert_eq!(result.changes.len(), 1);
1114        match &result.changes[0] {
1115            crate::ops::ChangeEnvelope::Added {
1116                id,
1117                title,
1118                entity_type,
1119            } => {
1120                assert_eq!(id.0, "specs--alpha");
1121                assert!(title.is_none(), "id-only contract");
1122                assert!(entity_type.is_none(), "id-only contract");
1123            }
1124            other => panic!("expected Added, got {other:?}"),
1125        }
1126        // head advances to the event's timestamp.
1127        assert_ne!(result.head, crate::ops::EMPTY_TREE_SHA);
1128    }
1129
1130    #[test]
1131    fn folder_changes_since_create_then_delete_cancels_to_no_envelope() {
1132        // Within the cursor window, an entity that was created and then
1133        // deleted nets out to Removed (final state wins for Delete).
1134        let tmp = TempDir::new().unwrap();
1135        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1136        append_at(
1137            &writer,
1138            1_700_000_000,
1139            ProvenanceKind::Create,
1140            "specs--ephemeral",
1141        );
1142        append_at(
1143            &writer,
1144            1_700_000_001,
1145            ProvenanceKind::Delete,
1146            "specs--ephemeral",
1147        );
1148
1149        let result =
1150            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1151                .unwrap();
1152        assert_eq!(result.changes.len(), 1);
1153        match &result.changes[0] {
1154            crate::ops::ChangeEnvelope::Removed { id, .. } => {
1155                assert_eq!(id.0, "specs--ephemeral");
1156            }
1157            other => panic!("expected Removed (Delete wins), got {other:?}"),
1158        }
1159    }
1160
1161    #[test]
1162    fn folder_changes_since_update_only_yields_updated_envelope() {
1163        let tmp = TempDir::new().unwrap();
1164        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1165        append_at(
1166            &writer,
1167            1_700_000_000,
1168            ProvenanceKind::Update,
1169            "specs--alpha",
1170        );
1171
1172        let result =
1173            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1174                .unwrap();
1175        assert_eq!(result.changes.len(), 1);
1176        assert!(matches!(
1177            result.changes[0],
1178            crate::ops::ChangeEnvelope::Updated { .. }
1179        ));
1180    }
1181
1182    #[test]
1183    fn folder_changes_since_cursor_filters_to_window() {
1184        // Three events at three timestamps; cursor between first and
1185        // second drops the first event from the window.
1186        let tmp = TempDir::new().unwrap();
1187        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1188        append_at(
1189            &writer,
1190            1_700_000_000,
1191            ProvenanceKind::Create,
1192            "specs--first",
1193        );
1194        append_at(
1195            &writer,
1196            1_750_000_000,
1197            ProvenanceKind::Create,
1198            "specs--middle",
1199        );
1200        append_at(
1201            &writer,
1202            1_800_000_000,
1203            ProvenanceKind::Create,
1204            "specs--last",
1205        );
1206
1207        let cursor = changelog::format_rfc3339_utc(
1208            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1209        );
1210        let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1211        assert_eq!(result.changes.len(), 2);
1212        let ids: Vec<_> = result
1213            .changes
1214            .iter()
1215            .map(|e| match e {
1216                crate::ops::ChangeEnvelope::Added { id, .. } => id.0.clone(),
1217                _ => panic!("expected Added"),
1218            })
1219            .collect();
1220        // BTreeMap iteration order — ids sort lexicographically.
1221        assert_eq!(
1222            ids,
1223            vec!["specs--last".to_string(), "specs--middle".to_string()]
1224        );
1225    }
1226
1227    #[test]
1228    fn folder_changes_since_skips_events_for_other_mems() {
1229        // Defensive: changelog drift could carry events for another
1230        // mem prefix; the impl filters them out so envelopes only
1231        // surface for the queried mem.
1232        let tmp = TempDir::new().unwrap();
1233        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1234        append_at(
1235            &writer,
1236            1_700_000_000,
1237            ProvenanceKind::Create,
1238            "specs--mine",
1239        );
1240        append_at(
1241            &writer,
1242            1_700_000_001,
1243            ProvenanceKind::Create,
1244            "other--theirs",
1245        );
1246
1247        let result =
1248            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1249                .unwrap();
1250        assert_eq!(result.changes.len(), 1);
1251        match &result.changes[0] {
1252            crate::ops::ChangeEnvelope::Added { id, .. } => {
1253                assert_eq!(id.0, "specs--mine");
1254            }
1255            other => panic!("expected Added, got {other:?}"),
1256        }
1257    }
1258
1259    #[test]
1260    fn folder_changes_since_skips_batch_events_with_no_entity() {
1261        // Batch events have entity=null. They don't surface as
1262        // envelopes (no per-entity id to attach to).
1263        let tmp = TempDir::new().unwrap();
1264        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1265        use crate::backend::MemBackend;
1266        // append_at requires an entity; use append_provenance directly
1267        // for the batch-with-no-entity case.
1268        writer
1269            .append_provenance(&Provenance::new(
1270                std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1271                ProvenanceKind::Batch,
1272                None,
1273                Actor::Cli,
1274                None,
1275                None,
1276            ))
1277            .unwrap();
1278        append_at(
1279            &writer,
1280            1_700_000_001,
1281            ProvenanceKind::Create,
1282            "specs--real",
1283        );
1284
1285        let result =
1286            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1287                .unwrap();
1288        // Only the Create-Real event surfaces; the batch is dropped.
1289        assert_eq!(result.changes.len(), 1);
1290        match &result.changes[0] {
1291            crate::ops::ChangeEnvelope::Added { id, .. } => {
1292                assert_eq!(id.0, "specs--real");
1293            }
1294            other => panic!("expected Added, got {other:?}"),
1295        }
1296    }
1297
1298    #[test]
1299    fn folder_changes_since_head_echoes_cursor_when_no_events_in_window() {
1300        // Events exist but all before the cursor → head echoes cursor.
1301        let tmp = TempDir::new().unwrap();
1302        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1303        append_at(&writer, 1_700_000_000, ProvenanceKind::Create, "specs--old");
1304
1305        let cursor = changelog::format_rfc3339_utc(
1306            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_900_000_000),
1307        );
1308        let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1309        assert!(result.changes.is_empty());
1310        assert_eq!(result.head, cursor);
1311    }
1312
1313    #[test]
1314    fn backend_writes_delegate_to_memwriter() {
1315        let tmp = TempDir::new().unwrap();
1316        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1317        use crate::backend::MemBackend;
1318        let backend: &dyn MemBackend = &writer;
1319
1320        backend.write_entity(Path::new("a.md"), b"alpha").unwrap();
1321        backend.commit("seed", &ctx_for_test()).unwrap();
1322        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
1323
1324        backend.delete_entity(Path::new("a.md")).unwrap();
1325        backend.commit("drop", &ctx_for_test()).unwrap();
1326        assert!(!tmp.path().join("a.md").exists());
1327    }
1328
1329    #[test]
1330    fn parse_client_id_splits_on_last_at() {
1331        let c = parse_client_id("claude-code@2.1.0").unwrap();
1332        assert_eq!(c.name, "claude-code");
1333        assert_eq!(c.version, "2.1.0");
1334        // Edge: name with `.`
1335        let c = parse_client_id("foo.bar@1.0").unwrap();
1336        assert_eq!(c.name, "foo.bar");
1337        // Bare strings without `@` → None (forward-compat: tolerant
1338        // readers ignore rather than mis-construct).
1339        assert!(parse_client_id("naked").is_none());
1340        assert!(parse_client_id("@1.0").is_none());
1341        assert!(parse_client_id("name@").is_none());
1342    }
1343}
1344
1345#[cfg(test)]
1346mod folder_drift_tests {
1347    use super::*;
1348
1349    const ENTITY: &str = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Seed\n\n## Identity\n\nSeed.\n";
1350    const SIBLING_ENTITY: &str = "---\ntype: spec\ncreated_date: 2026-01-02\nlast_modified: 2026-01-02\nlevel: M0\n---\n# Sibling\n\n## Identity\n\nWritten out-of-band.\n";
1351
1352    fn folder_engine(dir: std::path::PathBuf) -> crate::Engine {
1353        let mount = crate::Mount {
1354            mem: "specs".to_string(),
1355            schema: Some(memstead_schema::SchemaRef::new(
1356                "default",
1357                semver::Version::new(1, 0, 0),
1358            )),
1359            storage: crate::MountStorage::Folder { path: dir.clone() },
1360            capability: crate::MountCapability::Write,
1361            lifecycle: crate::MountLifecycle::Eager,
1362            cross_linkable: false,
1363            migration_target: None,
1364        };
1365        let backend = Box::new(FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
1366        crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
1367    }
1368
1369    /// A sibling process's folder commit is drift: the changelog-ts
1370    /// cursor advances, `reload_if_stale` reloads, surfaces
1371    /// MEM_RELOADED, stashes the structured notice — and the engine's
1372    /// own writes never masquerade as drift (the recorded head is
1373    /// probe-corrected to the cursor dialect).
1374    #[test]
1375    fn sibling_folder_write_is_drift_and_self_write_is_not() {
1376        let tmp = tempfile::TempDir::new().unwrap();
1377        let dir = tmp.path().join("specs");
1378        std::fs::create_dir_all(&dir).unwrap();
1379        // Seed through a writer WITH provenance so a baseline cursor exists.
1380        let seeder = FilesystemMemWriter::new(dir.clone());
1381        MemWriter::write_entity(&seeder, std::path::Path::new("seed.md"), ENTITY.as_bytes())
1382            .unwrap();
1383        MemWriter::commit(&seeder, "seed", &CommitContext::internal()).unwrap();
1384        crate::backend::MemBackend::append_provenance(
1385            &seeder,
1386            &Provenance::new(
1387                std::time::SystemTime::now(),
1388                ProvenanceKind::Create,
1389                Some("specs--seed".into()),
1390                Actor::Cli,
1391                None,
1392                None,
1393            ),
1394        )
1395        .unwrap();
1396
1397        let mut engine = folder_engine(dir.clone());
1398        // First probe captures the baseline silently.
1399        assert!(engine.reload_if_stale(None).is_empty());
1400
1401        // Self-write through the engine: no spurious drift afterwards.
1402        engine
1403            .create_entity(
1404                crate::CreateEntityArgs {
1405                    mem: "specs".to_string(),
1406                    title: "Self Made".to_string(),
1407                    entity_type: "spec".to_string(),
1408                    sections: [
1409                        ("identity".to_string(), "self".to_string()),
1410                        ("purpose".to_string(), "prove no self-drift".to_string()),
1411                    ]
1412                    .into_iter()
1413                    .collect(),
1414                    metadata: Default::default(),
1415                    relations: Vec::new(),
1416                    anchors: Vec::new(),
1417                    dry_run: false,
1418                },
1419                crate::vcs::Actor::Cli,
1420                None,
1421                None,
1422            )
1423            .unwrap();
1424        assert!(
1425            engine.reload_if_stale(None).is_empty(),
1426            "the engine's own write must not read as sibling drift"
1427        );
1428        assert!(engine.take_mem_changed_notices().is_empty());
1429
1430        // Sibling write: a separate writer instance (a stand-in for a
1431        // second process) commits + appends provenance out-of-band.
1432        std::thread::sleep(std::time::Duration::from_millis(5));
1433        let sibling = FilesystemMemWriter::new(dir);
1434        MemWriter::write_entity(
1435            &sibling,
1436            std::path::Path::new("sibling.md"),
1437            SIBLING_ENTITY.as_bytes(),
1438        )
1439        .unwrap();
1440        MemWriter::commit(&sibling, "sibling", &CommitContext::internal()).unwrap();
1441        crate::backend::MemBackend::append_provenance(
1442            &sibling,
1443            &Provenance::new(
1444                std::time::SystemTime::now(),
1445                ProvenanceKind::Create,
1446                Some("specs--sibling".into()),
1447                Actor::Cli,
1448                None,
1449                None,
1450            ),
1451        )
1452        .unwrap();
1453
1454        let warnings = engine.reload_if_stale(None);
1455        assert_eq!(
1456            warnings.len(),
1457            1,
1458            "sibling drift must surface: {warnings:?}"
1459        );
1460        match &warnings[0] {
1461            crate::ops::WarningHint::MemReloaded { mem, .. } => assert_eq!(mem, "specs"),
1462            other => panic!("expected MemReloaded, got {other:?}"),
1463        }
1464        let notices = engine.take_mem_changed_notices();
1465        assert_eq!(notices.len(), 1);
1466        // Post-reload the sibling entity is visible.
1467        assert!(
1468            engine
1469                .get_entity(&crate::EntityId("specs--sibling".to_string()))
1470                .is_some(),
1471            "reload must surface the sibling's entity"
1472        );
1473        // Idempotent probe: no repeat notice.
1474        assert!(engine.reload_if_stale(None).is_empty());
1475    }
1476}