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