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