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    /// The mem root directory exists. A folder mount whose path is
316    /// gone (a moved checkout, a mem never materialised) lists zero
317    /// entities exactly like an empty one; this is how boot tells the
318    /// two apart.
319    fn storage_present(&self) -> Result<bool, BackendError> {
320        Ok(self.root.is_dir())
321    }
322
323    fn entity_exists(&self, rel_path: &Path) -> Result<bool, BackendError> {
324        let key = normalise_rel_path(rel_path)?;
325        let pending = self.pending.lock().map_err(|_| {
326            BackendError::Other("filesystem writer pending state poisoned".to_string())
327        })?;
328        if let Some(state) = pending.ops.get(&key) {
329            return Ok(matches!(state, PendingState::Upsert(_)));
330        }
331        drop(pending);
332        match std::fs::symlink_metadata(self.root.join(&key)) {
333            Ok(md) => Ok(md.is_file()),
334            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
335            Err(e) => Err(BackendError::Io(e)),
336        }
337    }
338
339    fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError> {
340        <Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
341    }
342
343    fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError> {
344        <Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
345    }
346
347    fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError> {
348        <Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
349    }
350
351    fn discard_pending(&self) -> Result<(), BackendError> {
352        // Drop the in-memory op buffer without replaying it. The
353        // atomic batch path calls this to roll back staged writes when
354        // a later item refuses the whole batch.
355        let mut pending = self.pending.lock().map_err(|_| {
356            BackendError::Other("filesystem writer pending state poisoned".to_string())
357        })?;
358        pending.clear();
359        Ok(())
360    }
361
362    fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
363        <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
364    }
365
366    fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
367        // Folder backend reads `<root>/.memstead/config.json`.
368        // Missing file → Ok(None); other IO errors propagate as
369        // BackendError.
370        let config_path = self.root.join(crate::mem::MEM_META_DIR).join("config.json");
371        match std::fs::read(&config_path) {
372            Ok(bytes) => Ok(Some(bytes)),
373            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
374            Err(e) => Err(BackendError::Io(e)),
375        }
376    }
377
378    fn write_mem_config(&self, bytes: &[u8]) -> Result<(), BackendError> {
379        // Folder backend writes `<root>/.memstead/config.json` to disk.
380        // Creates the `.memstead/` directory if missing. Existing config
381        // is overwritten — caller's responsibility to gate against
382        // overwrites if that's the contract (the unified
383        // `mem_management::create_mem` Step 4 already does the
384        // refusal-to-overwrite check upstream).
385        let memstead_dir = self.root.join(crate::mem::MEM_META_DIR);
386        std::fs::create_dir_all(&memstead_dir).map_err(BackendError::Io)?;
387        let config_path = memstead_dir.join("config.json");
388        std::fs::write(&config_path, bytes).map_err(BackendError::Io)
389    }
390
391    /// Compare-and-set under an exclusive lock file, which is what makes the
392    /// check and the write one step (04/03, criterion 5). `create_new` on the
393    /// lock is the atomic primitive: exactly one process wins it, so no other
394    /// engine can slip a write between this one's compare and its write.
395    ///
396    /// A stale lock (a process killed mid-write) is broken after a short wait
397    /// rather than blocking forever: a config write that hangs is its own
398    /// outage, and the compare inside still refuses to overwrite content it
399    /// did not observe. A hand edit made in that window is still detected,
400    /// because the compare reads the file, not the lock.
401    fn write_mem_config_cas(
402        &self,
403        expected: Option<&[u8]>,
404        bytes: &[u8],
405        _note: Option<&str>,
406    ) -> Result<bool, BackendError> {
407        let memstead_dir = self.root.join(crate::mem::MEM_META_DIR);
408        std::fs::create_dir_all(&memstead_dir).map_err(BackendError::Io)?;
409        let config_path = memstead_dir.join("config.json");
410        let lock_path = memstead_dir.join("config.json.lock");
411
412        let mut held = None;
413        for attempt in 0..50 {
414            match std::fs::OpenOptions::new()
415                .write(true)
416                .create_new(true)
417                .open(&lock_path)
418            {
419                Ok(f) => {
420                    held = Some(f);
421                    break;
422                }
423                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
424                    // 50 x 10ms. Past that the holder is presumed dead: a
425                    // config write is milliseconds of work, so half a second
426                    // of contention is not a busy writer.
427                    if attempt == 49 {
428                        let _ = std::fs::remove_file(&lock_path);
429                    }
430                    std::thread::sleep(std::time::Duration::from_millis(10));
431                }
432                Err(e) => return Err(BackendError::Io(e)),
433            }
434        }
435        let _lock = match held {
436            Some(f) => f,
437            None => std::fs::OpenOptions::new()
438                .write(true)
439                .create(true)
440                .truncate(true)
441                .open(&lock_path)
442                .map_err(BackendError::Io)?,
443        };
444        // Release on every exit path, including the mismatch return.
445        let release = || {
446            let _ = std::fs::remove_file(&lock_path);
447        };
448
449        let current = match std::fs::read(&config_path) {
450            Ok(b) => Some(b),
451            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
452            Err(e) => {
453                release();
454                return Err(BackendError::Io(e));
455            }
456        };
457        if let Some(expected) = expected
458            && current.as_deref() != Some(expected)
459        {
460            release();
461            return Ok(false);
462        }
463        let result = std::fs::write(&config_path, bytes).map_err(BackendError::Io);
464        release();
465        result.map(|_| true)
466    }
467
468    fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
469        // Read via the entity path so a staged (pending) sidecar write is
470        // visible before its commit, symmetric with the other backends.
471        self.read_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH))
472    }
473
474    fn write_anchors_sidecar(&self, bytes: &[u8]) -> Result<(), BackendError> {
475        // Stage into the same op buffer entity writes use so the sidecar
476        // rides the entity mutation's commit. `list_entities`
477        // (`walk_for_md`) skips `.memstead/`, so it never lists as an
478        // entity.
479        <Self as MemWriter>::write_entity(
480            self,
481            Path::new(crate::anchor::ANCHOR_SIDECAR_PATH),
482            bytes,
483        )
484        .map_err(Into::into)
485    }
486
487    fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
488        let kind: MutationKind = record.kind.into();
489        let entry = ChangeEntry {
490            kind,
491            entity: record.entity.as_deref(),
492            actor: record.actor,
493            client: record.client.as_ref(),
494            note: record.note.as_deref(),
495            logical_operation_id: record.logical_operation_id.as_deref(),
496            role: record.role,
497            identity: record.identity.as_deref(),
498        };
499        // Monotonic variant: the last-line `ts` is this backend's
500        // drift cursor (`current_head()`), so same-millisecond commits
501        // must still advance it — see `append_change_monotonic`.
502        changelog::append_change_monotonic(&self.root, &entry, record.timestamp)
503            .map_err(|e| BackendError::Other(format!("changelog append: {e}")))
504    }
505
506    fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
507        let log_path = changelog_path(&self.root);
508        let raw = match std::fs::read_to_string(&log_path) {
509            Ok(s) => s,
510            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
511            Err(e) => return Err(BackendError::Io(e)),
512        };
513        let mut out = Vec::new();
514        for line in raw.lines() {
515            let trimmed = line.trim();
516            if trimmed.is_empty() {
517                continue;
518            }
519            let value: serde_json::Value = match serde_json::from_str(trimmed) {
520                Ok(v) => v,
521                Err(_) => continue,
522            };
523            let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
524            if let Some(c) = cursor
525                && ts_str <= c
526            {
527                continue;
528            }
529            let timestamp = parse_rfc3339_utc(ts_str).unwrap_or(std::time::UNIX_EPOCH);
530            let kind = value
531                .get("kind")
532                .and_then(|v| v.as_str())
533                .and_then(ProvenanceKind::parse)
534                .unwrap_or(ProvenanceKind::Update);
535            let entity = value
536                .get("entity")
537                .and_then(|v| v.as_str())
538                .map(|s| s.to_string());
539            let actor = value
540                .get("actor")
541                .and_then(|v| v.as_str())
542                .and_then(Actor::from_trailer)
543                .unwrap_or(Actor::Unknown);
544            let client = value
545                .get("client")
546                .and_then(|v| v.as_str())
547                .and_then(parse_client_id);
548            let note = value
549                .get("note")
550                .and_then(|v| v.as_str())
551                .map(|s| s.to_string());
552            let logical_operation_id = value
553                .get("logical_op")
554                .and_then(|v| v.as_str())
555                .map(|s| s.to_string());
556            let mut record = Provenance::new(timestamp, kind, entity, actor, client, note);
557            if let Some(id) = logical_operation_id {
558                record = record.with_logical_operation_id(id);
559            }
560            if let Some(role) = value
561                .get("role")
562                .and_then(|v| v.as_str())
563                .and_then(crate::vcs::Role::from_wire)
564            {
565                record = record.with_role(role);
566            }
567            record = record.with_identity(
568                value
569                    .get("identity")
570                    .and_then(|v| v.as_str())
571                    .map(|s| s.to_string()),
572            );
573            out.push(record);
574        }
575        Ok(out)
576    }
577}
578
579/// Walk `dir` for `.md` files, accumulating mem-relative paths in
580/// `out`. Skips the mem's `.memstead/` umbrella so the engine never
581/// confuses changelog / config / schema files with entity-bearing
582/// markdown, and `README.md` — repository documentation beside the
583/// entity files, never an entity (mirrors the entity-source walker's
584/// skip; entities are slug-named after their titles, so no legitimate
585/// entity file carries this name).
586fn walk_for_md(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BackendError> {
587    let entries = match std::fs::read_dir(dir) {
588        Ok(e) => e,
589        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
590        Err(e) => return Err(BackendError::Io(e)),
591    };
592    for entry in entries {
593        let entry = entry.map_err(BackendError::Io)?;
594        let path = entry.path();
595        let file_type = entry.file_type().map_err(BackendError::Io)?;
596        if file_type.is_dir() {
597            let name = entry.file_name();
598            if name == crate::mem::MEM_META_DIR {
599                continue;
600            }
601            walk_for_md(root, &path, out)?;
602        } else if file_type.is_file()
603            && path.extension().and_then(|s| s.to_str()) == Some("md")
604            && entry.file_name() != "README.md"
605            && let Ok(rel) = path.strip_prefix(root)
606        {
607            out.push(rel.to_path_buf());
608        }
609    }
610    Ok(())
611}
612
613/// Write `bytes` to `target` atomically: write to a sibling temp file
614/// then rename. Creates parent directories as needed. The temp file
615/// shares the target's parent so the rename is same-fs (atomic on
616/// POSIX). On rename failure, the temp file is best-effort removed.
617fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), MemWriterError> {
618    if let Some(parent) = target.parent()
619        && !parent.as_os_str().is_empty()
620    {
621        std::fs::create_dir_all(parent).map_err(MemWriterError::Io)?;
622    }
623    let tmp = make_tmp_path(target);
624    std::fs::write(&tmp, bytes).map_err(MemWriterError::Io)?;
625    if let Err(e) = std::fs::rename(&tmp, target) {
626        let _ = std::fs::remove_file(&tmp);
627        return Err(MemWriterError::Io(e));
628    }
629    Ok(())
630}
631
632/// Build a sibling temp path of the form `.<name>.tmp.<suffix>` next
633/// to `target`. The leading dot keeps the temp file out of the way of
634/// directory listings; the suffix combines UNIX-nanos with a process-
635/// scoped counter so concurrent writes never collide.
636fn make_tmp_path(target: &Path) -> PathBuf {
637    let name = target
638        .file_name()
639        .map(|n| n.to_string_lossy().to_string())
640        .unwrap_or_else(|| "_".to_string());
641    let suffix = unique_suffix();
642    let tmp_name = format!(".{name}.tmp.{suffix}");
643    target.with_file_name(tmp_name)
644}
645
646static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
647static COMMIT_COUNTER: AtomicU64 = AtomicU64::new(0);
648
649fn unique_suffix() -> String {
650    let nanos = std::time::SystemTime::now()
651        .duration_since(std::time::UNIX_EPOCH)
652        .map(|d| d.as_nanos())
653        .unwrap_or(0);
654    let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
655    format!("{nanos:x}-{counter:x}")
656}
657
658/// `pub(crate)` so the in-memory backend mints the same synthetic
659/// write-id shape (UNIX-nanos + counter, hex) the folder backend
660/// produces — both are history-free backends and must hand callers an
661/// identically-shaped opaque identity. It is NOT a change cursor:
662/// the change feed's cursor is an RFC3339 timestamp, and passing this
663/// token as `since` refuses with `INVALID_CURSOR` (it would otherwise
664/// sort below every timestamp and replay the whole history).
665pub(crate) fn make_commit_id() -> CommitId {
666    let nanos = std::time::SystemTime::now()
667        .duration_since(std::time::UNIX_EPOCH)
668        .map(|d| d.as_nanos())
669        .unwrap_or(0);
670    let counter = COMMIT_COUNTER.fetch_add(1, Ordering::Relaxed);
671    format!("{nanos:032x}{counter:016x}")
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use crate::vcs::{Actor, ClientId, CommitContext};
678    use tempfile::TempDir;
679
680    fn ctx_for_test<'a>() -> CommitContext<'a> {
681        CommitContext {
682            actor: Actor::Cli,
683            client: Some(ClientId {
684                name: "claude-code".to_string(),
685                version: "0.1.0".to_string(),
686            }),
687            tool: Some("test"),
688            note: None,
689            role: Default::default(),
690            identity: None,
691            logical_operation_id: None,
692            entity_ids: None,
693        }
694    }
695
696    /// `entity_exists` observes the same pending-buffer precedence as
697    /// `read_entity`: staged upsert → true before any commit, staged
698    /// delete → false while the file still sits on disk, and the
699    /// committed state answers between transactions — all without
700    /// reading bytes (flywheel W7/02 primitive).
701    #[test]
702    fn entity_exists_metadata_probe_and_pending_precedence() {
703        use crate::backend::MemBackend;
704        let tmp = TempDir::new().unwrap();
705        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
706
707        assert!(!MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
708
709        MemBackend::write_entity(
710            &writer,
711            Path::new("notes/a.md"),
712            b"# a
713",
714        )
715        .unwrap();
716        assert!(
717            MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
718            "staged upsert answers true before commit"
719        );
720
721        MemWriter::commit(&writer, "land a", &ctx_for_test()).unwrap();
722        assert!(MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
723
724        MemBackend::delete_entity(&writer, Path::new("notes/a.md")).unwrap();
725        assert!(
726            !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
727            "staged delete answers false while the file is still on disk"
728        );
729    }
730
731    #[test]
732    fn write_then_commit_round_trip() {
733        let tmp = TempDir::new().unwrap();
734        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
735
736        writer
737            .write_entity(Path::new("notes/hello.md"), b"# hi\n")
738            .unwrap();
739        let id = writer.commit("first commit", &ctx_for_test()).unwrap();
740        assert!(!id.is_empty());
741
742        let bytes = std::fs::read(tmp.path().join("notes/hello.md")).unwrap();
743        assert_eq!(bytes, b"# hi\n");
744    }
745
746    #[test]
747    fn delete_removes_path() {
748        let tmp = TempDir::new().unwrap();
749        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
750
751        writer.write_entity(Path::new("a.md"), b"a").unwrap();
752        writer.write_entity(Path::new("b.md"), b"b").unwrap();
753        writer.commit("seed", &ctx_for_test()).unwrap();
754
755        writer.delete_entity(Path::new("a.md")).unwrap();
756        writer.commit("drop a", &ctx_for_test()).unwrap();
757
758        assert!(!tmp.path().join("a.md").exists());
759        assert!(tmp.path().join("b.md").exists());
760    }
761
762    #[test]
763    fn delete_of_missing_path_is_idempotent() {
764        let tmp = TempDir::new().unwrap();
765        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
766
767        writer.delete_entity(Path::new("never-existed.md")).unwrap();
768        writer.commit("noop delete", &ctx_for_test()).unwrap();
769    }
770
771    #[test]
772    fn move_renames_path() {
773        let tmp = TempDir::new().unwrap();
774        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
775
776        writer
777            .write_entity(Path::new("from.md"), b"payload")
778            .unwrap();
779        writer.commit("seed", &ctx_for_test()).unwrap();
780
781        writer
782            .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
783            .unwrap();
784        writer.commit("rename", &ctx_for_test()).unwrap();
785
786        assert!(!tmp.path().join("from.md").exists());
787        let moved = std::fs::read(tmp.path().join("nested/to.md")).unwrap();
788        assert_eq!(moved, b"payload");
789    }
790
791    #[test]
792    fn move_with_pending_upsert_carries_bytes() {
793        let tmp = TempDir::new().unwrap();
794        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
795
796        writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
797        writer
798            .move_entity(Path::new("a.md"), Path::new("b.md"))
799            .unwrap();
800        writer.commit("write+move", &ctx_for_test()).unwrap();
801
802        assert!(!tmp.path().join("a.md").exists());
803        assert_eq!(std::fs::read(tmp.path().join("b.md")).unwrap(), b"alpha");
804    }
805
806    #[test]
807    fn move_missing_source_errors() {
808        let tmp = TempDir::new().unwrap();
809        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
810
811        let err = writer
812            .move_entity(Path::new("ghost.md"), Path::new("here.md"))
813            .unwrap_err();
814        assert!(matches!(err, MemWriterError::Path(_)));
815    }
816
817    #[test]
818    fn move_with_pending_target_upsert_errors() {
819        let tmp = TempDir::new().unwrap();
820        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
821
822        writer.write_entity(Path::new("from.md"), b"x").unwrap();
823        writer.write_entity(Path::new("to.md"), b"y").unwrap();
824        let err = writer
825            .move_entity(Path::new("from.md"), Path::new("to.md"))
826            .unwrap_err();
827        assert!(matches!(err, MemWriterError::Path(_)));
828    }
829
830    #[test]
831    fn multi_op_commit() {
832        let tmp = TempDir::new().unwrap();
833        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
834
835        writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
836        writer.commit("seed", &ctx_for_test()).unwrap();
837
838        writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
839        writer
840            .write_entity(Path::new("nested/b.md"), b"beta")
841            .unwrap();
842        writer.delete_entity(Path::new("doomed.md")).unwrap();
843        writer.commit("multi-op", &ctx_for_test()).unwrap();
844
845        assert!(!tmp.path().join("doomed.md").exists());
846        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
847        assert_eq!(
848            std::fs::read(tmp.path().join("nested/b.md")).unwrap(),
849            b"beta"
850        );
851    }
852
853    #[test]
854    fn rejects_path_traversal() {
855        let tmp = TempDir::new().unwrap();
856        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
857
858        let err = writer
859            .write_entity(Path::new("../escape.md"), b"x")
860            .unwrap_err();
861        assert!(matches!(err, MemWriterError::Path(_)));
862    }
863
864    #[test]
865    fn rejects_absolute_path() {
866        let tmp = TempDir::new().unwrap();
867        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
868
869        let err = writer
870            .write_entity(Path::new("/etc/passwd"), b"x")
871            .unwrap_err();
872        assert!(matches!(err, MemWriterError::Path(_)));
873    }
874
875    #[test]
876    fn rejects_empty_path() {
877        let tmp = TempDir::new().unwrap();
878        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
879
880        let err = writer.write_entity(Path::new(""), b"x").unwrap_err();
881        assert!(matches!(err, MemWriterError::Path(_)));
882    }
883
884    #[test]
885    fn write_overwrites_existing_file() {
886        let tmp = TempDir::new().unwrap();
887        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
888
889        writer.write_entity(Path::new("a.md"), b"first").unwrap();
890        writer.commit("c1", &ctx_for_test()).unwrap();
891        writer.write_entity(Path::new("a.md"), b"second").unwrap();
892        writer.commit("c2", &ctx_for_test()).unwrap();
893
894        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"second");
895    }
896
897    #[test]
898    fn no_temp_files_left_after_commit() {
899        let tmp = TempDir::new().unwrap();
900        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
901
902        writer.write_entity(Path::new("a.md"), b"a").unwrap();
903        writer.write_entity(Path::new("nested/b.md"), b"b").unwrap();
904        writer.commit("c", &ctx_for_test()).unwrap();
905
906        // Walk the tree and ensure no `.tmp.` artefacts survived.
907        fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
908            for entry in std::fs::read_dir(dir).unwrap() {
909                let p = entry.unwrap().path();
910                if p.is_dir() {
911                    walk(&p, out);
912                } else {
913                    out.push(p);
914                }
915            }
916        }
917        let mut files = Vec::new();
918        walk(tmp.path(), &mut files);
919        for f in &files {
920            let name = f.file_name().unwrap().to_string_lossy();
921            assert!(
922                !name.contains(".tmp."),
923                "stray temp file after commit: {}",
924                f.display()
925            );
926        }
927    }
928
929    #[test]
930    fn commit_id_is_unique_across_calls() {
931        let tmp = TempDir::new().unwrap();
932        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
933
934        writer.write_entity(Path::new("a.md"), b"a").unwrap();
935        let id1 = writer.commit("c1", &ctx_for_test()).unwrap();
936        writer.write_entity(Path::new("b.md"), b"b").unwrap();
937        let id2 = writer.commit("c2", &ctx_for_test()).unwrap();
938
939        assert_ne!(id1, id2);
940    }
941
942    #[test]
943    fn pending_buffer_clears_on_commit() {
944        let tmp = TempDir::new().unwrap();
945        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
946
947        writer.write_entity(Path::new("a.md"), b"a").unwrap();
948        writer.commit("c1", &ctx_for_test()).unwrap();
949        // A second commit with no mutations writes nothing new but
950        // still returns a fresh id.
951        let id2 = writer.commit("noop", &ctx_for_test()).unwrap();
952        assert!(!id2.is_empty());
953        // a.md still has its original content (no zombie pending op).
954        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"a");
955    }
956
957    // --- MemBackend impl ----------------------------------------
958
959    /// Folder backend's `write_mem_config` writes
960    /// `<root>/.memstead/config.json`, creating the umbrella directory
961    /// if needed. The subsequent `read_mem_config` round-trips the
962    /// bytes.
963    #[test]
964    fn backend_write_mem_config_round_trips_via_read() {
965        use crate::backend::MemBackend;
966
967        let tmp = TempDir::new().unwrap();
968        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
969        let backend: &dyn MemBackend = &writer;
970
971        // No config yet — read returns None.
972        assert!(backend.read_mem_config().unwrap().is_none());
973
974        // Write — creates `.memstead/` umbrella + the config blob.
975        let bytes = br#"{"version":"0.1.0","schema":"default@1.0.0"}"#.to_vec();
976        backend.write_mem_config(&bytes).unwrap();
977
978        // Read returns the same bytes.
979        let read_back = backend.read_mem_config().unwrap();
980        assert_eq!(read_back, Some(bytes.clone()));
981        // Config file lands at `<root>/.memstead/config.json`.
982        let on_disk = std::fs::read(tmp.path().join(".memstead/config.json")).unwrap();
983        assert_eq!(on_disk, bytes);
984    }
985
986    #[test]
987    fn backend_list_entities_returns_only_md_outside_meta_dirs() {
988        use crate::backend::MemBackend;
989
990        let tmp = TempDir::new().unwrap();
991        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
992
993        // With both traits in scope, dot-syntax `writer.foo(...)`
994        // is ambiguous — seed via fully-qualified MemWriter calls.
995        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
996        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"b")
997            .unwrap();
998        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
999            .unwrap();
1000        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1001        // The current `.memstead/` meta dir is skipped by the walker.
1002        // An ordinary dot-dir (`.other/`) is not special, so markdown
1003        // under it is walked like any other non-meta path.
1004        std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1005        std::fs::write(tmp.path().join(".memstead/config.json"), b"{}").unwrap();
1006        std::fs::write(tmp.path().join(".memstead/notes.md"), b"#").unwrap();
1007        std::fs::create_dir_all(tmp.path().join(".other")).unwrap();
1008        std::fs::write(tmp.path().join(".other/notes.md"), b"#").unwrap();
1009
1010        let backend: &dyn MemBackend = &writer;
1011        let mut paths: Vec<String> = backend
1012            .list_entities()
1013            .unwrap()
1014            .into_iter()
1015            .map(|p| p.to_string_lossy().into_owned())
1016            .collect();
1017        paths.sort();
1018        assert_eq!(
1019            paths,
1020            vec![
1021                ".other/notes.md".to_string(),
1022                "a.md".to_string(),
1023                "nested/b.md".to_string(),
1024            ]
1025        );
1026    }
1027
1028    #[test]
1029    fn backend_read_entity_consults_pending_then_disk() {
1030        let tmp = TempDir::new().unwrap();
1031        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1032
1033        // Seed via the legacy MemWriter trait — both traits expose
1034        // `write_entity`; importing `MemBackend` later in the test
1035        // makes the dot-syntax ambiguous, so we route the seed
1036        // through the trait that's still implicitly in scope here.
1037        <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("on_disk.md"), b"disk")
1038            .unwrap();
1039        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1040
1041        use crate::backend::MemBackend;
1042        let backend: &dyn MemBackend = &writer;
1043        // Disk path → reads from disk.
1044        assert_eq!(
1045            backend.read_entity(Path::new("on_disk.md")).unwrap(),
1046            Some(b"disk".to_vec())
1047        );
1048        // Buffered upsert wins over disk.
1049        backend
1050            .write_entity(Path::new("on_disk.md"), b"buffered")
1051            .unwrap();
1052        assert_eq!(
1053            backend.read_entity(Path::new("on_disk.md")).unwrap(),
1054            Some(b"buffered".to_vec())
1055        );
1056        // Buffered delete masks disk.
1057        backend.delete_entity(Path::new("on_disk.md")).unwrap();
1058        assert_eq!(backend.read_entity(Path::new("on_disk.md")).unwrap(), None);
1059        // Unknown path → None (idempotent).
1060        assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
1061    }
1062
1063    #[test]
1064    fn backend_provenance_round_trips_through_jsonl() {
1065        let tmp = TempDir::new().unwrap();
1066        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1067        use crate::backend::MemBackend;
1068        let backend: &dyn MemBackend = &writer;
1069
1070        let client = ClientId {
1071            name: "claude-code".into(),
1072            version: "2.1.0".into(),
1073        };
1074        let earlier = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
1075        let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_777_077_296);
1076
1077        backend
1078            .append_provenance(&Provenance::new(
1079                earlier,
1080                ProvenanceKind::Create,
1081                Some("v:e1".into()),
1082                Actor::Agent,
1083                Some(client.clone()),
1084                Some("first".into()),
1085            ))
1086            .unwrap();
1087        backend
1088            .append_provenance(&Provenance::new(
1089                later,
1090                ProvenanceKind::Update,
1091                Some("v:e1".into()),
1092                Actor::Cli,
1093                None,
1094                None,
1095            ))
1096            .unwrap();
1097
1098        let all = backend.read_provenance(None).unwrap();
1099        assert_eq!(all.len(), 2);
1100        assert_eq!(all[0].kind, ProvenanceKind::Create);
1101        assert_eq!(all[0].entity.as_deref(), Some("v:e1"));
1102        assert_eq!(all[0].actor, Actor::Agent);
1103        assert_eq!(all[0].note.as_deref(), Some("first"));
1104        assert_eq!(
1105            all[0]
1106                .client
1107                .as_ref()
1108                .map(|c| (c.name.as_str(), c.version.as_str())),
1109            Some(("claude-code", "2.1.0"))
1110        );
1111        assert_eq!(all[0].timestamp, earlier);
1112        assert_eq!(all[1].kind, ProvenanceKind::Update);
1113        assert_eq!(all[1].timestamp, later);
1114        assert!(all[1].note.is_none());
1115        assert!(all[1].client.is_none());
1116    }
1117
1118    #[test]
1119    fn backend_provenance_cursor_filters_by_timestamp() {
1120        let tmp = TempDir::new().unwrap();
1121        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1122        use crate::backend::MemBackend;
1123        let backend: &dyn MemBackend = &writer;
1124
1125        for (secs, label) in [
1126            (1_700_000_000u64, "first"),
1127            (1_750_000_000, "middle"),
1128            (1_800_000_000, "last"),
1129        ] {
1130            backend
1131                .append_provenance(&Provenance::new(
1132                    std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1133                    ProvenanceKind::Create,
1134                    Some(format!("v:{label}")),
1135                    Actor::Cli,
1136                    None,
1137                    None,
1138                ))
1139                .unwrap();
1140        }
1141        // Cursor between first and middle should drop the first entry.
1142        let cursor = changelog::format_rfc3339_utc(
1143            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1144        );
1145        let after = backend.read_provenance(Some(&cursor)).unwrap();
1146        let entities: Vec<_> = after.iter().filter_map(|p| p.entity.clone()).collect();
1147        assert_eq!(entities, vec!["v:middle".to_string(), "v:last".to_string()]);
1148    }
1149
1150    #[test]
1151    fn backend_read_provenance_handles_missing_log() {
1152        let tmp = TempDir::new().unwrap();
1153        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1154        use crate::backend::MemBackend;
1155        let backend: &dyn MemBackend = &writer;
1156        // No mutations yet → no `.memstead/changes.jsonl` → empty result, no error.
1157        assert!(backend.read_provenance(None).unwrap().is_empty());
1158    }
1159
1160    // ---- folder JSONL synthesis (memstead_base::ops::folder_changes_since) ----
1161
1162    /// Helper: append a single Provenance event with an explicit
1163    /// timestamp, so tests get deterministic JSONL ordering.
1164    fn append_at(
1165        backend: &dyn crate::backend::MemBackend,
1166        secs: u64,
1167        kind: ProvenanceKind,
1168        entity: &str,
1169    ) {
1170        backend
1171            .append_provenance(&Provenance::new(
1172                std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1173                kind,
1174                Some(entity.to_string()),
1175                Actor::Cli,
1176                None,
1177                None,
1178            ))
1179            .unwrap();
1180    }
1181
1182    #[test]
1183    fn folder_changes_since_no_log_returns_empty_at_cursor() {
1184        // Fresh mem, no `.memstead/changes.jsonl` → empty BackendChanges
1185        // with `head` echoing the cursor.
1186        let tmp = TempDir::new().unwrap();
1187        let result =
1188            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1189                .unwrap();
1190        assert_eq!(result.since, crate::ops::EMPTY_TREE_SHA);
1191        assert_eq!(result.head, crate::ops::EMPTY_TREE_SHA);
1192        assert!(result.changes.is_empty());
1193    }
1194
1195    #[test]
1196    fn folder_changes_since_create_only_yields_added_envelope() {
1197        let tmp = TempDir::new().unwrap();
1198        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1199        append_at(
1200            &writer,
1201            1_700_000_000,
1202            ProvenanceKind::Create,
1203            "specs--alpha",
1204        );
1205
1206        let result =
1207            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1208                .unwrap();
1209        assert_eq!(result.changes.len(), 1);
1210        match &result.changes[0] {
1211            crate::ops::ChangeEnvelope::Added {
1212                id,
1213                title,
1214                entity_type,
1215            } => {
1216                assert_eq!(id.0, "specs--alpha");
1217                assert!(title.is_none(), "id-only contract");
1218                assert!(entity_type.is_none(), "id-only contract");
1219            }
1220            other => panic!("expected Added, got {other:?}"),
1221        }
1222        // head advances to the event's timestamp.
1223        assert_ne!(result.head, crate::ops::EMPTY_TREE_SHA);
1224    }
1225
1226    #[test]
1227    fn folder_changes_since_create_then_delete_cancels_to_no_envelope() {
1228        // Within the cursor window, an entity that was created and then
1229        // deleted nets out to Removed (final state wins for Delete).
1230        let tmp = TempDir::new().unwrap();
1231        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1232        append_at(
1233            &writer,
1234            1_700_000_000,
1235            ProvenanceKind::Create,
1236            "specs--ephemeral",
1237        );
1238        append_at(
1239            &writer,
1240            1_700_000_001,
1241            ProvenanceKind::Delete,
1242            "specs--ephemeral",
1243        );
1244
1245        let result =
1246            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1247                .unwrap();
1248        assert_eq!(result.changes.len(), 1);
1249        match &result.changes[0] {
1250            crate::ops::ChangeEnvelope::Removed { id, .. } => {
1251                assert_eq!(id.0, "specs--ephemeral");
1252            }
1253            other => panic!("expected Removed (Delete wins), got {other:?}"),
1254        }
1255    }
1256
1257    #[test]
1258    fn folder_changes_since_update_only_yields_updated_envelope() {
1259        let tmp = TempDir::new().unwrap();
1260        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1261        append_at(
1262            &writer,
1263            1_700_000_000,
1264            ProvenanceKind::Update,
1265            "specs--alpha",
1266        );
1267
1268        let result =
1269            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1270                .unwrap();
1271        assert_eq!(result.changes.len(), 1);
1272        assert!(matches!(
1273            result.changes[0],
1274            crate::ops::ChangeEnvelope::Updated { .. }
1275        ));
1276    }
1277
1278    /// The exact confusion the guard exists for: a mutation's
1279    /// `write_id` (fixed-width hex, sorts below every timestamp)
1280    /// passed back as `since` must refuse, never silently replay the
1281    /// whole history. Garbage refuses on the same rule; the empty
1282    /// string and the empty-tree sentinel stay "from the beginning".
1283    #[test]
1284    fn folder_changes_since_refuses_non_timestamp_cursor() {
1285        let tmp = TempDir::new().unwrap();
1286        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1287        append_at(
1288            &writer,
1289            1_700_000_000,
1290            ProvenanceKind::Create,
1291            "specs--alpha",
1292        );
1293
1294        let write_token = make_commit_id();
1295        for bad in [write_token.as_str(), "not-a-timestamp"] {
1296            let err = crate::ops::folder_changes_since(tmp.path(), "specs", bad).unwrap_err();
1297            match err {
1298                BackendError::Other(msg) => {
1299                    assert_eq!(msg, format!("INVALID_TS_CURSOR:{bad}"));
1300                }
1301                other => panic!("expected typed marker, got {other:?}"),
1302            }
1303        }
1304        for from_start in ["", crate::ops::EMPTY_TREE_SHA] {
1305            let ok = crate::ops::folder_changes_since(tmp.path(), "specs", from_start).unwrap();
1306            assert_eq!(ok.changes.len(), 1, "sentinel '{from_start}' reads all");
1307        }
1308    }
1309
1310    #[test]
1311    fn folder_changes_since_cursor_filters_to_window() {
1312        // Three events at three timestamps; cursor between first and
1313        // second drops the first event from the window.
1314        let tmp = TempDir::new().unwrap();
1315        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1316        append_at(
1317            &writer,
1318            1_700_000_000,
1319            ProvenanceKind::Create,
1320            "specs--first",
1321        );
1322        append_at(
1323            &writer,
1324            1_750_000_000,
1325            ProvenanceKind::Create,
1326            "specs--middle",
1327        );
1328        append_at(
1329            &writer,
1330            1_800_000_000,
1331            ProvenanceKind::Create,
1332            "specs--last",
1333        );
1334
1335        let cursor = changelog::format_rfc3339_utc(
1336            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1337        );
1338        let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1339        assert_eq!(result.changes.len(), 2);
1340        let ids: Vec<_> = result
1341            .changes
1342            .iter()
1343            .map(|e| match e {
1344                crate::ops::ChangeEnvelope::Added { id, .. } => id.0.clone(),
1345                _ => panic!("expected Added"),
1346            })
1347            .collect();
1348        // BTreeMap iteration order — ids sort lexicographically.
1349        assert_eq!(
1350            ids,
1351            vec!["specs--last".to_string(), "specs--middle".to_string()]
1352        );
1353    }
1354
1355    #[test]
1356    fn folder_changes_since_skips_events_for_other_mems() {
1357        // Defensive: changelog drift could carry events for another
1358        // mem prefix; the impl filters them out so envelopes only
1359        // surface for the queried mem.
1360        let tmp = TempDir::new().unwrap();
1361        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1362        append_at(
1363            &writer,
1364            1_700_000_000,
1365            ProvenanceKind::Create,
1366            "specs--mine",
1367        );
1368        append_at(
1369            &writer,
1370            1_700_000_001,
1371            ProvenanceKind::Create,
1372            "other--theirs",
1373        );
1374
1375        let result =
1376            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1377                .unwrap();
1378        assert_eq!(result.changes.len(), 1);
1379        match &result.changes[0] {
1380            crate::ops::ChangeEnvelope::Added { id, .. } => {
1381                assert_eq!(id.0, "specs--mine");
1382            }
1383            other => panic!("expected Added, got {other:?}"),
1384        }
1385    }
1386
1387    #[test]
1388    fn folder_changes_since_skips_batch_events_with_no_entity() {
1389        // Batch events have entity=null. They don't surface as
1390        // envelopes (no per-entity id to attach to).
1391        let tmp = TempDir::new().unwrap();
1392        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1393        use crate::backend::MemBackend;
1394        // append_at requires an entity; use append_provenance directly
1395        // for the batch-with-no-entity case.
1396        writer
1397            .append_provenance(&Provenance::new(
1398                std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1399                ProvenanceKind::Batch,
1400                None,
1401                Actor::Cli,
1402                None,
1403                None,
1404            ))
1405            .unwrap();
1406        append_at(
1407            &writer,
1408            1_700_000_001,
1409            ProvenanceKind::Create,
1410            "specs--real",
1411        );
1412
1413        let result =
1414            crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1415                .unwrap();
1416        // Only the Create-Real event surfaces; the batch is dropped.
1417        assert_eq!(result.changes.len(), 1);
1418        match &result.changes[0] {
1419            crate::ops::ChangeEnvelope::Added { id, .. } => {
1420                assert_eq!(id.0, "specs--real");
1421            }
1422            other => panic!("expected Added, got {other:?}"),
1423        }
1424    }
1425
1426    #[test]
1427    fn folder_changes_since_head_echoes_cursor_when_no_events_in_window() {
1428        // Events exist but all before the cursor → head echoes cursor.
1429        let tmp = TempDir::new().unwrap();
1430        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1431        append_at(&writer, 1_700_000_000, ProvenanceKind::Create, "specs--old");
1432
1433        let cursor = changelog::format_rfc3339_utc(
1434            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_900_000_000),
1435        );
1436        let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1437        assert!(result.changes.is_empty());
1438        assert_eq!(result.head, cursor);
1439    }
1440
1441    #[test]
1442    fn backend_writes_delegate_to_memwriter() {
1443        let tmp = TempDir::new().unwrap();
1444        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1445        use crate::backend::MemBackend;
1446        let backend: &dyn MemBackend = &writer;
1447
1448        backend.write_entity(Path::new("a.md"), b"alpha").unwrap();
1449        backend.commit("seed", &ctx_for_test()).unwrap();
1450        assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
1451
1452        backend.delete_entity(Path::new("a.md")).unwrap();
1453        backend.commit("drop", &ctx_for_test()).unwrap();
1454        assert!(!tmp.path().join("a.md").exists());
1455    }
1456
1457    #[test]
1458    fn parse_client_id_splits_on_last_at() {
1459        let c = parse_client_id("claude-code@2.1.0").unwrap();
1460        assert_eq!(c.name, "claude-code");
1461        assert_eq!(c.version, "2.1.0");
1462        // Edge: name with `.`
1463        let c = parse_client_id("foo.bar@1.0").unwrap();
1464        assert_eq!(c.name, "foo.bar");
1465        // Bare strings without `@` → None (forward-compat: tolerant
1466        // readers ignore rather than mis-construct).
1467        assert!(parse_client_id("naked").is_none());
1468        assert!(parse_client_id("@1.0").is_none());
1469        assert!(parse_client_id("name@").is_none());
1470    }
1471}
1472
1473#[cfg(test)]
1474mod folder_drift_tests {
1475    use super::*;
1476
1477    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";
1478    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";
1479
1480    fn folder_engine(dir: std::path::PathBuf) -> crate::Engine {
1481        let mount = crate::Mount {
1482            mem: "specs".to_string(),
1483            schema: Some(memstead_schema::SchemaRef::new(
1484                "default",
1485                semver::Version::new(1, 0, 0),
1486            )),
1487            storage: crate::MountStorage::Folder { path: dir.clone() },
1488            capability: crate::MountCapability::Write,
1489            lifecycle: crate::MountLifecycle::Eager,
1490            cross_linkable: false,
1491            migration_target: None,
1492        };
1493        let backend = Box::new(FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
1494        crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
1495    }
1496
1497    /// A sibling process's folder commit is drift: the changelog-ts
1498    /// cursor advances, `reload_if_stale` reloads, surfaces
1499    /// MEM_RELOADED, stashes the structured notice — and the engine's
1500    /// own writes never masquerade as drift (the recorded head is
1501    /// probe-corrected to the cursor dialect).
1502    #[test]
1503    fn sibling_folder_write_is_drift_and_self_write_is_not() {
1504        let tmp = tempfile::TempDir::new().unwrap();
1505        let dir = tmp.path().join("specs");
1506        std::fs::create_dir_all(&dir).unwrap();
1507        // Seed through a writer WITH provenance so a baseline cursor exists.
1508        let seeder = FilesystemMemWriter::new(dir.clone());
1509        MemWriter::write_entity(&seeder, std::path::Path::new("seed.md"), ENTITY.as_bytes())
1510            .unwrap();
1511        MemWriter::commit(&seeder, "seed", &CommitContext::internal()).unwrap();
1512        crate::backend::MemBackend::append_provenance(
1513            &seeder,
1514            &Provenance::new(
1515                std::time::SystemTime::now(),
1516                ProvenanceKind::Create,
1517                Some("specs--seed".into()),
1518                Actor::Cli,
1519                None,
1520                None,
1521            ),
1522        )
1523        .unwrap();
1524
1525        let mut engine = folder_engine(dir.clone());
1526        // First probe captures the baseline silently.
1527        assert!(engine.reload_if_stale(None).is_empty());
1528
1529        // Self-write through the engine: no spurious drift afterwards.
1530        engine
1531            .create_entity(
1532                crate::CreateEntityArgs {
1533                    mem: "specs".to_string(),
1534                    title: "Self Made".to_string(),
1535                    entity_type: "spec".to_string(),
1536                    sections: [
1537                        ("identity".to_string(), "self".to_string()),
1538                        ("purpose".to_string(), "prove no self-drift".to_string()),
1539                    ]
1540                    .into_iter()
1541                    .collect(),
1542                    metadata: Default::default(),
1543                    relations: Vec::new(),
1544                    anchors: Vec::new(),
1545                    dry_run: false,
1546                },
1547                crate::vcs::Actor::Cli,
1548                None,
1549                None,
1550            )
1551            .unwrap();
1552        assert!(
1553            engine.reload_if_stale(None).is_empty(),
1554            "the engine's own write must not read as sibling drift"
1555        );
1556        assert!(engine.take_mem_changed_notices().is_empty());
1557
1558        // Sibling write: a separate writer instance (a stand-in for a
1559        // second process) commits + appends provenance out-of-band.
1560        std::thread::sleep(std::time::Duration::from_millis(5));
1561        let sibling = FilesystemMemWriter::new(dir);
1562        MemWriter::write_entity(
1563            &sibling,
1564            std::path::Path::new("sibling.md"),
1565            SIBLING_ENTITY.as_bytes(),
1566        )
1567        .unwrap();
1568        MemWriter::commit(&sibling, "sibling", &CommitContext::internal()).unwrap();
1569        crate::backend::MemBackend::append_provenance(
1570            &sibling,
1571            &Provenance::new(
1572                std::time::SystemTime::now(),
1573                ProvenanceKind::Create,
1574                Some("specs--sibling".into()),
1575                Actor::Cli,
1576                None,
1577                None,
1578            ),
1579        )
1580        .unwrap();
1581
1582        let warnings = engine.reload_if_stale(None);
1583        assert_eq!(
1584            warnings.len(),
1585            1,
1586            "sibling drift must surface: {warnings:?}"
1587        );
1588        match &warnings[0] {
1589            crate::ops::WarningHint::MemReloaded { mem, .. } => assert_eq!(mem, "specs"),
1590            other => panic!("expected MemReloaded, got {other:?}"),
1591        }
1592        let notices = engine.take_mem_changed_notices();
1593        assert_eq!(notices.len(), 1);
1594        // Post-reload the sibling entity is visible.
1595        assert!(
1596            engine
1597                .get_entity(&crate::EntityId("specs--sibling".to_string()))
1598                .is_some(),
1599            "reload must surface the sibling's entity"
1600        );
1601        // Idempotent probe: no repeat notice.
1602        assert!(engine.reload_if_stale(None).is_empty());
1603    }
1604}