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 write_mem_config_cas(
402 &self,
403 expected: Option<&[u8]>,
404 bytes: &[u8],
405 _note: Option<&str>,
406 ) -> Result<bool, BackendError> {
407 let memstead_dir = self.root.join(crate::mem::MEM_META_DIR);
408 std::fs::create_dir_all(&memstead_dir).map_err(BackendError::Io)?;
409 let config_path = memstead_dir.join("config.json");
410 let lock_path = memstead_dir.join("config.json.lock");
411
412 let mut held = None;
413 for attempt in 0..50 {
414 match std::fs::OpenOptions::new()
415 .write(true)
416 .create_new(true)
417 .open(&lock_path)
418 {
419 Ok(f) => {
420 held = Some(f);
421 break;
422 }
423 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
424 if attempt == 49 {
428 let _ = std::fs::remove_file(&lock_path);
429 }
430 std::thread::sleep(std::time::Duration::from_millis(10));
431 }
432 Err(e) => return Err(BackendError::Io(e)),
433 }
434 }
435 let _lock = match held {
436 Some(f) => f,
437 None => std::fs::OpenOptions::new()
438 .write(true)
439 .create(true)
440 .truncate(true)
441 .open(&lock_path)
442 .map_err(BackendError::Io)?,
443 };
444 let release = || {
446 let _ = std::fs::remove_file(&lock_path);
447 };
448
449 let current = match std::fs::read(&config_path) {
450 Ok(b) => Some(b),
451 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
452 Err(e) => {
453 release();
454 return Err(BackendError::Io(e));
455 }
456 };
457 if let Some(expected) = expected
458 && current.as_deref() != Some(expected)
459 {
460 release();
461 return Ok(false);
462 }
463 let result = std::fs::write(&config_path, bytes).map_err(BackendError::Io);
464 release();
465 result.map(|_| true)
466 }
467
468 fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
469 self.read_entity(Path::new(crate::anchor::ANCHOR_SIDECAR_PATH))
472 }
473
474 fn write_anchors_sidecar(&self, bytes: &[u8]) -> Result<(), BackendError> {
475 <Self as MemWriter>::write_entity(
480 self,
481 Path::new(crate::anchor::ANCHOR_SIDECAR_PATH),
482 bytes,
483 )
484 .map_err(Into::into)
485 }
486
487 fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
488 let kind: MutationKind = record.kind.into();
489 let entry = ChangeEntry {
490 kind,
491 entity: record.entity.as_deref(),
492 actor: record.actor,
493 client: record.client.as_ref(),
494 note: record.note.as_deref(),
495 logical_operation_id: record.logical_operation_id.as_deref(),
496 role: record.role,
497 identity: record.identity.as_deref(),
498 };
499 changelog::append_change_monotonic(&self.root, &entry, record.timestamp)
503 .map_err(|e| BackendError::Other(format!("changelog append: {e}")))
504 }
505
506 fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
507 let log_path = changelog_path(&self.root);
508 let raw = match std::fs::read_to_string(&log_path) {
509 Ok(s) => s,
510 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
511 Err(e) => return Err(BackendError::Io(e)),
512 };
513 let mut out = Vec::new();
514 for line in raw.lines() {
515 let trimmed = line.trim();
516 if trimmed.is_empty() {
517 continue;
518 }
519 let value: serde_json::Value = match serde_json::from_str(trimmed) {
520 Ok(v) => v,
521 Err(_) => continue,
522 };
523 let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
524 if let Some(c) = cursor
525 && ts_str <= c
526 {
527 continue;
528 }
529 let timestamp = parse_rfc3339_utc(ts_str).unwrap_or(std::time::UNIX_EPOCH);
530 let kind = value
531 .get("kind")
532 .and_then(|v| v.as_str())
533 .and_then(ProvenanceKind::parse)
534 .unwrap_or(ProvenanceKind::Update);
535 let entity = value
536 .get("entity")
537 .and_then(|v| v.as_str())
538 .map(|s| s.to_string());
539 let actor = value
540 .get("actor")
541 .and_then(|v| v.as_str())
542 .and_then(Actor::from_trailer)
543 .unwrap_or(Actor::Unknown);
544 let client = value
545 .get("client")
546 .and_then(|v| v.as_str())
547 .and_then(parse_client_id);
548 let note = value
549 .get("note")
550 .and_then(|v| v.as_str())
551 .map(|s| s.to_string());
552 let logical_operation_id = value
553 .get("logical_op")
554 .and_then(|v| v.as_str())
555 .map(|s| s.to_string());
556 let mut record = Provenance::new(timestamp, kind, entity, actor, client, note);
557 if let Some(id) = logical_operation_id {
558 record = record.with_logical_operation_id(id);
559 }
560 if let Some(role) = value
561 .get("role")
562 .and_then(|v| v.as_str())
563 .and_then(crate::vcs::Role::from_wire)
564 {
565 record = record.with_role(role);
566 }
567 record = record.with_identity(
568 value
569 .get("identity")
570 .and_then(|v| v.as_str())
571 .map(|s| s.to_string()),
572 );
573 out.push(record);
574 }
575 Ok(out)
576 }
577}
578
579fn walk_for_md(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BackendError> {
587 let entries = match std::fs::read_dir(dir) {
588 Ok(e) => e,
589 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
590 Err(e) => return Err(BackendError::Io(e)),
591 };
592 for entry in entries {
593 let entry = entry.map_err(BackendError::Io)?;
594 let path = entry.path();
595 let file_type = entry.file_type().map_err(BackendError::Io)?;
596 if file_type.is_dir() {
597 let name = entry.file_name();
598 if name == crate::mem::MEM_META_DIR {
599 continue;
600 }
601 walk_for_md(root, &path, out)?;
602 } else if file_type.is_file()
603 && path.extension().and_then(|s| s.to_str()) == Some("md")
604 && entry.file_name() != "README.md"
605 && let Ok(rel) = path.strip_prefix(root)
606 {
607 out.push(rel.to_path_buf());
608 }
609 }
610 Ok(())
611}
612
613fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), MemWriterError> {
618 if let Some(parent) = target.parent()
619 && !parent.as_os_str().is_empty()
620 {
621 std::fs::create_dir_all(parent).map_err(MemWriterError::Io)?;
622 }
623 let tmp = make_tmp_path(target);
624 std::fs::write(&tmp, bytes).map_err(MemWriterError::Io)?;
625 if let Err(e) = std::fs::rename(&tmp, target) {
626 let _ = std::fs::remove_file(&tmp);
627 return Err(MemWriterError::Io(e));
628 }
629 Ok(())
630}
631
632fn make_tmp_path(target: &Path) -> PathBuf {
637 let name = target
638 .file_name()
639 .map(|n| n.to_string_lossy().to_string())
640 .unwrap_or_else(|| "_".to_string());
641 let suffix = unique_suffix();
642 let tmp_name = format!(".{name}.tmp.{suffix}");
643 target.with_file_name(tmp_name)
644}
645
646static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
647static COMMIT_COUNTER: AtomicU64 = AtomicU64::new(0);
648
649fn unique_suffix() -> String {
650 let nanos = std::time::SystemTime::now()
651 .duration_since(std::time::UNIX_EPOCH)
652 .map(|d| d.as_nanos())
653 .unwrap_or(0);
654 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
655 format!("{nanos:x}-{counter:x}")
656}
657
658pub(crate) fn make_commit_id() -> CommitId {
666 let nanos = std::time::SystemTime::now()
667 .duration_since(std::time::UNIX_EPOCH)
668 .map(|d| d.as_nanos())
669 .unwrap_or(0);
670 let counter = COMMIT_COUNTER.fetch_add(1, Ordering::Relaxed);
671 format!("{nanos:032x}{counter:016x}")
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use crate::vcs::{Actor, ClientId, CommitContext};
678 use tempfile::TempDir;
679
680 fn ctx_for_test<'a>() -> CommitContext<'a> {
681 CommitContext {
682 actor: Actor::Cli,
683 client: Some(ClientId {
684 name: "claude-code".to_string(),
685 version: "0.1.0".to_string(),
686 }),
687 tool: Some("test"),
688 note: None,
689 role: Default::default(),
690 identity: None,
691 logical_operation_id: None,
692 entity_ids: None,
693 }
694 }
695
696 #[test]
702 fn entity_exists_metadata_probe_and_pending_precedence() {
703 use crate::backend::MemBackend;
704 let tmp = TempDir::new().unwrap();
705 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
706
707 assert!(!MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
708
709 MemBackend::write_entity(
710 &writer,
711 Path::new("notes/a.md"),
712 b"# a
713",
714 )
715 .unwrap();
716 assert!(
717 MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
718 "staged upsert answers true before commit"
719 );
720
721 MemWriter::commit(&writer, "land a", &ctx_for_test()).unwrap();
722 assert!(MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap());
723
724 MemBackend::delete_entity(&writer, Path::new("notes/a.md")).unwrap();
725 assert!(
726 !MemBackend::entity_exists(&writer, Path::new("notes/a.md")).unwrap(),
727 "staged delete answers false while the file is still on disk"
728 );
729 }
730
731 #[test]
732 fn write_then_commit_round_trip() {
733 let tmp = TempDir::new().unwrap();
734 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
735
736 writer
737 .write_entity(Path::new("notes/hello.md"), b"# hi\n")
738 .unwrap();
739 let id = writer.commit("first commit", &ctx_for_test()).unwrap();
740 assert!(!id.is_empty());
741
742 let bytes = std::fs::read(tmp.path().join("notes/hello.md")).unwrap();
743 assert_eq!(bytes, b"# hi\n");
744 }
745
746 #[test]
747 fn delete_removes_path() {
748 let tmp = TempDir::new().unwrap();
749 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
750
751 writer.write_entity(Path::new("a.md"), b"a").unwrap();
752 writer.write_entity(Path::new("b.md"), b"b").unwrap();
753 writer.commit("seed", &ctx_for_test()).unwrap();
754
755 writer.delete_entity(Path::new("a.md")).unwrap();
756 writer.commit("drop a", &ctx_for_test()).unwrap();
757
758 assert!(!tmp.path().join("a.md").exists());
759 assert!(tmp.path().join("b.md").exists());
760 }
761
762 #[test]
763 fn delete_of_missing_path_is_idempotent() {
764 let tmp = TempDir::new().unwrap();
765 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
766
767 writer.delete_entity(Path::new("never-existed.md")).unwrap();
768 writer.commit("noop delete", &ctx_for_test()).unwrap();
769 }
770
771 #[test]
772 fn move_renames_path() {
773 let tmp = TempDir::new().unwrap();
774 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
775
776 writer
777 .write_entity(Path::new("from.md"), b"payload")
778 .unwrap();
779 writer.commit("seed", &ctx_for_test()).unwrap();
780
781 writer
782 .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
783 .unwrap();
784 writer.commit("rename", &ctx_for_test()).unwrap();
785
786 assert!(!tmp.path().join("from.md").exists());
787 let moved = std::fs::read(tmp.path().join("nested/to.md")).unwrap();
788 assert_eq!(moved, b"payload");
789 }
790
791 #[test]
792 fn move_with_pending_upsert_carries_bytes() {
793 let tmp = TempDir::new().unwrap();
794 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
795
796 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
797 writer
798 .move_entity(Path::new("a.md"), Path::new("b.md"))
799 .unwrap();
800 writer.commit("write+move", &ctx_for_test()).unwrap();
801
802 assert!(!tmp.path().join("a.md").exists());
803 assert_eq!(std::fs::read(tmp.path().join("b.md")).unwrap(), b"alpha");
804 }
805
806 #[test]
807 fn move_missing_source_errors() {
808 let tmp = TempDir::new().unwrap();
809 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
810
811 let err = writer
812 .move_entity(Path::new("ghost.md"), Path::new("here.md"))
813 .unwrap_err();
814 assert!(matches!(err, MemWriterError::Path(_)));
815 }
816
817 #[test]
818 fn move_with_pending_target_upsert_errors() {
819 let tmp = TempDir::new().unwrap();
820 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
821
822 writer.write_entity(Path::new("from.md"), b"x").unwrap();
823 writer.write_entity(Path::new("to.md"), b"y").unwrap();
824 let err = writer
825 .move_entity(Path::new("from.md"), Path::new("to.md"))
826 .unwrap_err();
827 assert!(matches!(err, MemWriterError::Path(_)));
828 }
829
830 #[test]
831 fn multi_op_commit() {
832 let tmp = TempDir::new().unwrap();
833 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
834
835 writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
836 writer.commit("seed", &ctx_for_test()).unwrap();
837
838 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
839 writer
840 .write_entity(Path::new("nested/b.md"), b"beta")
841 .unwrap();
842 writer.delete_entity(Path::new("doomed.md")).unwrap();
843 writer.commit("multi-op", &ctx_for_test()).unwrap();
844
845 assert!(!tmp.path().join("doomed.md").exists());
846 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
847 assert_eq!(
848 std::fs::read(tmp.path().join("nested/b.md")).unwrap(),
849 b"beta"
850 );
851 }
852
853 #[test]
854 fn rejects_path_traversal() {
855 let tmp = TempDir::new().unwrap();
856 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
857
858 let err = writer
859 .write_entity(Path::new("../escape.md"), b"x")
860 .unwrap_err();
861 assert!(matches!(err, MemWriterError::Path(_)));
862 }
863
864 #[test]
865 fn rejects_absolute_path() {
866 let tmp = TempDir::new().unwrap();
867 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
868
869 let err = writer
870 .write_entity(Path::new("/etc/passwd"), b"x")
871 .unwrap_err();
872 assert!(matches!(err, MemWriterError::Path(_)));
873 }
874
875 #[test]
876 fn rejects_empty_path() {
877 let tmp = TempDir::new().unwrap();
878 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
879
880 let err = writer.write_entity(Path::new(""), b"x").unwrap_err();
881 assert!(matches!(err, MemWriterError::Path(_)));
882 }
883
884 #[test]
885 fn write_overwrites_existing_file() {
886 let tmp = TempDir::new().unwrap();
887 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
888
889 writer.write_entity(Path::new("a.md"), b"first").unwrap();
890 writer.commit("c1", &ctx_for_test()).unwrap();
891 writer.write_entity(Path::new("a.md"), b"second").unwrap();
892 writer.commit("c2", &ctx_for_test()).unwrap();
893
894 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"second");
895 }
896
897 #[test]
898 fn no_temp_files_left_after_commit() {
899 let tmp = TempDir::new().unwrap();
900 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
901
902 writer.write_entity(Path::new("a.md"), b"a").unwrap();
903 writer.write_entity(Path::new("nested/b.md"), b"b").unwrap();
904 writer.commit("c", &ctx_for_test()).unwrap();
905
906 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
908 for entry in std::fs::read_dir(dir).unwrap() {
909 let p = entry.unwrap().path();
910 if p.is_dir() {
911 walk(&p, out);
912 } else {
913 out.push(p);
914 }
915 }
916 }
917 let mut files = Vec::new();
918 walk(tmp.path(), &mut files);
919 for f in &files {
920 let name = f.file_name().unwrap().to_string_lossy();
921 assert!(
922 !name.contains(".tmp."),
923 "stray temp file after commit: {}",
924 f.display()
925 );
926 }
927 }
928
929 #[test]
930 fn commit_id_is_unique_across_calls() {
931 let tmp = TempDir::new().unwrap();
932 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
933
934 writer.write_entity(Path::new("a.md"), b"a").unwrap();
935 let id1 = writer.commit("c1", &ctx_for_test()).unwrap();
936 writer.write_entity(Path::new("b.md"), b"b").unwrap();
937 let id2 = writer.commit("c2", &ctx_for_test()).unwrap();
938
939 assert_ne!(id1, id2);
940 }
941
942 #[test]
943 fn pending_buffer_clears_on_commit() {
944 let tmp = TempDir::new().unwrap();
945 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
946
947 writer.write_entity(Path::new("a.md"), b"a").unwrap();
948 writer.commit("c1", &ctx_for_test()).unwrap();
949 let id2 = writer.commit("noop", &ctx_for_test()).unwrap();
952 assert!(!id2.is_empty());
953 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"a");
955 }
956
957 #[test]
964 fn backend_write_mem_config_round_trips_via_read() {
965 use crate::backend::MemBackend;
966
967 let tmp = TempDir::new().unwrap();
968 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
969 let backend: &dyn MemBackend = &writer;
970
971 assert!(backend.read_mem_config().unwrap().is_none());
973
974 let bytes = br#"{"version":"0.1.0","schema":"default@1.0.0"}"#.to_vec();
976 backend.write_mem_config(&bytes).unwrap();
977
978 let read_back = backend.read_mem_config().unwrap();
980 assert_eq!(read_back, Some(bytes.clone()));
981 let on_disk = std::fs::read(tmp.path().join(".memstead/config.json")).unwrap();
983 assert_eq!(on_disk, bytes);
984 }
985
986 #[test]
987 fn backend_list_entities_returns_only_md_outside_meta_dirs() {
988 use crate::backend::MemBackend;
989
990 let tmp = TempDir::new().unwrap();
991 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
992
993 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
996 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"b")
997 .unwrap();
998 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
999 .unwrap();
1000 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1001 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
1005 std::fs::write(tmp.path().join(".memstead/config.json"), b"{}").unwrap();
1006 std::fs::write(tmp.path().join(".memstead/notes.md"), b"#").unwrap();
1007 std::fs::create_dir_all(tmp.path().join(".other")).unwrap();
1008 std::fs::write(tmp.path().join(".other/notes.md"), b"#").unwrap();
1009
1010 let backend: &dyn MemBackend = &writer;
1011 let mut paths: Vec<String> = backend
1012 .list_entities()
1013 .unwrap()
1014 .into_iter()
1015 .map(|p| p.to_string_lossy().into_owned())
1016 .collect();
1017 paths.sort();
1018 assert_eq!(
1019 paths,
1020 vec![
1021 ".other/notes.md".to_string(),
1022 "a.md".to_string(),
1023 "nested/b.md".to_string(),
1024 ]
1025 );
1026 }
1027
1028 #[test]
1029 fn backend_read_entity_consults_pending_then_disk() {
1030 let tmp = TempDir::new().unwrap();
1031 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1032
1033 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("on_disk.md"), b"disk")
1038 .unwrap();
1039 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
1040
1041 use crate::backend::MemBackend;
1042 let backend: &dyn MemBackend = &writer;
1043 assert_eq!(
1045 backend.read_entity(Path::new("on_disk.md")).unwrap(),
1046 Some(b"disk".to_vec())
1047 );
1048 backend
1050 .write_entity(Path::new("on_disk.md"), b"buffered")
1051 .unwrap();
1052 assert_eq!(
1053 backend.read_entity(Path::new("on_disk.md")).unwrap(),
1054 Some(b"buffered".to_vec())
1055 );
1056 backend.delete_entity(Path::new("on_disk.md")).unwrap();
1058 assert_eq!(backend.read_entity(Path::new("on_disk.md")).unwrap(), None);
1059 assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
1061 }
1062
1063 #[test]
1064 fn backend_provenance_round_trips_through_jsonl() {
1065 let tmp = TempDir::new().unwrap();
1066 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1067 use crate::backend::MemBackend;
1068 let backend: &dyn MemBackend = &writer;
1069
1070 let client = ClientId {
1071 name: "claude-code".into(),
1072 version: "2.1.0".into(),
1073 };
1074 let earlier = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
1075 let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_777_077_296);
1076
1077 backend
1078 .append_provenance(&Provenance::new(
1079 earlier,
1080 ProvenanceKind::Create,
1081 Some("v:e1".into()),
1082 Actor::Agent,
1083 Some(client.clone()),
1084 Some("first".into()),
1085 ))
1086 .unwrap();
1087 backend
1088 .append_provenance(&Provenance::new(
1089 later,
1090 ProvenanceKind::Update,
1091 Some("v:e1".into()),
1092 Actor::Cli,
1093 None,
1094 None,
1095 ))
1096 .unwrap();
1097
1098 let all = backend.read_provenance(None).unwrap();
1099 assert_eq!(all.len(), 2);
1100 assert_eq!(all[0].kind, ProvenanceKind::Create);
1101 assert_eq!(all[0].entity.as_deref(), Some("v:e1"));
1102 assert_eq!(all[0].actor, Actor::Agent);
1103 assert_eq!(all[0].note.as_deref(), Some("first"));
1104 assert_eq!(
1105 all[0]
1106 .client
1107 .as_ref()
1108 .map(|c| (c.name.as_str(), c.version.as_str())),
1109 Some(("claude-code", "2.1.0"))
1110 );
1111 assert_eq!(all[0].timestamp, earlier);
1112 assert_eq!(all[1].kind, ProvenanceKind::Update);
1113 assert_eq!(all[1].timestamp, later);
1114 assert!(all[1].note.is_none());
1115 assert!(all[1].client.is_none());
1116 }
1117
1118 #[test]
1119 fn backend_provenance_cursor_filters_by_timestamp() {
1120 let tmp = TempDir::new().unwrap();
1121 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1122 use crate::backend::MemBackend;
1123 let backend: &dyn MemBackend = &writer;
1124
1125 for (secs, label) in [
1126 (1_700_000_000u64, "first"),
1127 (1_750_000_000, "middle"),
1128 (1_800_000_000, "last"),
1129 ] {
1130 backend
1131 .append_provenance(&Provenance::new(
1132 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1133 ProvenanceKind::Create,
1134 Some(format!("v:{label}")),
1135 Actor::Cli,
1136 None,
1137 None,
1138 ))
1139 .unwrap();
1140 }
1141 let cursor = changelog::format_rfc3339_utc(
1143 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1144 );
1145 let after = backend.read_provenance(Some(&cursor)).unwrap();
1146 let entities: Vec<_> = after.iter().filter_map(|p| p.entity.clone()).collect();
1147 assert_eq!(entities, vec!["v:middle".to_string(), "v:last".to_string()]);
1148 }
1149
1150 #[test]
1151 fn backend_read_provenance_handles_missing_log() {
1152 let tmp = TempDir::new().unwrap();
1153 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1154 use crate::backend::MemBackend;
1155 let backend: &dyn MemBackend = &writer;
1156 assert!(backend.read_provenance(None).unwrap().is_empty());
1158 }
1159
1160 fn append_at(
1165 backend: &dyn crate::backend::MemBackend,
1166 secs: u64,
1167 kind: ProvenanceKind,
1168 entity: &str,
1169 ) {
1170 backend
1171 .append_provenance(&Provenance::new(
1172 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
1173 kind,
1174 Some(entity.to_string()),
1175 Actor::Cli,
1176 None,
1177 None,
1178 ))
1179 .unwrap();
1180 }
1181
1182 #[test]
1183 fn folder_changes_since_no_log_returns_empty_at_cursor() {
1184 let tmp = TempDir::new().unwrap();
1187 let result =
1188 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1189 .unwrap();
1190 assert_eq!(result.since, crate::ops::EMPTY_TREE_SHA);
1191 assert_eq!(result.head, crate::ops::EMPTY_TREE_SHA);
1192 assert!(result.changes.is_empty());
1193 }
1194
1195 #[test]
1196 fn folder_changes_since_create_only_yields_added_envelope() {
1197 let tmp = TempDir::new().unwrap();
1198 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1199 append_at(
1200 &writer,
1201 1_700_000_000,
1202 ProvenanceKind::Create,
1203 "specs--alpha",
1204 );
1205
1206 let result =
1207 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1208 .unwrap();
1209 assert_eq!(result.changes.len(), 1);
1210 match &result.changes[0] {
1211 crate::ops::ChangeEnvelope::Added {
1212 id,
1213 title,
1214 entity_type,
1215 } => {
1216 assert_eq!(id.0, "specs--alpha");
1217 assert!(title.is_none(), "id-only contract");
1218 assert!(entity_type.is_none(), "id-only contract");
1219 }
1220 other => panic!("expected Added, got {other:?}"),
1221 }
1222 assert_ne!(result.head, crate::ops::EMPTY_TREE_SHA);
1224 }
1225
1226 #[test]
1227 fn folder_changes_since_create_then_delete_cancels_to_no_envelope() {
1228 let tmp = TempDir::new().unwrap();
1231 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1232 append_at(
1233 &writer,
1234 1_700_000_000,
1235 ProvenanceKind::Create,
1236 "specs--ephemeral",
1237 );
1238 append_at(
1239 &writer,
1240 1_700_000_001,
1241 ProvenanceKind::Delete,
1242 "specs--ephemeral",
1243 );
1244
1245 let result =
1246 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1247 .unwrap();
1248 assert_eq!(result.changes.len(), 1);
1249 match &result.changes[0] {
1250 crate::ops::ChangeEnvelope::Removed { id, .. } => {
1251 assert_eq!(id.0, "specs--ephemeral");
1252 }
1253 other => panic!("expected Removed (Delete wins), got {other:?}"),
1254 }
1255 }
1256
1257 #[test]
1258 fn folder_changes_since_update_only_yields_updated_envelope() {
1259 let tmp = TempDir::new().unwrap();
1260 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1261 append_at(
1262 &writer,
1263 1_700_000_000,
1264 ProvenanceKind::Update,
1265 "specs--alpha",
1266 );
1267
1268 let result =
1269 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1270 .unwrap();
1271 assert_eq!(result.changes.len(), 1);
1272 assert!(matches!(
1273 result.changes[0],
1274 crate::ops::ChangeEnvelope::Updated { .. }
1275 ));
1276 }
1277
1278 #[test]
1284 fn folder_changes_since_refuses_non_timestamp_cursor() {
1285 let tmp = TempDir::new().unwrap();
1286 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1287 append_at(
1288 &writer,
1289 1_700_000_000,
1290 ProvenanceKind::Create,
1291 "specs--alpha",
1292 );
1293
1294 let write_token = make_commit_id();
1295 for bad in [write_token.as_str(), "not-a-timestamp"] {
1296 let err = crate::ops::folder_changes_since(tmp.path(), "specs", bad).unwrap_err();
1297 match err {
1298 BackendError::Other(msg) => {
1299 assert_eq!(msg, format!("INVALID_TS_CURSOR:{bad}"));
1300 }
1301 other => panic!("expected typed marker, got {other:?}"),
1302 }
1303 }
1304 for from_start in ["", crate::ops::EMPTY_TREE_SHA] {
1305 let ok = crate::ops::folder_changes_since(tmp.path(), "specs", from_start).unwrap();
1306 assert_eq!(ok.changes.len(), 1, "sentinel '{from_start}' reads all");
1307 }
1308 }
1309
1310 #[test]
1311 fn folder_changes_since_cursor_filters_to_window() {
1312 let tmp = TempDir::new().unwrap();
1315 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1316 append_at(
1317 &writer,
1318 1_700_000_000,
1319 ProvenanceKind::Create,
1320 "specs--first",
1321 );
1322 append_at(
1323 &writer,
1324 1_750_000_000,
1325 ProvenanceKind::Create,
1326 "specs--middle",
1327 );
1328 append_at(
1329 &writer,
1330 1_800_000_000,
1331 ProvenanceKind::Create,
1332 "specs--last",
1333 );
1334
1335 let cursor = changelog::format_rfc3339_utc(
1336 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1337 );
1338 let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1339 assert_eq!(result.changes.len(), 2);
1340 let ids: Vec<_> = result
1341 .changes
1342 .iter()
1343 .map(|e| match e {
1344 crate::ops::ChangeEnvelope::Added { id, .. } => id.0.clone(),
1345 _ => panic!("expected Added"),
1346 })
1347 .collect();
1348 assert_eq!(
1350 ids,
1351 vec!["specs--last".to_string(), "specs--middle".to_string()]
1352 );
1353 }
1354
1355 #[test]
1356 fn folder_changes_since_skips_events_for_other_mems() {
1357 let tmp = TempDir::new().unwrap();
1361 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1362 append_at(
1363 &writer,
1364 1_700_000_000,
1365 ProvenanceKind::Create,
1366 "specs--mine",
1367 );
1368 append_at(
1369 &writer,
1370 1_700_000_001,
1371 ProvenanceKind::Create,
1372 "other--theirs",
1373 );
1374
1375 let result =
1376 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1377 .unwrap();
1378 assert_eq!(result.changes.len(), 1);
1379 match &result.changes[0] {
1380 crate::ops::ChangeEnvelope::Added { id, .. } => {
1381 assert_eq!(id.0, "specs--mine");
1382 }
1383 other => panic!("expected Added, got {other:?}"),
1384 }
1385 }
1386
1387 #[test]
1388 fn folder_changes_since_skips_batch_events_with_no_entity() {
1389 let tmp = TempDir::new().unwrap();
1392 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1393 use crate::backend::MemBackend;
1394 writer
1397 .append_provenance(&Provenance::new(
1398 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1399 ProvenanceKind::Batch,
1400 None,
1401 Actor::Cli,
1402 None,
1403 None,
1404 ))
1405 .unwrap();
1406 append_at(
1407 &writer,
1408 1_700_000_001,
1409 ProvenanceKind::Create,
1410 "specs--real",
1411 );
1412
1413 let result =
1414 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1415 .unwrap();
1416 assert_eq!(result.changes.len(), 1);
1418 match &result.changes[0] {
1419 crate::ops::ChangeEnvelope::Added { id, .. } => {
1420 assert_eq!(id.0, "specs--real");
1421 }
1422 other => panic!("expected Added, got {other:?}"),
1423 }
1424 }
1425
1426 #[test]
1427 fn folder_changes_since_head_echoes_cursor_when_no_events_in_window() {
1428 let tmp = TempDir::new().unwrap();
1430 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1431 append_at(&writer, 1_700_000_000, ProvenanceKind::Create, "specs--old");
1432
1433 let cursor = changelog::format_rfc3339_utc(
1434 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_900_000_000),
1435 );
1436 let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1437 assert!(result.changes.is_empty());
1438 assert_eq!(result.head, cursor);
1439 }
1440
1441 #[test]
1442 fn backend_writes_delegate_to_memwriter() {
1443 let tmp = TempDir::new().unwrap();
1444 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1445 use crate::backend::MemBackend;
1446 let backend: &dyn MemBackend = &writer;
1447
1448 backend.write_entity(Path::new("a.md"), b"alpha").unwrap();
1449 backend.commit("seed", &ctx_for_test()).unwrap();
1450 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
1451
1452 backend.delete_entity(Path::new("a.md")).unwrap();
1453 backend.commit("drop", &ctx_for_test()).unwrap();
1454 assert!(!tmp.path().join("a.md").exists());
1455 }
1456
1457 #[test]
1458 fn parse_client_id_splits_on_last_at() {
1459 let c = parse_client_id("claude-code@2.1.0").unwrap();
1460 assert_eq!(c.name, "claude-code");
1461 assert_eq!(c.version, "2.1.0");
1462 let c = parse_client_id("foo.bar@1.0").unwrap();
1464 assert_eq!(c.name, "foo.bar");
1465 assert!(parse_client_id("naked").is_none());
1468 assert!(parse_client_id("@1.0").is_none());
1469 assert!(parse_client_id("name@").is_none());
1470 }
1471}
1472
1473#[cfg(test)]
1474mod folder_drift_tests {
1475 use super::*;
1476
1477 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";
1478 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";
1479
1480 fn folder_engine(dir: std::path::PathBuf) -> crate::Engine {
1481 let mount = crate::Mount {
1482 mem: "specs".to_string(),
1483 schema: Some(memstead_schema::SchemaRef::new(
1484 "default",
1485 semver::Version::new(1, 0, 0),
1486 )),
1487 storage: crate::MountStorage::Folder { path: dir.clone() },
1488 capability: crate::MountCapability::Write,
1489 lifecycle: crate::MountLifecycle::Eager,
1490 cross_linkable: false,
1491 migration_target: None,
1492 };
1493 let backend = Box::new(FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
1494 crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
1495 }
1496
1497 #[test]
1503 fn sibling_folder_write_is_drift_and_self_write_is_not() {
1504 let tmp = tempfile::TempDir::new().unwrap();
1505 let dir = tmp.path().join("specs");
1506 std::fs::create_dir_all(&dir).unwrap();
1507 let seeder = FilesystemMemWriter::new(dir.clone());
1509 MemWriter::write_entity(&seeder, std::path::Path::new("seed.md"), ENTITY.as_bytes())
1510 .unwrap();
1511 MemWriter::commit(&seeder, "seed", &CommitContext::internal()).unwrap();
1512 crate::backend::MemBackend::append_provenance(
1513 &seeder,
1514 &Provenance::new(
1515 std::time::SystemTime::now(),
1516 ProvenanceKind::Create,
1517 Some("specs--seed".into()),
1518 Actor::Cli,
1519 None,
1520 None,
1521 ),
1522 )
1523 .unwrap();
1524
1525 let mut engine = folder_engine(dir.clone());
1526 assert!(engine.reload_if_stale(None).is_empty());
1528
1529 engine
1531 .create_entity(
1532 crate::CreateEntityArgs {
1533 mem: "specs".to_string(),
1534 title: "Self Made".to_string(),
1535 entity_type: "spec".to_string(),
1536 sections: [
1537 ("identity".to_string(), "self".to_string()),
1538 ("purpose".to_string(), "prove no self-drift".to_string()),
1539 ]
1540 .into_iter()
1541 .collect(),
1542 metadata: Default::default(),
1543 relations: Vec::new(),
1544 anchors: Vec::new(),
1545 dry_run: false,
1546 },
1547 crate::vcs::Actor::Cli,
1548 None,
1549 None,
1550 )
1551 .unwrap();
1552 assert!(
1553 engine.reload_if_stale(None).is_empty(),
1554 "the engine's own write must not read as sibling drift"
1555 );
1556 assert!(engine.take_mem_changed_notices().is_empty());
1557
1558 std::thread::sleep(std::time::Duration::from_millis(5));
1561 let sibling = FilesystemMemWriter::new(dir);
1562 MemWriter::write_entity(
1563 &sibling,
1564 std::path::Path::new("sibling.md"),
1565 SIBLING_ENTITY.as_bytes(),
1566 )
1567 .unwrap();
1568 MemWriter::commit(&sibling, "sibling", &CommitContext::internal()).unwrap();
1569 crate::backend::MemBackend::append_provenance(
1570 &sibling,
1571 &Provenance::new(
1572 std::time::SystemTime::now(),
1573 ProvenanceKind::Create,
1574 Some("specs--sibling".into()),
1575 Actor::Cli,
1576 None,
1577 None,
1578 ),
1579 )
1580 .unwrap();
1581
1582 let warnings = engine.reload_if_stale(None);
1583 assert_eq!(
1584 warnings.len(),
1585 1,
1586 "sibling drift must surface: {warnings:?}"
1587 );
1588 match &warnings[0] {
1589 crate::ops::WarningHint::MemReloaded { mem, .. } => assert_eq!(mem, "specs"),
1590 other => panic!("expected MemReloaded, got {other:?}"),
1591 }
1592 let notices = engine.take_mem_changed_notices();
1593 assert_eq!(notices.len(), 1);
1594 assert!(
1596 engine
1597 .get_entity(&crate::EntityId("specs--sibling".to_string()))
1598 .is_some(),
1599 "reload must surface the sibling's entity"
1600 );
1601 assert!(engine.reload_if_stale(None).is_empty());
1603 }
1604}