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