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