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 read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
252 let key = normalise_rel_path(rel_path)?;
253 let pending = self.pending.lock().map_err(|_| {
254 BackendError::Other("filesystem writer pending state poisoned".to_string())
255 })?;
256 if let Some(state) = pending.ops.get(&key) {
257 return Ok(match state {
258 PendingState::Upsert(bytes) => Some(bytes.clone()),
259 PendingState::Delete => None,
260 });
261 }
262 let full = self.root.join(&key);
263 match std::fs::read(&full) {
264 Ok(bytes) => Ok(Some(bytes)),
265 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
266 Err(e) => Err(BackendError::Io(e)),
267 }
268 }
269
270 fn write_entity(&self, rel_path: &Path, content: &[u8]) -> Result<(), BackendError> {
271 <Self as MemWriter>::write_entity(self, rel_path, content).map_err(Into::into)
272 }
273
274 fn delete_entity(&self, rel_path: &Path) -> Result<(), BackendError> {
275 <Self as MemWriter>::delete_entity(self, rel_path).map_err(Into::into)
276 }
277
278 fn move_entity(&self, from: &Path, to: &Path) -> Result<(), BackendError> {
279 <Self as MemWriter>::move_entity(self, from, to).map_err(Into::into)
280 }
281
282 fn discard_pending(&self) -> Result<(), BackendError> {
283 let mut pending = self.pending.lock().map_err(|_| {
287 BackendError::Other("filesystem writer pending state poisoned".to_string())
288 })?;
289 pending.clear();
290 Ok(())
291 }
292
293 fn commit(&self, message: &str, ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
294 <Self as MemWriter>::commit(self, message, ctx).map_err(Into::into)
295 }
296
297 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
298 let config_path = self.root.join(crate::mem::MEM_META_DIR).join("config.json");
302 match std::fs::read(&config_path) {
303 Ok(bytes) => Ok(Some(bytes)),
304 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
305 Err(e) => Err(BackendError::Io(e)),
306 }
307 }
308
309 fn write_mem_config(&self, bytes: &[u8]) -> Result<(), BackendError> {
310 let memstead_dir = self.root.join(crate::mem::MEM_META_DIR);
317 std::fs::create_dir_all(&memstead_dir).map_err(BackendError::Io)?;
318 let config_path = memstead_dir.join("config.json");
319 std::fs::write(&config_path, bytes).map_err(BackendError::Io)
320 }
321
322 fn append_provenance(&self, record: &Provenance) -> Result<(), BackendError> {
323 let kind: MutationKind = record.kind.into();
324 let entry = ChangeEntry {
325 kind,
326 entity: record.entity.as_deref(),
327 actor: record.actor,
328 client: record.client.as_ref(),
329 note: record.note.as_deref(),
330 logical_operation_id: record.logical_operation_id.as_deref(),
331 };
332 changelog::append_change_at(&self.root, &entry, record.timestamp)
333 .map_err(|e| BackendError::Other(format!("changelog append: {e}")))
334 }
335
336 fn read_provenance(&self, cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
337 let log_path = changelog_path(&self.root);
338 let raw = match std::fs::read_to_string(&log_path) {
339 Ok(s) => s,
340 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
341 Err(e) => return Err(BackendError::Io(e)),
342 };
343 let mut out = Vec::new();
344 for line in raw.lines() {
345 let trimmed = line.trim();
346 if trimmed.is_empty() {
347 continue;
348 }
349 let value: serde_json::Value = match serde_json::from_str(trimmed) {
350 Ok(v) => v,
351 Err(_) => continue,
352 };
353 let ts_str = value.get("ts").and_then(|v| v.as_str()).unwrap_or("");
354 if let Some(c) = cursor
355 && ts_str <= c
356 {
357 continue;
358 }
359 let timestamp = parse_rfc3339_utc(ts_str).unwrap_or(std::time::UNIX_EPOCH);
360 let kind = value
361 .get("kind")
362 .and_then(|v| v.as_str())
363 .and_then(ProvenanceKind::parse)
364 .unwrap_or(ProvenanceKind::Update);
365 let entity = value
366 .get("entity")
367 .and_then(|v| v.as_str())
368 .map(|s| s.to_string());
369 let actor = value
370 .get("actor")
371 .and_then(|v| v.as_str())
372 .and_then(Actor::from_trailer)
373 .unwrap_or(Actor::Unknown);
374 let client = value
375 .get("client")
376 .and_then(|v| v.as_str())
377 .and_then(parse_client_id);
378 let note = value
379 .get("note")
380 .and_then(|v| v.as_str())
381 .map(|s| s.to_string());
382 let logical_operation_id = value
383 .get("logical_op")
384 .and_then(|v| v.as_str())
385 .map(|s| s.to_string());
386 let mut record = Provenance::new(timestamp, kind, entity, actor, client, note);
387 if let Some(id) = logical_operation_id {
388 record = record.with_logical_operation_id(id);
389 }
390 out.push(record);
391 }
392 Ok(out)
393 }
394}
395
396fn walk_for_md(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BackendError> {
401 let entries = match std::fs::read_dir(dir) {
402 Ok(e) => e,
403 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
404 Err(e) => return Err(BackendError::Io(e)),
405 };
406 for entry in entries {
407 let entry = entry.map_err(BackendError::Io)?;
408 let path = entry.path();
409 let file_type = entry.file_type().map_err(BackendError::Io)?;
410 if file_type.is_dir() {
411 let name = entry.file_name();
412 if name == crate::mem::MEM_META_DIR {
413 continue;
414 }
415 walk_for_md(root, &path, out)?;
416 } else if file_type.is_file()
417 && path.extension().and_then(|s| s.to_str()) == Some("md")
418 && let Ok(rel) = path.strip_prefix(root)
419 {
420 out.push(rel.to_path_buf());
421 }
422 }
423 Ok(())
424}
425
426fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), MemWriterError> {
431 if let Some(parent) = target.parent()
432 && !parent.as_os_str().is_empty()
433 {
434 std::fs::create_dir_all(parent).map_err(MemWriterError::Io)?;
435 }
436 let tmp = make_tmp_path(target);
437 std::fs::write(&tmp, bytes).map_err(MemWriterError::Io)?;
438 if let Err(e) = std::fs::rename(&tmp, target) {
439 let _ = std::fs::remove_file(&tmp);
440 return Err(MemWriterError::Io(e));
441 }
442 Ok(())
443}
444
445fn make_tmp_path(target: &Path) -> PathBuf {
450 let name = target
451 .file_name()
452 .map(|n| n.to_string_lossy().to_string())
453 .unwrap_or_else(|| "_".to_string());
454 let suffix = unique_suffix();
455 let tmp_name = format!(".{name}.tmp.{suffix}");
456 target.with_file_name(tmp_name)
457}
458
459static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
460static COMMIT_COUNTER: AtomicU64 = AtomicU64::new(0);
461
462fn unique_suffix() -> String {
463 let nanos = std::time::SystemTime::now()
464 .duration_since(std::time::UNIX_EPOCH)
465 .map(|d| d.as_nanos())
466 .unwrap_or(0);
467 let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
468 format!("{nanos:x}-{counter:x}")
469}
470
471pub(crate) fn make_commit_id() -> CommitId {
476 let nanos = std::time::SystemTime::now()
477 .duration_since(std::time::UNIX_EPOCH)
478 .map(|d| d.as_nanos())
479 .unwrap_or(0);
480 let counter = COMMIT_COUNTER.fetch_add(1, Ordering::Relaxed);
481 format!("{nanos:032x}{counter:016x}")
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::vcs::{Actor, ClientId, CommitContext};
488 use tempfile::TempDir;
489
490 fn ctx_for_test<'a>() -> CommitContext<'a> {
491 CommitContext {
492 actor: Actor::Cli,
493 client: Some(ClientId {
494 name: "claude-code".to_string(),
495 version: "0.1.0".to_string(),
496 }),
497 tool: Some("test"),
498 note: None,
499 logical_operation_id: None,
500 entity_ids: None,
501 }
502 }
503
504 #[test]
505 fn write_then_commit_round_trip() {
506 let tmp = TempDir::new().unwrap();
507 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
508
509 writer
510 .write_entity(Path::new("notes/hello.md"), b"# hi\n")
511 .unwrap();
512 let id = writer.commit("first commit", &ctx_for_test()).unwrap();
513 assert!(!id.is_empty());
514
515 let bytes = std::fs::read(tmp.path().join("notes/hello.md")).unwrap();
516 assert_eq!(bytes, b"# hi\n");
517 }
518
519 #[test]
520 fn delete_removes_path() {
521 let tmp = TempDir::new().unwrap();
522 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
523
524 writer.write_entity(Path::new("a.md"), b"a").unwrap();
525 writer.write_entity(Path::new("b.md"), b"b").unwrap();
526 writer.commit("seed", &ctx_for_test()).unwrap();
527
528 writer.delete_entity(Path::new("a.md")).unwrap();
529 writer.commit("drop a", &ctx_for_test()).unwrap();
530
531 assert!(!tmp.path().join("a.md").exists());
532 assert!(tmp.path().join("b.md").exists());
533 }
534
535 #[test]
536 fn delete_of_missing_path_is_idempotent() {
537 let tmp = TempDir::new().unwrap();
538 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
539
540 writer.delete_entity(Path::new("never-existed.md")).unwrap();
541 writer.commit("noop delete", &ctx_for_test()).unwrap();
542 }
543
544 #[test]
545 fn move_renames_path() {
546 let tmp = TempDir::new().unwrap();
547 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
548
549 writer
550 .write_entity(Path::new("from.md"), b"payload")
551 .unwrap();
552 writer.commit("seed", &ctx_for_test()).unwrap();
553
554 writer
555 .move_entity(Path::new("from.md"), Path::new("nested/to.md"))
556 .unwrap();
557 writer.commit("rename", &ctx_for_test()).unwrap();
558
559 assert!(!tmp.path().join("from.md").exists());
560 let moved = std::fs::read(tmp.path().join("nested/to.md")).unwrap();
561 assert_eq!(moved, b"payload");
562 }
563
564 #[test]
565 fn move_with_pending_upsert_carries_bytes() {
566 let tmp = TempDir::new().unwrap();
567 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
568
569 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
570 writer
571 .move_entity(Path::new("a.md"), Path::new("b.md"))
572 .unwrap();
573 writer.commit("write+move", &ctx_for_test()).unwrap();
574
575 assert!(!tmp.path().join("a.md").exists());
576 assert_eq!(std::fs::read(tmp.path().join("b.md")).unwrap(), b"alpha");
577 }
578
579 #[test]
580 fn move_missing_source_errors() {
581 let tmp = TempDir::new().unwrap();
582 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
583
584 let err = writer
585 .move_entity(Path::new("ghost.md"), Path::new("here.md"))
586 .unwrap_err();
587 assert!(matches!(err, MemWriterError::Path(_)));
588 }
589
590 #[test]
591 fn move_with_pending_target_upsert_errors() {
592 let tmp = TempDir::new().unwrap();
593 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
594
595 writer.write_entity(Path::new("from.md"), b"x").unwrap();
596 writer.write_entity(Path::new("to.md"), b"y").unwrap();
597 let err = writer
598 .move_entity(Path::new("from.md"), Path::new("to.md"))
599 .unwrap_err();
600 assert!(matches!(err, MemWriterError::Path(_)));
601 }
602
603 #[test]
604 fn multi_op_commit() {
605 let tmp = TempDir::new().unwrap();
606 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
607
608 writer.write_entity(Path::new("doomed.md"), b"x").unwrap();
609 writer.commit("seed", &ctx_for_test()).unwrap();
610
611 writer.write_entity(Path::new("a.md"), b"alpha").unwrap();
612 writer
613 .write_entity(Path::new("nested/b.md"), b"beta")
614 .unwrap();
615 writer.delete_entity(Path::new("doomed.md")).unwrap();
616 writer.commit("multi-op", &ctx_for_test()).unwrap();
617
618 assert!(!tmp.path().join("doomed.md").exists());
619 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
620 assert_eq!(
621 std::fs::read(tmp.path().join("nested/b.md")).unwrap(),
622 b"beta"
623 );
624 }
625
626 #[test]
627 fn rejects_path_traversal() {
628 let tmp = TempDir::new().unwrap();
629 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
630
631 let err = writer
632 .write_entity(Path::new("../escape.md"), b"x")
633 .unwrap_err();
634 assert!(matches!(err, MemWriterError::Path(_)));
635 }
636
637 #[test]
638 fn rejects_absolute_path() {
639 let tmp = TempDir::new().unwrap();
640 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
641
642 let err = writer
643 .write_entity(Path::new("/etc/passwd"), b"x")
644 .unwrap_err();
645 assert!(matches!(err, MemWriterError::Path(_)));
646 }
647
648 #[test]
649 fn rejects_empty_path() {
650 let tmp = TempDir::new().unwrap();
651 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
652
653 let err = writer.write_entity(Path::new(""), b"x").unwrap_err();
654 assert!(matches!(err, MemWriterError::Path(_)));
655 }
656
657 #[test]
658 fn write_overwrites_existing_file() {
659 let tmp = TempDir::new().unwrap();
660 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
661
662 writer.write_entity(Path::new("a.md"), b"first").unwrap();
663 writer.commit("c1", &ctx_for_test()).unwrap();
664 writer.write_entity(Path::new("a.md"), b"second").unwrap();
665 writer.commit("c2", &ctx_for_test()).unwrap();
666
667 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"second");
668 }
669
670 #[test]
671 fn no_temp_files_left_after_commit() {
672 let tmp = TempDir::new().unwrap();
673 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
674
675 writer.write_entity(Path::new("a.md"), b"a").unwrap();
676 writer.write_entity(Path::new("nested/b.md"), b"b").unwrap();
677 writer.commit("c", &ctx_for_test()).unwrap();
678
679 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
681 for entry in std::fs::read_dir(dir).unwrap() {
682 let p = entry.unwrap().path();
683 if p.is_dir() {
684 walk(&p, out);
685 } else {
686 out.push(p);
687 }
688 }
689 }
690 let mut files = Vec::new();
691 walk(tmp.path(), &mut files);
692 for f in &files {
693 let name = f.file_name().unwrap().to_string_lossy();
694 assert!(
695 !name.contains(".tmp."),
696 "stray temp file after commit: {}",
697 f.display()
698 );
699 }
700 }
701
702 #[test]
703 fn commit_id_is_unique_across_calls() {
704 let tmp = TempDir::new().unwrap();
705 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
706
707 writer.write_entity(Path::new("a.md"), b"a").unwrap();
708 let id1 = writer.commit("c1", &ctx_for_test()).unwrap();
709 writer.write_entity(Path::new("b.md"), b"b").unwrap();
710 let id2 = writer.commit("c2", &ctx_for_test()).unwrap();
711
712 assert_ne!(id1, id2);
713 }
714
715 #[test]
716 fn pending_buffer_clears_on_commit() {
717 let tmp = TempDir::new().unwrap();
718 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
719
720 writer.write_entity(Path::new("a.md"), b"a").unwrap();
721 writer.commit("c1", &ctx_for_test()).unwrap();
722 let id2 = writer.commit("noop", &ctx_for_test()).unwrap();
725 assert!(!id2.is_empty());
726 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"a");
728 }
729
730 #[test]
737 fn backend_write_mem_config_round_trips_via_read() {
738 use crate::backend::MemBackend;
739
740 let tmp = TempDir::new().unwrap();
741 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
742 let backend: &dyn MemBackend = &writer;
743
744 assert!(backend.read_mem_config().unwrap().is_none());
746
747 let bytes = br#"{"version":"0.1.0","schema":"default@1.0.0"}"#.to_vec();
749 backend.write_mem_config(&bytes).unwrap();
750
751 let read_back = backend.read_mem_config().unwrap();
753 assert_eq!(read_back, Some(bytes.clone()));
754 let on_disk = std::fs::read(tmp.path().join(".memstead/config.json")).unwrap();
756 assert_eq!(on_disk, bytes);
757 }
758
759 #[test]
760 fn backend_list_entities_returns_only_md_outside_meta_dirs() {
761 use crate::backend::MemBackend;
762
763 let tmp = TempDir::new().unwrap();
764 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
765
766 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("a.md"), b"a").unwrap();
769 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("nested/b.md"), b"b")
770 .unwrap();
771 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("notes.json"), b"{}")
772 .unwrap();
773 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
774 std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
778 std::fs::write(tmp.path().join(".memstead/config.json"), b"{}").unwrap();
779 std::fs::write(tmp.path().join(".memstead/notes.md"), b"#").unwrap();
780 std::fs::create_dir_all(tmp.path().join(".other")).unwrap();
781 std::fs::write(tmp.path().join(".other/notes.md"), b"#").unwrap();
782
783 let backend: &dyn MemBackend = &writer;
784 let mut paths: Vec<String> = backend
785 .list_entities()
786 .unwrap()
787 .into_iter()
788 .map(|p| p.to_string_lossy().into_owned())
789 .collect();
790 paths.sort();
791 assert_eq!(
792 paths,
793 vec![
794 ".other/notes.md".to_string(),
795 "a.md".to_string(),
796 "nested/b.md".to_string(),
797 ]
798 );
799 }
800
801 #[test]
802 fn backend_read_entity_consults_pending_then_disk() {
803 let tmp = TempDir::new().unwrap();
804 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
805
806 <FilesystemMemWriter as MemWriter>::write_entity(&writer, Path::new("on_disk.md"), b"disk")
811 .unwrap();
812 <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &ctx_for_test()).unwrap();
813
814 use crate::backend::MemBackend;
815 let backend: &dyn MemBackend = &writer;
816 assert_eq!(
818 backend.read_entity(Path::new("on_disk.md")).unwrap(),
819 Some(b"disk".to_vec())
820 );
821 backend
823 .write_entity(Path::new("on_disk.md"), b"buffered")
824 .unwrap();
825 assert_eq!(
826 backend.read_entity(Path::new("on_disk.md")).unwrap(),
827 Some(b"buffered".to_vec())
828 );
829 backend.delete_entity(Path::new("on_disk.md")).unwrap();
831 assert_eq!(backend.read_entity(Path::new("on_disk.md")).unwrap(), None);
832 assert_eq!(backend.read_entity(Path::new("never.md")).unwrap(), None);
834 }
835
836 #[test]
837 fn backend_provenance_round_trips_through_jsonl() {
838 let tmp = TempDir::new().unwrap();
839 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
840 use crate::backend::MemBackend;
841 let backend: &dyn MemBackend = &writer;
842
843 let client = ClientId {
844 name: "claude-code".into(),
845 version: "2.1.0".into(),
846 };
847 let earlier = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
848 let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_777_077_296);
849
850 backend
851 .append_provenance(&Provenance::new(
852 earlier,
853 ProvenanceKind::Create,
854 Some("v:e1".into()),
855 Actor::Agent,
856 Some(client.clone()),
857 Some("first".into()),
858 ))
859 .unwrap();
860 backend
861 .append_provenance(&Provenance::new(
862 later,
863 ProvenanceKind::Update,
864 Some("v:e1".into()),
865 Actor::Cli,
866 None,
867 None,
868 ))
869 .unwrap();
870
871 let all = backend.read_provenance(None).unwrap();
872 assert_eq!(all.len(), 2);
873 assert_eq!(all[0].kind, ProvenanceKind::Create);
874 assert_eq!(all[0].entity.as_deref(), Some("v:e1"));
875 assert_eq!(all[0].actor, Actor::Agent);
876 assert_eq!(all[0].note.as_deref(), Some("first"));
877 assert_eq!(
878 all[0]
879 .client
880 .as_ref()
881 .map(|c| (c.name.as_str(), c.version.as_str())),
882 Some(("claude-code", "2.1.0"))
883 );
884 assert_eq!(all[0].timestamp, earlier);
885 assert_eq!(all[1].kind, ProvenanceKind::Update);
886 assert_eq!(all[1].timestamp, later);
887 assert!(all[1].note.is_none());
888 assert!(all[1].client.is_none());
889 }
890
891 #[test]
892 fn backend_provenance_cursor_filters_by_timestamp() {
893 let tmp = TempDir::new().unwrap();
894 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
895 use crate::backend::MemBackend;
896 let backend: &dyn MemBackend = &writer;
897
898 for (secs, label) in [
899 (1_700_000_000u64, "first"),
900 (1_750_000_000, "middle"),
901 (1_800_000_000, "last"),
902 ] {
903 backend
904 .append_provenance(&Provenance::new(
905 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
906 ProvenanceKind::Create,
907 Some(format!("v:{label}")),
908 Actor::Cli,
909 None,
910 None,
911 ))
912 .unwrap();
913 }
914 let cursor = changelog::format_rfc3339_utc(
916 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
917 );
918 let after = backend.read_provenance(Some(&cursor)).unwrap();
919 let entities: Vec<_> = after.iter().filter_map(|p| p.entity.clone()).collect();
920 assert_eq!(entities, vec!["v:middle".to_string(), "v:last".to_string()]);
921 }
922
923 #[test]
924 fn backend_read_provenance_handles_missing_log() {
925 let tmp = TempDir::new().unwrap();
926 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
927 use crate::backend::MemBackend;
928 let backend: &dyn MemBackend = &writer;
929 assert!(backend.read_provenance(None).unwrap().is_empty());
931 }
932
933 fn append_at(
938 backend: &dyn crate::backend::MemBackend,
939 secs: u64,
940 kind: ProvenanceKind,
941 entity: &str,
942 ) {
943 backend
944 .append_provenance(&Provenance::new(
945 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs),
946 kind,
947 Some(entity.to_string()),
948 Actor::Cli,
949 None,
950 None,
951 ))
952 .unwrap();
953 }
954
955 #[test]
956 fn folder_changes_since_no_log_returns_empty_at_cursor() {
957 let tmp = TempDir::new().unwrap();
960 let result =
961 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
962 .unwrap();
963 assert_eq!(result.since, crate::ops::EMPTY_TREE_SHA);
964 assert_eq!(result.head, crate::ops::EMPTY_TREE_SHA);
965 assert!(result.changes.is_empty());
966 }
967
968 #[test]
969 fn folder_changes_since_create_only_yields_added_envelope() {
970 let tmp = TempDir::new().unwrap();
971 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
972 append_at(
973 &writer,
974 1_700_000_000,
975 ProvenanceKind::Create,
976 "specs--alpha",
977 );
978
979 let result =
980 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
981 .unwrap();
982 assert_eq!(result.changes.len(), 1);
983 match &result.changes[0] {
984 crate::ops::ChangeEnvelope::Added {
985 id,
986 title,
987 entity_type,
988 } => {
989 assert_eq!(id.0, "specs--alpha");
990 assert!(title.is_none(), "id-only contract");
991 assert!(entity_type.is_none(), "id-only contract");
992 }
993 other => panic!("expected Added, got {other:?}"),
994 }
995 assert_ne!(result.head, crate::ops::EMPTY_TREE_SHA);
997 }
998
999 #[test]
1000 fn folder_changes_since_create_then_delete_cancels_to_no_envelope() {
1001 let tmp = TempDir::new().unwrap();
1004 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1005 append_at(
1006 &writer,
1007 1_700_000_000,
1008 ProvenanceKind::Create,
1009 "specs--ephemeral",
1010 );
1011 append_at(
1012 &writer,
1013 1_700_000_001,
1014 ProvenanceKind::Delete,
1015 "specs--ephemeral",
1016 );
1017
1018 let result =
1019 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1020 .unwrap();
1021 assert_eq!(result.changes.len(), 1);
1022 match &result.changes[0] {
1023 crate::ops::ChangeEnvelope::Removed { id, .. } => {
1024 assert_eq!(id.0, "specs--ephemeral");
1025 }
1026 other => panic!("expected Removed (Delete wins), got {other:?}"),
1027 }
1028 }
1029
1030 #[test]
1031 fn folder_changes_since_update_only_yields_updated_envelope() {
1032 let tmp = TempDir::new().unwrap();
1033 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1034 append_at(
1035 &writer,
1036 1_700_000_000,
1037 ProvenanceKind::Update,
1038 "specs--alpha",
1039 );
1040
1041 let result =
1042 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1043 .unwrap();
1044 assert_eq!(result.changes.len(), 1);
1045 assert!(matches!(
1046 result.changes[0],
1047 crate::ops::ChangeEnvelope::Updated { .. }
1048 ));
1049 }
1050
1051 #[test]
1052 fn folder_changes_since_cursor_filters_to_window() {
1053 let tmp = TempDir::new().unwrap();
1056 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1057 append_at(
1058 &writer,
1059 1_700_000_000,
1060 ProvenanceKind::Create,
1061 "specs--first",
1062 );
1063 append_at(
1064 &writer,
1065 1_750_000_000,
1066 ProvenanceKind::Create,
1067 "specs--middle",
1068 );
1069 append_at(
1070 &writer,
1071 1_800_000_000,
1072 ProvenanceKind::Create,
1073 "specs--last",
1074 );
1075
1076 let cursor = changelog::format_rfc3339_utc(
1077 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_725_000_000),
1078 );
1079 let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1080 assert_eq!(result.changes.len(), 2);
1081 let ids: Vec<_> = result
1082 .changes
1083 .iter()
1084 .map(|e| match e {
1085 crate::ops::ChangeEnvelope::Added { id, .. } => id.0.clone(),
1086 _ => panic!("expected Added"),
1087 })
1088 .collect();
1089 assert_eq!(
1091 ids,
1092 vec!["specs--last".to_string(), "specs--middle".to_string()]
1093 );
1094 }
1095
1096 #[test]
1097 fn folder_changes_since_skips_events_for_other_mems() {
1098 let tmp = TempDir::new().unwrap();
1102 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1103 append_at(
1104 &writer,
1105 1_700_000_000,
1106 ProvenanceKind::Create,
1107 "specs--mine",
1108 );
1109 append_at(
1110 &writer,
1111 1_700_000_001,
1112 ProvenanceKind::Create,
1113 "other--theirs",
1114 );
1115
1116 let result =
1117 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1118 .unwrap();
1119 assert_eq!(result.changes.len(), 1);
1120 match &result.changes[0] {
1121 crate::ops::ChangeEnvelope::Added { id, .. } => {
1122 assert_eq!(id.0, "specs--mine");
1123 }
1124 other => panic!("expected Added, got {other:?}"),
1125 }
1126 }
1127
1128 #[test]
1129 fn folder_changes_since_skips_batch_events_with_no_entity() {
1130 let tmp = TempDir::new().unwrap();
1133 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1134 use crate::backend::MemBackend;
1135 writer
1138 .append_provenance(&Provenance::new(
1139 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1140 ProvenanceKind::Batch,
1141 None,
1142 Actor::Cli,
1143 None,
1144 None,
1145 ))
1146 .unwrap();
1147 append_at(
1148 &writer,
1149 1_700_000_001,
1150 ProvenanceKind::Create,
1151 "specs--real",
1152 );
1153
1154 let result =
1155 crate::ops::folder_changes_since(tmp.path(), "specs", crate::ops::EMPTY_TREE_SHA)
1156 .unwrap();
1157 assert_eq!(result.changes.len(), 1);
1159 match &result.changes[0] {
1160 crate::ops::ChangeEnvelope::Added { id, .. } => {
1161 assert_eq!(id.0, "specs--real");
1162 }
1163 other => panic!("expected Added, got {other:?}"),
1164 }
1165 }
1166
1167 #[test]
1168 fn folder_changes_since_head_echoes_cursor_when_no_events_in_window() {
1169 let tmp = TempDir::new().unwrap();
1171 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1172 append_at(&writer, 1_700_000_000, ProvenanceKind::Create, "specs--old");
1173
1174 let cursor = changelog::format_rfc3339_utc(
1175 std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_900_000_000),
1176 );
1177 let result = crate::ops::folder_changes_since(tmp.path(), "specs", &cursor).unwrap();
1178 assert!(result.changes.is_empty());
1179 assert_eq!(result.head, cursor);
1180 }
1181
1182 #[test]
1183 fn backend_writes_delegate_to_memwriter() {
1184 let tmp = TempDir::new().unwrap();
1185 let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1186 use crate::backend::MemBackend;
1187 let backend: &dyn MemBackend = &writer;
1188
1189 backend.write_entity(Path::new("a.md"), b"alpha").unwrap();
1190 backend.commit("seed", &ctx_for_test()).unwrap();
1191 assert_eq!(std::fs::read(tmp.path().join("a.md")).unwrap(), b"alpha");
1192
1193 backend.delete_entity(Path::new("a.md")).unwrap();
1194 backend.commit("drop", &ctx_for_test()).unwrap();
1195 assert!(!tmp.path().join("a.md").exists());
1196 }
1197
1198 #[test]
1199 fn parse_client_id_splits_on_last_at() {
1200 let c = parse_client_id("claude-code@2.1.0").unwrap();
1201 assert_eq!(c.name, "claude-code");
1202 assert_eq!(c.version, "2.1.0");
1203 let c = parse_client_id("foo.bar@1.0").unwrap();
1205 assert_eq!(c.name, "foo.bar");
1206 assert!(parse_client_id("naked").is_none());
1209 assert!(parse_client_id("@1.0").is_none());
1210 assert!(parse_client_id("name@").is_none());
1211 }
1212}