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