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