1use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32use std::sync::Mutex;
33use std::sync::atomic::{AtomicU64, Ordering};
34
35use super::{CommitId, MemWriter, MemWriterError};
36use 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
50enum PendingState {
55 Upsert(Vec<u8>),
56 Delete,
57}
58
59struct 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
76pub struct FilesystemMemWriter {
80 root: PathBuf,
81 pending: Mutex<Pending>,
82}
83
84impl FilesystemMemWriter {
85 pub fn new(root: PathBuf) -> Self {
88 Self {
89 root,
90 pending: Mutex::new(Pending::new()),
91 }
92 }
93
94 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
117pub(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 current_head(&self) -> Result<Option<String>, BackendError> {
264 let log_path = crate::filesystem::changelog::changelog_path(&self.root);
265 let raw = match std::fs::read_to_string(&log_path) {
266 Ok(s) => s,
267 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
268 Err(e) => {
269 return Err(BackendError::Other(format!(
270 "reading folder changelog {}: {e}",
271 log_path.display()
272 )));
273 }
274 };
275 let last_ts = raw
276 .lines()
277 .rev()
278 .filter_map(|line| {
279 let trimmed = line.trim();
280 if trimmed.is_empty() {
281 return None;
282 }
283 serde_json::from_str::<serde_json::Value>(trimmed)
284 .ok()?
285 .get("ts")?
286 .as_str()
287 .map(str::to_string)
288 })
289 .next();
290 Ok(last_ts)
291 }
292
293 fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
294 let key = normalise_rel_path(rel_path)?;
295 let pending = self.pending.lock().map_err(|_| {
296 BackendError::Other("filesystem writer pending state poisoned".to_string())
297 })?;
298 if let Some(state) = pending.ops.get(&key) {
299 return Ok(match state {
300 PendingState::Upsert(bytes) => Some(bytes.clone()),
301 PendingState::Delete => None,
302 });
303 }
304 let full = self.root.join(&key);
305 match std::fs::read(&full) {
306 Ok(bytes) => Ok(Some(bytes)),
307 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
308 Err(e) => Err(BackendError::Io(e)),
309 }
310 }
311
312 fn storage_present(&self) -> Result<bool, BackendError> {
320 Ok(self.root.is_dir())
321 }
322
323 fn entity_exists(&self, rel_path: &Path) -> Result<bool, BackendError> {
324 let key = normalise_rel_path(rel_path)?;
325 let pending = self.pending.lock().map_err(|_| {
326 BackendError::Other("filesystem writer pending state poisoned".to_string())
327 })?;
328 if let Some(state) = pending.ops.get(&key) {
329 return Ok(matches!(state, PendingState::Upsert(_)));
330 }
331 drop(pending);
332 match std::fs::symlink_metadata(self.root.join(&key)) {
333 Ok(md) => Ok(md.is_file()),
334 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
335 Err(e) => Err(BackendError::Io(e)),
336 }
337 }
338
339 fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError> {
340 <Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
341 }
342
343 fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError> {
344 <Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
345 }
346
347 fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError> {
348 <Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
349 }
350
351 fn discard_pending(&self) -> Result<(), BackendError> {
352 let mut pending = self.pending.lock().map_err(|_| {
356 BackendError::Other("filesystem writer pending state poisoned".to_string())
357 })?;
358 pending.clear();
359 Ok(())
360 }
361
362 fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
363 <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
364 }
365
366 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
367 let config_path = self.root.join(crate::mem::MEM_META_DIR).join("config.json");
371 match std::fs::read(&config_path) {
372 Ok(bytes) => Ok(Some(bytes)),
373 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
374 Err(e) => Err(BackendError::Io(e)),
375 }
376 }
377
378 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), BackendError> {
379 let memstead_dir = self.root.join(crate::mem::MEM_META_DIR);
386 std::fs::create_dir_all(&memstead_dir).map_err(BackendError::Io)?;
387 let config_path = memstead_dir.join("config.json");
388 std::fs::write(&config_path, bytes).map_err(BackendError::Io)
389 }
390
391 fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
392 self.read_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH))
395 }
396
397 fn write_anchors_sidecar(&self, bytes: &[u8]) -> Result<(), BackendError> {
398 <Self as MemWriter>::write_entity(
403 self,
404 Path::new(crate::anchor::ANCHOR_SIDECAR_PATH),
405 bytes,
406 )
407 .map_err(Into::into)
408 }
409
410 fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
411 let kind: MutationKind = record.kind.into();
412 let entry = ChangeEntry {
413 kind,
414 entity: record.entity.as_deref(),
415 actor: record.actor,
416 client: record.client.as_ref(),
417 note: record.note.as_deref(),
418 logical_operation_id: record.logical_operation_id.as_deref(),
419 role: record.role,
420 };
421 changelog::append_change_monotonic(&self.root, &entry, record.timestamp)
425 .map_err(|e| BackendError::Other(format!("changelog append: {e}")))
426 }
427
428 fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
429 let log_path = changelog_path(&self.root);
430 let raw = match std::fs::read_to_string(&log_path) {
431 Ok(s) => s,
432 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
433 Err(e) => return Err(BackendError::Io(e)),
434 };
435 let mut out = Vec::new();
436 for line in raw.lines() {
437 let trimmed = line.trim();
438 if trimmed.is_empty() {
439 continue;
440 }
441 let value: serde_json::Value = match serde_json::from_str(trimmed) {
442 Ok(v) => v,
443 Err(_) => continue,
444 };
445 let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
446 if let Some(c) = cursor
447 && ts_str <= c
448 {
449 continue;
450 }
451 let timestamp = parse_rfc3339_utc(ts_str).unwrap_or(std::time::UNIX_EPOCH);
452 let kind = value
453 .get("kind")
454 .and_then(|v| v.as_str())
455 .and_then(ProvenanceKind::parse)
456 .unwrap_or(ProvenanceKind::Update);
457 let entity = value
458 .get("entity")
459 .and_then(|v| v.as_str())
460 .map(|s| s.to_string());
461 let actor = value
462 .get("actor")
463 .and_then(|v| v.as_str())
464 .and_then(Actor::from_trailer)
465 .unwrap_or(Actor::Unknown);
466 let client = value
467 .get("client")
468 .and_then(|v| v.as_str())
469 .and_then(parse_client_id);
470 let note = value
471 .get("note")
472 .and_then(|v| v.as_str())
473 .map(|s| s.to_string());
474 let logical_operation_id = value
475 .get("logical_op")
476 .and_then(|v| v.as_str())
477 .map(|s| s.to_string());
478 let mut record = Provenance::new(timestamp, kind, entity, actor, client, note);
479 if let Some(id) = logical_operation_id {
480 record = record.with_logical_operation_id(id);
481 }
482 if let Some(role) = value
483 .get("role")
484 .and_then(|v| v.as_str())
485 .and_then(crate::vcs::Role::from_wire)
486 {
487 record = record.with_role(role);
488 }
489 out.push(record);
490 }
491 Ok(out)
492 }
493}
494
495fn walk_for_md(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BackendError> {
503 let entries = match std::fs::read_dir(dir) {
504 Ok(e) => e,
505 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
506 Err(e) => return Err(BackendError::Io(e)),
507 };
508 for entry in entries {
509 let entry = entry.map_err(BackendError::Io)?;
510 let path = entry.path();
511 let file_type = entry.file_type().map_err(BackendError::Io)?;
512 if file_type.is_dir() {
513 let name = entry.file_name();
514 if name == crate::mem::MEM_META_DIR {
515 continue;
516 }
517 walk_for_md(root, &path, out)?;
518 } else if file_type.is_file()
519 && path.extension().and_then(|s| s.to_str()) == Some("md")
520 && entry.file_name() != "README.md"
521 && let Ok(rel) = path.strip_prefix(root)
522 {
523 out.push(rel.to_path_buf());
524 }
525 }
526 Ok(())
527}
528
529fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), MemWriterError> {
534 if let Some(parent) = target.parent()
535 && !parent.as_os_str().is_empty()
536 {
537 std::fs::create_dir_all(parent).map_err(MemWriterError::Io)?;
538 }
539 let tmp = make_tmp_path(target);
540 std::fs::write(&tmp, bytes).map_err(MemWriterError::Io)?;
541 if let Err(e) = std::fs::rename(&tmp, target) {
542 let _ = std::fs::remove_file(&tmp);
543 return Err(MemWriterError::Io(e));
544 }
545 Ok(())
546}
547
548fn make_tmp_path(target: &Path) -> PathBuf {
553 let name = target
554 .file_name()
555 .map(|n| n.to_string_lossy().to_string())
556 .unwrap_or_else(|| "_".to_string());
557 let suffix = unique_suffix();
558 let tmp_name = format!(".{name}.tmp.{suffix}");
559 target.with_file_name(tmp_name)
560}
561
562static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
563static COMMIT_COUNTER: AtomicU64 = AtomicU64::new(0);
564
565fn unique_suffix() -> String {
566 let nanos = std::time::SystemTime::now()
567 .duration_since(std::time::UNIX_EPOCH)
568 .map(|d| d.as_nanos())
569 .unwrap_or(0);
570 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
571 format!("{nanos:x}-{counter:x}")
572}
573
574pub(crate) fn make_commit_id() -> CommitId {
579 let nanos = std::time::SystemTime::now()
580 .duration_since(std::time::UNIX_EPOCH)
581 .map(|d| d.as_nanos())
582 .unwrap_or(0);
583 let counter = COMMIT_COUNTER.fetch_add(1, Ordering::Relaxed);
584 format!("{nanos:032x}{counter:016x}")
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use crate::vcs::{Actor, ClientId, CommitContext};
591 use tempfile::TempDir;
592
593 fn ctx_for_test<'a>() -> CommitContext<'a> {
594 CommitContext {
595 actor: Actor::Cli,
596 client: Some(ClientId {
597 name: "claude-code".to_string(),
598 version: "0.1.0".to_string(),
599 }),
600 tool: Some("test"),
601 note: None,
602 role: Default::default(),
603 logical_operation_id: None,
604 entity_ids: None,
605 }
606 }
607
608 #[test]
614 fn entity_exists_metadata_probe_and_pending_precedence() {
615 use crate::backend::MemBackend;
616 let tmp = TempDir::new().unwrap();
617 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
618
619 assert!(!MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
620
621 MemBackend::write_entity(
622 &writer,
623 Path::new("notes/a.md"),
624 b"# a
625",
626 )
627 .unwrap();
628 assert!(
629 MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
630 "staged upsert answers true before commit"
631 );
632
633 MemWriter::commit(&writer, "land a", &ctx_for_test()).unwrap();
634 assert!(MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
635
636 MemBackend::delete_entity(&writer, Path::new("notes/a.md")).unwrap();
637 assert!(
638 !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
639 "staged delete answers false while the file is still on disk"
640 );
641 }
642
643 #[test]
644 fn write_then_commit_round_trip() {
645 let tmp = TempDir::new().unwrap();
646 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
647
648 writer
649 .write_entity(Path::new("notes/hello.md"), b"# hi\n")
650 .unwrap();
651 let id = writer.commit("first commit", &ctx_for_test()).unwrap();
652 assert!(!id.is_empty());
653
654 let bytes = std::fs::read(tmp.path().join("notes/hello.md")).unwrap();
655 assert_eq!(bytes, b"# hi\n");
656 }
657
658 #[test]
659 fn delete_removes_path() {
660 let tmp = TempDir::new().unwrap();
661 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
662
663 writer.write_entity(Path::new("a.md"), b"a").unwrap();
664 writer.write_entity(Path::new("b.md"), b"b").unwrap();
665 writer.commit("seed", &ctx_for_test()).unwrap();
666
667 writer.delete_entity(Path::new("a.md")).unwrap();
668 writer.commit("drop a", &ctx_for_test()).unwrap();
669
670 assert!(!tmp.path().join("a.md").exists());
671 assert!(tmp.path().join("b.md").exists());
672 }
673
674 #[test]
675 fn delete_of_missing_path_is_idempotent() {
676 let tmp = TempDir::new().unwrap();
677 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
678
679 writer.delete_entity(Path::new("never-existed.md")).unwrap();
680 writer.commit("noop delete", &ctx_for_test()).unwrap();
681 }
682
683 #[test]
684 fn move_renames_path() {
685 let tmp = TempDir::new().unwrap();
686 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
687
688 writer
689 .write_entity(Path::new("from.md"), b"payload")
690 .unwrap();
691 writer.commit("seed", &ctx_for_test()).unwrap();
692
693 writer
694 .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
695 .unwrap();
696 writer.commit("rename", &ctx_for_test()).unwrap();
697
698 assert!(!tmp.path().join("from.md").exists());
699 let moved = std::fs::read(tmp.path().join("nested/to.md")).unwrap();
700 assert_eq!(moved, b"payload");
701 }
702
703 #[test]
704 fn move_with_pending_upsert_carries_bytes() {
705 let tmp = TempDir::new().unwrap();
706 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
707
708 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
709 writer
710 .move_entity(Path::new("a.md"), Path::new("b.md"))
711 .unwrap();
712 writer.commit("write+move", &ctx_for_test()).unwrap();
713
714 assert!(!tmp.path().join("a.md").exists());
715 assert_eq!(std::fs::read(tmp.path().join("b.md")).unwrap(), b"alpha");
716 }
717
718 #[test]
719 fn move_missing_source_errors() {
720 let tmp = TempDir::new().unwrap();
721 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
722
723 let err = writer
724 .move_entity(Path::new("ghost.md"), Path::new("here.md"))
725 .unwrap_err();
726 assert!(matches!(err, MemWriterError::Path(_)));
727 }
728
729 #[test]
730 fn move_with_pending_target_upsert_errors() {
731 let tmp = TempDir::new().unwrap();
732 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
733
734 writer.write_entity(Path::new("from.md"), b"x").unwrap();
735 writer.write_entity(Path::new("to.md"), b"y").unwrap();
736 let err = writer
737 .move_entity(Path::new("from.md"), Path::new("to.md"))
738 .unwrap_err();
739 assert!(matches!(err, MemWriterError::Path(_)));
740 }
741
742 #[test]
743 fn multi_op_commit() {
744 let tmp = TempDir::new().unwrap();
745 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
746
747 writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
748 writer.commit("seed", &ctx_for_test()).unwrap();
749
750 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
751 writer
752 .write_entity(Path::new("nested/b.md"), b"beta")
753 .unwrap();
754 writer.delete_entity(Path::new("doomed.md")).unwrap();
755 writer.commit("multi-op", &ctx_for_test()).unwrap();
756
757 assert!(!tmp.path().join("doomed.md").exists());
758 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
759 assert_eq!(
760 std::fs::read(tmp.path().join("nested/b.md")).unwrap(),
761 b"beta"
762 );
763 }
764
765 #[test]
766 fn rejects_path_traversal() {
767 let tmp = TempDir::new().unwrap();
768 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
769
770 let err = writer
771 .write_entity(Path::new("../escape.md"), b"x")
772 .unwrap_err();
773 assert!(matches!(err, MemWriterError::Path(_)));
774 }
775
776 #[test]
777 fn rejects_absolute_path() {
778 let tmp = TempDir::new().unwrap();
779 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
780
781 let err = writer
782 .write_entity(Path::new("/etc/passwd"), b"x")
783 .unwrap_err();
784 assert!(matches!(err, MemWriterError::Path(_)));
785 }
786
787 #[test]
788 fn rejects_empty_path() {
789 let tmp = TempDir::new().unwrap();
790 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
791
792 let err = writer.write_entity(Path::new(""), b"x").unwrap_err();
793 assert!(matches!(err, MemWriterError::Path(_)));
794 }
795
796 #[test]
797 fn write_overwrites_existing_file() {
798 let tmp = TempDir::new().unwrap();
799 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
800
801 writer.write_entity(Path::new("a.md"), b"first").unwrap();
802 writer.commit("c1", &ctx_for_test()).unwrap();
803 writer.write_entity(Path::new("a.md"), b"second").unwrap();
804 writer.commit("c2", &ctx_for_test()).unwrap();
805
806 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"second");
807 }
808
809 #[test]
810 fn no_temp_files_left_after_commit() {
811 let tmp = TempDir::new().unwrap();
812 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
813
814 writer.write_entity(Path::new("a.md"), b"a").unwrap();
815 writer.write_entity(Path::new("nested/b.md"), b"b").unwrap();
816 writer.commit("c", &ctx_for_test()).unwrap();
817
818 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
820 for entry in std::fs::read_dir(dir).unwrap() {
821 let p = entry.unwrap().path();
822 if p.is_dir() {
823 walk(&p, out);
824 } else {
825 out.push(p);
826 }
827 }
828 }
829 let mut files = Vec::new();
830 walk(tmp.path(), &mut files);
831 for f in &files {
832 let name = f.file_name().unwrap().to_string_lossy();
833 assert!(
834 !name.contains(".tmp."),
835 "stray temp file after commit: {}",
836 f.display()
837 );
838 }
839 }
840
841 #[test]
842 fn commit_id_is_unique_across_calls() {
843 let tmp = TempDir::new().unwrap();
844 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
845
846 writer.write_entity(Path::new("a.md"), b"a").unwrap();
847 let id1 = writer.commit("c1", &ctx_for_test()).unwrap();
848 writer.write_entity(Path::new("b.md"), b"b").unwrap();
849 let id2 = writer.commit("c2", &ctx_for_test()).unwrap();
850
851 assert_ne!(id1, id2);
852 }
853
854 #[test]
855 fn pending_buffer_clears_on_commit() {
856 let tmp = TempDir::new().unwrap();
857 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
858
859 writer.write_entity(Path::new("a.md"), b"a").unwrap();
860 writer.commit("c1", &ctx_for_test()).unwrap();
861 let id2 = writer.commit("noop", &ctx_for_test()).unwrap();
864 assert!(!id2.is_empty());
865 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"a");
867 }
868
869 #[test]
876 fn backend_write_mem_config_round_trips_via_read() {
877 use crate::backend::MemBackend;
878
879 let tmp = TempDir::new().unwrap();
880 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
881 let backend: &dyn MemBackend = &writer;
882
883 assert!(backend.read_mem_config().unwrap().is_none());
885
886 let bytes = br#"{"version":"0.1.0","schema":"default@1.0.0"}"#.to_vec();
888 backend.write_mem_config(&bytes).unwrap();
889
890 let read_back = backend.read_mem_config().unwrap();
892 assert_eq!(read_back, Some(bytes.clone()));
893 let on_disk = std::fs::read(tmp.path().join(".memstead/config.json")).unwrap();
895 assert_eq!(on_disk, bytes);
896 }
897
898 #[test]
899 fn backend_list_entities_returns_only_md_outside_meta_dirs() {
900 use crate::backend::MemBackend;
901
902 let tmp = TempDir::new().unwrap();
903 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
904
905 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
908 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"b")
909 .unwrap();
910 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
911 .unwrap();
912 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
913 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
917 std::fs::write(tmp.path().join(".memstead/config.json"), b"{}").unwrap();
918 std::fs::write(tmp.path().join(".memstead/notes.md"), b"#").unwrap();
919 std::fs::create_dir_all(tmp.path().join(".other")).unwrap();
920 std::fs::write(tmp.path().join(".other/notes.md"), b"#").unwrap();
921
922 let backend: &dyn MemBackend = &writer;
923 let mut paths: Vec<String> = backend
924 .list_entities()
925 .unwrap()
926 .into_iter()
927 .map(|p| p.to_string_lossy().into_owned())
928 .collect();
929 paths.sort();
930 assert_eq!(
931 paths,
932 vec![
933 ".other/notes.md".to_string(),
934 "a.md".to_string(),
935 "nested/b.md".to_string(),
936 ]
937 );
938 }
939
940 #[test]
941 fn backend_read_entity_consults_pending_then_disk() {
942 let tmp = TempDir::new().unwrap();
943 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
944
945 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("on_disk.md"), b"disk")
950 .unwrap();
951 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
952
953 use crate::backend::MemBackend;
954 let backend: &dyn MemBackend = &writer;
955 assert_eq!(
957 backend.read_entity(Path::new("on_disk.md")).unwrap(),
958 Some(b"disk".to_vec())
959 );
960 backend
962 .write_entity(Path::new("on_disk.md"), b"buffered")
963 .unwrap();
964 assert_eq!(
965 backend.read_entity(Path::new("on_disk.md")).unwrap(),
966 Some(b"buffered".to_vec())
967 );
968 backend.delete_entity(Path::new("on_disk.md")).unwrap();
970 assert_eq!(backend.read_entity(Path::new("on_disk.md")).unwrap(), None);
971 assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
973 }
974
975 #[test]
976 fn backend_provenance_round_trips_through_jsonl() {
977 let tmp = TempDir::new().unwrap();
978 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
979 use crate::backend::MemBackend;
980 let backend: &dyn MemBackend = &writer;
981
982 let client = ClientId {
983 name: "claude-code".into(),
984 version: "2.1.0".into(),
985 };
986 let earlier = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
987 let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_777_077_296);
988
989 backend
990 .append_provenance(&Provenance::new(
991 earlier,
992 ProvenanceKind::Create,
993 Some("v:e1".into()),
994 Actor::Agent,
995 Some(client.clone()),
996 Some("first".into()),
997 ))
998 .unwrap();
999 backend
1000 .append_provenance(&Provenance::new(
1001 later,
1002 ProvenanceKind::Update,
1003 Some("v:e1".into()),
1004 Actor::Cli,
1005 None,
1006 None,
1007 ))
1008 .unwrap();
1009
1010 let all = backend.read_provenance(None).unwrap();
1011 assert_eq!(all.len(), 2);
1012 assert_eq!(all[0].kind, ProvenanceKind::Create);
1013 assert_eq!(all[0].entity.as_deref(), Some("v:e1"));
1014 assert_eq!(all[0].actor, Actor::Agent);
1015 assert_eq!(all[0].note.as_deref(), Some("first"));
1016 assert_eq!(
1017 all[0]
1018 .client
1019 .as_ref()
1020 .map(|c| (c.name.as_str(), c.version.as_str())),
1021 Some(("claude-code", "2.1.0"))
1022 );
1023 assert_eq!(all[0].timestamp, earlier);
1024 assert_eq!(all[1].kind, ProvenanceKind::Update);
1025 assert_eq!(all[1].timestamp, later);
1026 assert!(all[1].note.is_none());
1027 assert!(all[1].client.is_none());
1028 }
1029
1030 #[test]
1031 fn backend_provenance_cursor_filters_by_timestamp() {
1032 let tmp = TempDir::new().unwrap();
1033 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1034 use crate::backend::MemBackend;
1035 let backend: &dyn MemBackend = &writer;
1036
1037 for (secs, label) in [
1038 (1_700_000_000u64, "first"),
1039 (1_750_000_000, "middle"),
1040 (1_800_000_000, "last"),
1041 ] {
1042 backend
1043 .append_provenance(&Provenance::new(
1044 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1045 ProvenanceKind::Create,
1046 Some(format!("v:{label}")),
1047 Actor::Cli,
1048 None,
1049 None,
1050 ))
1051 .unwrap();
1052 }
1053 let cursor = changelog::format_rfc3339_utc(
1055 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1056 );
1057 let after = backend.read_provenance(Some(&cursor)).unwrap();
1058 let entities: Vec<_> = after.iter().filter_map(|p| p.entity.clone()).collect();
1059 assert_eq!(entities, vec!["v:middle".to_string(), "v:last".to_string()]);
1060 }
1061
1062 #[test]
1063 fn backend_read_provenance_handles_missing_log() {
1064 let tmp = TempDir::new().unwrap();
1065 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1066 use crate::backend::MemBackend;
1067 let backend: &dyn MemBackend = &writer;
1068 assert!(backend.read_provenance(None).unwrap().is_empty());
1070 }
1071
1072 fn append_at(
1077 backend: &dyn crate::backend::MemBackend,
1078 secs: u64,
1079 kind: ProvenanceKind,
1080 entity: &str,
1081 ) {
1082 backend
1083 .append_provenance(&Provenance::new(
1084 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1085 kind,
1086 Some(entity.to_string()),
1087 Actor::Cli,
1088 None,
1089 None,
1090 ))
1091 .unwrap();
1092 }
1093
1094 #[test]
1095 fn folder_changes_since_no_log_returns_empty_at_cursor() {
1096 let tmp = TempDir::new().unwrap();
1099 let result =
1100 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1101 .unwrap();
1102 assert_eq!(result.since, crate::ops::EMPTY_TREE_SHA);
1103 assert_eq!(result.head, crate::ops::EMPTY_TREE_SHA);
1104 assert!(result.changes.is_empty());
1105 }
1106
1107 #[test]
1108 fn folder_changes_since_create_only_yields_added_envelope() {
1109 let tmp = TempDir::new().unwrap();
1110 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1111 append_at(
1112 &writer,
1113 1_700_000_000,
1114 ProvenanceKind::Create,
1115 "specs--alpha",
1116 );
1117
1118 let result =
1119 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1120 .unwrap();
1121 assert_eq!(result.changes.len(), 1);
1122 match &result.changes[0] {
1123 crate::ops::ChangeEnvelope::Added {
1124 id,
1125 title,
1126 entity_type,
1127 } => {
1128 assert_eq!(id.0, "specs--alpha");
1129 assert!(title.is_none(), "id-only contract");
1130 assert!(entity_type.is_none(), "id-only contract");
1131 }
1132 other => panic!("expected Added, got {other:?}"),
1133 }
1134 assert_ne!(result.head, crate::ops::EMPTY_TREE_SHA);
1136 }
1137
1138 #[test]
1139 fn folder_changes_since_create_then_delete_cancels_to_no_envelope() {
1140 let tmp = TempDir::new().unwrap();
1143 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1144 append_at(
1145 &writer,
1146 1_700_000_000,
1147 ProvenanceKind::Create,
1148 "specs--ephemeral",
1149 );
1150 append_at(
1151 &writer,
1152 1_700_000_001,
1153 ProvenanceKind::Delete,
1154 "specs--ephemeral",
1155 );
1156
1157 let result =
1158 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1159 .unwrap();
1160 assert_eq!(result.changes.len(), 1);
1161 match &result.changes[0] {
1162 crate::ops::ChangeEnvelope::Removed { id, .. } => {
1163 assert_eq!(id.0, "specs--ephemeral");
1164 }
1165 other => panic!("expected Removed (Delete wins), got {other:?}"),
1166 }
1167 }
1168
1169 #[test]
1170 fn folder_changes_since_update_only_yields_updated_envelope() {
1171 let tmp = TempDir::new().unwrap();
1172 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1173 append_at(
1174 &writer,
1175 1_700_000_000,
1176 ProvenanceKind::Update,
1177 "specs--alpha",
1178 );
1179
1180 let result =
1181 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1182 .unwrap();
1183 assert_eq!(result.changes.len(), 1);
1184 assert!(matches!(
1185 result.changes[0],
1186 crate::ops::ChangeEnvelope::Updated { .. }
1187 ));
1188 }
1189
1190 #[test]
1191 fn folder_changes_since_cursor_filters_to_window() {
1192 let tmp = TempDir::new().unwrap();
1195 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1196 append_at(
1197 &writer,
1198 1_700_000_000,
1199 ProvenanceKind::Create,
1200 "specs--first",
1201 );
1202 append_at(
1203 &writer,
1204 1_750_000_000,
1205 ProvenanceKind::Create,
1206 "specs--middle",
1207 );
1208 append_at(
1209 &writer,
1210 1_800_000_000,
1211 ProvenanceKind::Create,
1212 "specs--last",
1213 );
1214
1215 let cursor = changelog::format_rfc3339_utc(
1216 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1217 );
1218 let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1219 assert_eq!(result.changes.len(), 2);
1220 let ids: Vec<_> = result
1221 .changes
1222 .iter()
1223 .map(|e| match e {
1224 crate::ops::ChangeEnvelope::Added { id, .. } => id.0.clone(),
1225 _ => panic!("expected Added"),
1226 })
1227 .collect();
1228 assert_eq!(
1230 ids,
1231 vec!["specs--last".to_string(), "specs--middle".to_string()]
1232 );
1233 }
1234
1235 #[test]
1236 fn folder_changes_since_skips_events_for_other_mems() {
1237 let tmp = TempDir::new().unwrap();
1241 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1242 append_at(
1243 &writer,
1244 1_700_000_000,
1245 ProvenanceKind::Create,
1246 "specs--mine",
1247 );
1248 append_at(
1249 &writer,
1250 1_700_000_001,
1251 ProvenanceKind::Create,
1252 "other--theirs",
1253 );
1254
1255 let result =
1256 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1257 .unwrap();
1258 assert_eq!(result.changes.len(), 1);
1259 match &result.changes[0] {
1260 crate::ops::ChangeEnvelope::Added { id, .. } => {
1261 assert_eq!(id.0, "specs--mine");
1262 }
1263 other => panic!("expected Added, got {other:?}"),
1264 }
1265 }
1266
1267 #[test]
1268 fn folder_changes_since_skips_batch_events_with_no_entity() {
1269 let tmp = TempDir::new().unwrap();
1272 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1273 use crate::backend::MemBackend;
1274 writer
1277 .append_provenance(&Provenance::new(
1278 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1279 ProvenanceKind::Batch,
1280 None,
1281 Actor::Cli,
1282 None,
1283 None,
1284 ))
1285 .unwrap();
1286 append_at(
1287 &writer,
1288 1_700_000_001,
1289 ProvenanceKind::Create,
1290 "specs--real",
1291 );
1292
1293 let result =
1294 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1295 .unwrap();
1296 assert_eq!(result.changes.len(), 1);
1298 match &result.changes[0] {
1299 crate::ops::ChangeEnvelope::Added { id, .. } => {
1300 assert_eq!(id.0, "specs--real");
1301 }
1302 other => panic!("expected Added, got {other:?}"),
1303 }
1304 }
1305
1306 #[test]
1307 fn folder_changes_since_head_echoes_cursor_when_no_events_in_window() {
1308 let tmp = TempDir::new().unwrap();
1310 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1311 append_at(&writer, 1_700_000_000, ProvenanceKind::Create, "specs--old");
1312
1313 let cursor = changelog::format_rfc3339_utc(
1314 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_900_000_000),
1315 );
1316 let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1317 assert!(result.changes.is_empty());
1318 assert_eq!(result.head, cursor);
1319 }
1320
1321 #[test]
1322 fn backend_writes_delegate_to_memwriter() {
1323 let tmp = TempDir::new().unwrap();
1324 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1325 use crate::backend::MemBackend;
1326 let backend: &dyn MemBackend = &writer;
1327
1328 backend.write_entity(Path::new("a.md"), b"alpha").unwrap();
1329 backend.commit("seed", &ctx_for_test()).unwrap();
1330 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
1331
1332 backend.delete_entity(Path::new("a.md")).unwrap();
1333 backend.commit("drop", &ctx_for_test()).unwrap();
1334 assert!(!tmp.path().join("a.md").exists());
1335 }
1336
1337 #[test]
1338 fn parse_client_id_splits_on_last_at() {
1339 let c = parse_client_id("claude-code@2.1.0").unwrap();
1340 assert_eq!(c.name, "claude-code");
1341 assert_eq!(c.version, "2.1.0");
1342 let c = parse_client_id("foo.bar@1.0").unwrap();
1344 assert_eq!(c.name, "foo.bar");
1345 assert!(parse_client_id("naked").is_none());
1348 assert!(parse_client_id("@1.0").is_none());
1349 assert!(parse_client_id("name@").is_none());
1350 }
1351}
1352
1353#[cfg(test)]
1354mod folder_drift_tests {
1355 use super::*;
1356
1357 const ENTITY: &str = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Seed\n\n## Identity\n\nSeed.\n";
1358 const SIBLING_ENTITY: &str = "---\ntype: spec\ncreated_date: 2026-01-02\nlast_modified: 2026-01-02\nlevel: M0\n---\n# Sibling\n\n## Identity\n\nWritten out-of-band.\n";
1359
1360 fn folder_engine(dir: std::path::PathBuf) -> crate::Engine {
1361 let mount = crate::Mount {
1362 mem: "specs".to_string(),
1363 schema: Some(memstead_schema::SchemaRef::new(
1364 "default",
1365 semver::Version::new(1, 0, 0),
1366 )),
1367 storage: crate::MountStorage::Folder { path: dir.clone() },
1368 capability: crate::MountCapability::Write,
1369 lifecycle: crate::MountLifecycle::Eager,
1370 cross_linkable: false,
1371 migration_target: None,
1372 };
1373 let backend = Box::new(FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
1374 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
1375 }
1376
1377 #[test]
1383 fn sibling_folder_write_is_drift_and_self_write_is_not() {
1384 let tmp = tempfile::TempDir::new().unwrap();
1385 let dir = tmp.path().join("specs");
1386 std::fs::create_dir_all(&dir).unwrap();
1387 let seeder = FilesystemMemWriter::new(dir.clone());
1389 MemWriter::write_entity(&seeder, std::path::Path::new("seed.md"), ENTITY.as_bytes())
1390 .unwrap();
1391 MemWriter::commit(&seeder, "seed", &CommitContext::internal()).unwrap();
1392 crate::backend::MemBackend::append_provenance(
1393 &seeder,
1394 &Provenance::new(
1395 std::time::SystemTime::now(),
1396 ProvenanceKind::Create,
1397 Some("specs--seed".into()),
1398 Actor::Cli,
1399 None,
1400 None,
1401 ),
1402 )
1403 .unwrap();
1404
1405 let mut engine = folder_engine(dir.clone());
1406 assert!(engine.reload_if_stale(None).is_empty());
1408
1409 engine
1411 .create_entity(
1412 crate::CreateEntityArgs {
1413 mem: "specs".to_string(),
1414 title: "Self Made".to_string(),
1415 entity_type: "spec".to_string(),
1416 sections: [
1417 ("identity".to_string(), "self".to_string()),
1418 ("purpose".to_string(), "prove no self-drift".to_string()),
1419 ]
1420 .into_iter()
1421 .collect(),
1422 metadata: Default::default(),
1423 relations: Vec::new(),
1424 anchors: Vec::new(),
1425 dry_run: false,
1426 },
1427 crate::vcs::Actor::Cli,
1428 None,
1429 None,
1430 )
1431 .unwrap();
1432 assert!(
1433 engine.reload_if_stale(None).is_empty(),
1434 "the engine's own write must not read as sibling drift"
1435 );
1436 assert!(engine.take_mem_changed_notices().is_empty());
1437
1438 std::thread::sleep(std::time::Duration::from_millis(5));
1441 let sibling = FilesystemMemWriter::new(dir);
1442 MemWriter::write_entity(
1443 &sibling,
1444 std::path::Path::new("sibling.md"),
1445 SIBLING_ENTITY.as_bytes(),
1446 )
1447 .unwrap();
1448 MemWriter::commit(&sibling, "sibling", &CommitContext::internal()).unwrap();
1449 crate::backend::MemBackend::append_provenance(
1450 &sibling,
1451 &Provenance::new(
1452 std::time::SystemTime::now(),
1453 ProvenanceKind::Create,
1454 Some("specs--sibling".into()),
1455 Actor::Cli,
1456 None,
1457 None,
1458 ),
1459 )
1460 .unwrap();
1461
1462 let warnings = engine.reload_if_stale(None);
1463 assert_eq!(
1464 warnings.len(),
1465 1,
1466 "sibling drift must surface: {warnings:?}"
1467 );
1468 match &warnings[0] {
1469 crate::ops::WarningHint::MemReloaded { mem, .. } => assert_eq!(mem, "specs"),
1470 other => panic!("expected MemReloaded, got {other:?}"),
1471 }
1472 let notices = engine.take_mem_changed_notices();
1473 assert_eq!(notices.len(), 1);
1474 assert!(
1476 engine
1477 .get_entity(&crate::EntityId("specs--sibling".to_string()))
1478 .is_some(),
1479 "reload must surface the sibling's entity"
1480 );
1481 assert!(engine.reload_if_stale(None).is_empty());
1483 }
1484}