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