1use std::collections::HashSet;
27use std::fmt;
28use std::path::{Path, PathBuf};
29use std::process::{Command, Stdio};
30
31use super::{SnapshotResult, WorkspaceBackend, WorkspaceStatus};
32use crate::model::types::{EpochId, WorkspaceId, WorkspaceInfo, WorkspaceMode, WorkspaceState};
33
34const EPOCH_FILE: &str = ".maw-epoch";
43
44#[derive(Debug)]
50pub enum ReflinkBackendError {
51 Io(std::io::Error),
53 Command {
55 command: String,
56 stderr: String,
57 exit_code: Option<i32>,
58 },
59 NotFound { name: String },
61 EpochSnapshotMissing { epoch: String },
63 MissingEpochFile { workspace: String },
65 InvalidEpochFile { workspace: String, reason: String },
67}
68
69impl fmt::Display for ReflinkBackendError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::Io(e) => write!(f, "I/O error: {e}"),
73 Self::Command {
74 command,
75 stderr,
76 exit_code,
77 } => {
78 write!(f, "`{command}` failed")?;
79 if let Some(code) = exit_code {
80 write!(f, " (exit code {code})")?;
81 }
82 if !stderr.is_empty() {
83 write!(f, ": {stderr}")?;
84 }
85 Ok(())
86 }
87 Self::NotFound { name } => write!(f, "workspace '{name}' not found"),
88 Self::EpochSnapshotMissing { epoch } => {
89 write!(
90 f,
91 "epoch snapshot .manifold/epochs/e-{epoch}/ not found; \
92 run `maw epoch snapshot` to create it"
93 )
94 }
95 Self::MissingEpochFile { workspace } => {
96 write!(
97 f,
98 "workspace '{workspace}' is missing {EPOCH_FILE}; \
99 the workspace may be corrupted"
100 )
101 }
102 Self::InvalidEpochFile { workspace, reason } => {
103 write!(
104 f,
105 "workspace '{workspace}' has an invalid {EPOCH_FILE}: {reason}"
106 )
107 }
108 }
109 }
110}
111
112impl std::error::Error for ReflinkBackendError {
113 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
114 match self {
115 Self::Io(e) => Some(e),
116 _ => None,
117 }
118 }
119}
120
121impl From<std::io::Error> for ReflinkBackendError {
122 fn from(e: std::io::Error) -> Self {
123 Self::Io(e)
124 }
125}
126
127pub struct RefLinkBackend {
141 root: PathBuf,
143}
144
145impl RefLinkBackend {
146 #[must_use]
151 pub const fn new(root: PathBuf) -> Self {
152 Self { root }
153 }
154
155 fn workspaces_dir(&self) -> PathBuf {
161 self.root.join("ws")
162 }
163
164 fn epoch_snapshot_path(&self, epoch: &EpochId) -> PathBuf {
168 self.root
169 .join(".manifold")
170 .join("epochs")
171 .join(format!("e-{}", epoch.as_str()))
172 }
173
174 fn read_epoch_file(ws_path: &Path, name: &str) -> Result<EpochId, ReflinkBackendError> {
176 let epoch_file = ws_path.join(EPOCH_FILE);
177 if !epoch_file.exists() {
178 return Err(ReflinkBackendError::MissingEpochFile {
179 workspace: name.to_owned(),
180 });
181 }
182 let raw = std::fs::read_to_string(&epoch_file)?;
183 let oid_str = raw.trim();
184 EpochId::new(oid_str).map_err(|e| ReflinkBackendError::InvalidEpochFile {
185 workspace: name.to_owned(),
186 reason: e.to_string(),
187 })
188 }
189
190 fn write_epoch_file(ws_path: &Path, epoch: &EpochId) -> Result<(), ReflinkBackendError> {
192 let epoch_file = ws_path.join(EPOCH_FILE);
193 let content = format!("{}\n", epoch.as_str());
194 std::fs::write(&epoch_file, content)?;
195 Ok(())
196 }
197
198 fn current_epoch_opt(&self) -> Option<EpochId> {
202 let output = Command::new("git")
203 .args(["rev-parse", "refs/manifold/epoch/current"])
204 .current_dir(&self.root)
205 .stdout(Stdio::piped())
206 .stderr(Stdio::null())
207 .output()
208 .ok()?;
209 if output.status.success() {
210 let oid_str = String::from_utf8_lossy(&output.stdout).trim().to_owned();
211 EpochId::new(&oid_str).ok()
212 } else {
213 None
214 }
215 }
216
217 fn reflink_copy(src: &Path, dst: &Path) -> Result<(), ReflinkBackendError> {
224 let output = Command::new("cp")
226 .args(["-r", "--reflink=auto"])
227 .arg(src)
228 .arg(dst)
229 .stdout(Stdio::null())
230 .stderr(Stdio::piped())
231 .output();
232
233 match output {
234 Ok(o) if o.status.success() => return Ok(()),
235 Ok(o) => {
236 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_owned();
237 if !stderr.contains("invalid option") && !stderr.contains("unrecognized option") {
240 return Err(ReflinkBackendError::Command {
241 command: format!(
242 "cp -r --reflink=auto {} {}",
243 src.display(),
244 dst.display()
245 ),
246 stderr,
247 exit_code: o.status.code(),
248 });
249 }
250 }
251 Err(_) => {} }
253
254 Self::recursive_copy(src, dst)
256 }
257
258 fn recursive_copy(src: &Path, dst: &Path) -> Result<(), ReflinkBackendError> {
260 std::fs::create_dir_all(dst)?;
261 for entry in std::fs::read_dir(src)? {
262 let entry = entry?;
263 let src_path = entry.path();
264 let dst_path = dst.join(entry.file_name());
265 let metadata = entry.metadata()?;
266 if metadata.is_dir() {
267 Self::recursive_copy(&src_path, &dst_path)?;
268 } else if metadata.is_symlink() {
269 let target = std::fs::read_link(&src_path)?;
270 #[cfg(unix)]
271 std::os::unix::fs::symlink(&target, &dst_path)?;
272 #[cfg(not(unix))]
273 {
274 std::fs::copy(&src_path, &dst_path)?;
276 }
277 } else {
278 std::fs::copy(&src_path, &dst_path)?;
279 }
280 }
281 Ok(())
282 }
283}
284
285impl WorkspaceBackend for RefLinkBackend {
290 type Error = ReflinkBackendError;
291
292 fn create(&self, name: &WorkspaceId, epoch: &EpochId) -> Result<WorkspaceInfo, Self::Error> {
301 let ws_path = self.workspace_path(name);
302
303 if ws_path.exists() {
305 if let Ok(existing_epoch) = Self::read_epoch_file(&ws_path, name.as_str())
306 && existing_epoch == *epoch
307 {
308 return Ok(WorkspaceInfo {
309 id: name.clone(),
310 path: ws_path,
311 epoch: epoch.clone(),
312 state: WorkspaceState::Active,
313 mode: WorkspaceMode::default(),
314 commits_ahead: 0,
315 });
316 }
317 std::fs::remove_dir_all(&ws_path)?;
319 }
320
321 let snapshot_path = self.epoch_snapshot_path(epoch);
323 if !snapshot_path.exists() {
324 return Err(ReflinkBackendError::EpochSnapshotMissing {
325 epoch: epoch.as_str().to_owned(),
326 });
327 }
328
329 let ws_dir = self.workspaces_dir();
331 std::fs::create_dir_all(&ws_dir)?;
332
333 Self::reflink_copy(&snapshot_path, &ws_path)?;
335
336 Self::write_epoch_file(&ws_path, epoch)?;
338
339 Ok(WorkspaceInfo {
340 id: name.clone(),
341 path: ws_path,
342 epoch: epoch.clone(),
343 state: WorkspaceState::Active,
344 mode: WorkspaceMode::default(),
345 commits_ahead: 0,
346 })
347 }
348
349 fn destroy(&self, name: &WorkspaceId) -> Result<(), Self::Error> {
353 let ws_path = self.workspace_path(name);
354 if ws_path.exists() {
355 std::fs::remove_dir_all(&ws_path)?;
356 }
357 Ok(())
358 }
359
360 fn list(&self) -> Result<Vec<WorkspaceInfo>, Self::Error> {
366 let ws_dir = self.workspaces_dir();
367 if !ws_dir.exists() {
368 return Ok(vec![]);
369 }
370
371 let current_epoch = self.current_epoch_opt();
372 let mut infos = Vec::new();
373
374 for entry in std::fs::read_dir(&ws_dir)? {
375 let entry = entry?;
376 let path = entry.path();
377 if !path.is_dir() {
378 continue;
379 }
380 let name_str = match path.file_name().and_then(|n| n.to_str()) {
381 Some(s) => s.to_owned(),
382 None => continue,
383 };
384 let Ok(id) = WorkspaceId::new(&name_str) else {
385 continue; };
387
388 let Ok(epoch) = Self::read_epoch_file(&path, &name_str) else {
389 continue; };
391
392 let state = match ¤t_epoch {
393 Some(current) if epoch == *current => WorkspaceState::Active,
394 Some(_) => WorkspaceState::Stale { behind_epochs: 1 },
395 None => WorkspaceState::Active,
396 };
397
398 infos.push(WorkspaceInfo {
399 id,
400 path,
401 epoch,
402 state,
403 mode: WorkspaceMode::default(),
404 commits_ahead: 0,
405 });
406 }
407
408 Ok(infos)
409 }
410
411 fn status(&self, name: &WorkspaceId) -> Result<WorkspaceStatus, Self::Error> {
416 let ws_path = self.workspace_path(name);
417 if !ws_path.exists() {
418 return Err(ReflinkBackendError::NotFound {
419 name: name.as_str().to_owned(),
420 });
421 }
422
423 let base_epoch = Self::read_epoch_file(&ws_path, name.as_str())?;
424 let snapshot_path = self.epoch_snapshot_path(&base_epoch);
425
426 let snap = diff_dirs(&snapshot_path, &ws_path);
427 let mut dirty_files: Vec<PathBuf> = snap
428 .added
429 .iter()
430 .chain(snap.modified.iter())
431 .chain(snap.deleted.iter())
432 .cloned()
433 .collect();
434 dirty_files.sort();
435 dirty_files.dedup();
436
437 let is_stale = self
438 .current_epoch_opt()
439 .is_some_and(|current| base_epoch != current);
440
441 Ok(WorkspaceStatus::new(base_epoch, dirty_files, is_stale))
442 }
443
444 fn snapshot(&self, name: &WorkspaceId) -> Result<SnapshotResult, Self::Error> {
452 let ws_path = self.workspace_path(name);
453 if !ws_path.exists() {
454 return Err(ReflinkBackendError::NotFound {
455 name: name.as_str().to_owned(),
456 });
457 }
458
459 let base_epoch = Self::read_epoch_file(&ws_path, name.as_str())?;
460 let snapshot_path = self.epoch_snapshot_path(&base_epoch);
461
462 Ok(diff_dirs(&snapshot_path, &ws_path))
463 }
464
465 fn workspace_path(&self, name: &WorkspaceId) -> PathBuf {
466 self.workspaces_dir().join(name.as_str())
467 }
468
469 fn exists(&self, name: &WorkspaceId) -> bool {
470 let ws_path = self.workspace_path(name);
471 ws_path.is_dir() && ws_path.join(EPOCH_FILE).exists()
472 }
473}
474
475const EXCLUDED_NAMES: &[&str] = &[EPOCH_FILE];
484
485fn diff_dirs(base_dir: &Path, ws_dir: &Path) -> SnapshotResult {
493 let base_files: HashSet<PathBuf> = if base_dir.exists() {
495 collect_files(base_dir, &[]).into_iter().collect()
496 } else {
497 HashSet::new()
498 };
499
500 let ws_files: HashSet<PathBuf> = collect_files(ws_dir, EXCLUDED_NAMES).into_iter().collect();
502
503 let mut added = Vec::new();
504 let mut modified = Vec::new();
505 let mut deleted = Vec::new();
506
507 for rel in &ws_files {
509 if base_files.contains(rel) {
510 let base_file = base_dir.join(rel);
512 let ws_file = ws_dir.join(rel);
513 if !files_equal(&base_file, &ws_file) {
514 modified.push(rel.clone());
515 }
516 } else {
517 added.push(rel.clone());
518 }
519 }
520
521 for rel in &base_files {
523 if !ws_files.contains(rel) {
524 deleted.push(rel.clone());
525 }
526 }
527
528 added.sort();
529 modified.sort();
530 deleted.sort();
531
532 SnapshotResult::new(added, modified, deleted)
533}
534
535fn collect_files(root: &Path, exclude_names: &[&str]) -> Vec<PathBuf> {
540 let mut files = Vec::new();
541 collect_files_inner(root, root, exclude_names, &mut files);
542 files
543}
544
545fn collect_files_inner(root: &Path, dir: &Path, exclude_names: &[&str], files: &mut Vec<PathBuf>) {
546 let Ok(entries) = std::fs::read_dir(dir) else {
547 return;
548 };
549 for entry in entries.flatten() {
550 let path = entry.path();
551 let name = entry.file_name();
552 let name_str = name.to_string_lossy();
553
554 if exclude_names.iter().any(|e| *e == name_str.as_ref()) {
556 continue;
557 }
558
559 if path.is_dir() {
560 collect_files_inner(root, &path, exclude_names, files);
561 } else if path.is_file() {
562 if let Ok(rel) = path.strip_prefix(root) {
564 files.push(rel.to_path_buf());
565 }
566 }
567 }
571}
572
573fn files_equal(a: &Path, b: &Path) -> bool {
577 match (std::fs::read(a), std::fs::read(b)) {
578 (Ok(a_bytes), Ok(b_bytes)) => a_bytes == b_bytes,
579 _ => false,
580 }
581}
582
583#[cfg(test)]
588#[allow(clippy::redundant_clone)]
589mod tests {
590 use super::*;
591 use std::fs;
592 use tempfile::TempDir;
593
594 fn setup_repo_with_snapshot() -> (TempDir, PathBuf, EpochId) {
598 let temp = TempDir::new().unwrap();
599 let root = temp.path().to_path_buf();
600
601 let epoch_oid = "a".repeat(40);
603 let epoch = EpochId::new(&epoch_oid).unwrap();
604
605 let snap_dir = root
607 .join(".manifold")
608 .join("epochs")
609 .join(format!("e-{epoch_oid}"));
610 fs::create_dir_all(&snap_dir).unwrap();
611 fs::write(snap_dir.join("README.md"), "# Epoch snapshot").unwrap();
612 fs::write(snap_dir.join("main.rs"), "fn main() {}").unwrap();
613 fs::create_dir_all(snap_dir.join("src")).unwrap();
614 fs::write(snap_dir.join("src").join("lib.rs"), "pub fn lib() {}").unwrap();
615
616 (temp, root, epoch)
617 }
618
619 #[test]
622 fn test_create_workspace() {
623 let (_temp, root, epoch) = setup_repo_with_snapshot();
624 let backend = RefLinkBackend::new(root.clone());
625 let ws_name = WorkspaceId::new("test-ws").unwrap();
626
627 let info = backend.create(&ws_name, &epoch).unwrap();
628 assert_eq!(info.id, ws_name);
629 assert_eq!(info.path, root.join("ws").join("test-ws"));
630 assert!(info.path.exists());
631 assert!(info.path.join("README.md").exists());
633 assert!(info.path.join("main.rs").exists());
634 assert!(info.path.join("src").join("lib.rs").exists());
635 assert!(info.path.join(EPOCH_FILE).exists());
637 }
638
639 #[test]
640 fn test_create_idempotent() {
641 let (_temp, root, epoch) = setup_repo_with_snapshot();
642 let backend = RefLinkBackend::new(root.clone());
643 let ws_name = WorkspaceId::new("idem-ws").unwrap();
644
645 let info1 = backend.create(&ws_name, &epoch).unwrap();
646 let info2 = backend.create(&ws_name, &epoch).unwrap();
647 assert_eq!(info1.path, info2.path);
648 assert_eq!(info1.epoch, info2.epoch);
649 }
650
651 #[test]
652 fn test_create_replaces_mismatched_workspace() {
653 let (_temp, root, epoch) = setup_repo_with_snapshot();
654 let backend = RefLinkBackend::new(root.clone());
655 let ws_name = WorkspaceId::new("replace-ws").unwrap();
656
657 let ws_path = root.join("ws").join("replace-ws");
659 fs::create_dir_all(&ws_path).unwrap();
660 fs::write(ws_path.join(EPOCH_FILE), "b".repeat(40) + "\n").unwrap();
661 fs::write(ws_path.join("stale.txt"), "stale content").unwrap();
662
663 let info = backend.create(&ws_name, &epoch).unwrap();
665 assert_eq!(info.epoch, epoch);
666 assert!(!ws_path.join("stale.txt").exists());
668 assert!(ws_path.join("README.md").exists());
670 }
671
672 #[test]
673 fn test_create_missing_epoch_snapshot() {
674 let (_temp, root, _epoch) = setup_repo_with_snapshot();
675 let backend = RefLinkBackend::new(root.clone());
676 let ws_name = WorkspaceId::new("no-snap-ws").unwrap();
677
678 let missing_epoch = EpochId::new(&"f".repeat(40)).unwrap();
680 let err = backend.create(&ws_name, &missing_epoch).unwrap_err();
681 assert!(
682 matches!(err, ReflinkBackendError::EpochSnapshotMissing { .. }),
683 "expected EpochSnapshotMissing: {err}"
684 );
685 }
686
687 #[test]
690 fn test_exists_false_for_nonexistent() {
691 let (_temp, root, _epoch) = setup_repo_with_snapshot();
692 let backend = RefLinkBackend::new(root.clone());
693 assert!(!backend.exists(&WorkspaceId::new("nope").unwrap()));
694 }
695
696 #[test]
697 fn test_exists_true_after_create() {
698 let (_temp, root, epoch) = setup_repo_with_snapshot();
699 let backend = RefLinkBackend::new(root.clone());
700 let ws_name = WorkspaceId::new("exists-ws").unwrap();
701
702 backend.create(&ws_name, &epoch).unwrap();
703 assert!(backend.exists(&ws_name));
704 }
705
706 #[test]
707 fn test_exists_false_for_dir_without_epoch_file() {
708 let (_temp, root, _epoch) = setup_repo_with_snapshot();
709 let backend = RefLinkBackend::new(root.clone());
710 let ws_path = root.join("ws").join("incomplete");
711 fs::create_dir_all(&ws_path).unwrap();
712 let ws_name = WorkspaceId::new("incomplete").unwrap();
715 assert!(!backend.exists(&ws_name));
716 }
717
718 #[test]
721 fn test_destroy_workspace() {
722 let (_temp, root, epoch) = setup_repo_with_snapshot();
723 let backend = RefLinkBackend::new(root.clone());
724 let ws_name = WorkspaceId::new("destroy-ws").unwrap();
725
726 let info = backend.create(&ws_name, &epoch).unwrap();
727 assert!(info.path.exists());
728
729 backend.destroy(&ws_name).unwrap();
730 assert!(!info.path.exists());
731 assert!(!backend.exists(&ws_name));
732 }
733
734 #[test]
735 fn test_destroy_idempotent() {
736 let (_temp, root, epoch) = setup_repo_with_snapshot();
737 let backend = RefLinkBackend::new(root.clone());
738 let ws_name = WorkspaceId::new("idem-destroy").unwrap();
739
740 backend.create(&ws_name, &epoch).unwrap();
741 backend.destroy(&ws_name).unwrap();
742 backend.destroy(&ws_name).unwrap(); }
744
745 #[test]
746 fn test_destroy_never_existed() {
747 let (_temp, root, _epoch) = setup_repo_with_snapshot();
748 let backend = RefLinkBackend::new(root.clone());
749 let ws_name = WorkspaceId::new("no-such-ws").unwrap();
750 backend.destroy(&ws_name).unwrap(); }
752
753 #[test]
754 fn test_create_after_destroy() {
755 let (_temp, root, epoch) = setup_repo_with_snapshot();
756 let backend = RefLinkBackend::new(root.clone());
757 let ws_name = WorkspaceId::new("recreate-ws").unwrap();
758
759 backend.create(&ws_name, &epoch).unwrap();
760 backend.destroy(&ws_name).unwrap();
761 let info = backend.create(&ws_name, &epoch).unwrap();
762 assert!(info.path.exists());
763 assert!(backend.exists(&ws_name));
764 }
765
766 #[test]
769 fn test_list_empty_no_workspaces() {
770 let (_temp, root, _epoch) = setup_repo_with_snapshot();
771 let backend = RefLinkBackend::new(root.clone());
772 let infos = backend.list().unwrap();
773 assert!(infos.is_empty());
774 }
775
776 #[test]
777 fn test_list_single_workspace() {
778 let (_temp, root, epoch) = setup_repo_with_snapshot();
779 let backend = RefLinkBackend::new(root.clone());
780 let ws_name = WorkspaceId::new("list-ws").unwrap();
781
782 backend.create(&ws_name, &epoch).unwrap();
783
784 let infos = backend.list().unwrap();
785 assert_eq!(infos.len(), 1, "expected 1: {infos:?}");
786 assert_eq!(infos[0].id, ws_name);
787 assert_eq!(infos[0].epoch, epoch);
788 assert!(infos[0].state.is_active());
789 }
790
791 #[test]
792 fn test_list_multiple_workspaces() {
793 let (_temp, root, epoch) = setup_repo_with_snapshot();
794 let backend = RefLinkBackend::new(root.clone());
795
796 let a = WorkspaceId::new("alpha").unwrap();
797 let b = WorkspaceId::new("beta").unwrap();
798 backend.create(&a, &epoch).unwrap();
799 backend.create(&b, &epoch).unwrap();
800
801 let mut infos = backend.list().unwrap();
802 assert_eq!(infos.len(), 2, "expected 2: {infos:?}");
803 infos.sort_by(|x, y| x.id.as_str().cmp(y.id.as_str()));
804 assert_eq!(infos[0].id.as_str(), "alpha");
805 assert_eq!(infos[1].id.as_str(), "beta");
806 }
807
808 #[test]
809 fn test_list_excludes_destroyed_workspace() {
810 let (_temp, root, epoch) = setup_repo_with_snapshot();
811 let backend = RefLinkBackend::new(root.clone());
812 let ws_name = WorkspaceId::new("gone-ws").unwrap();
813
814 backend.create(&ws_name, &epoch).unwrap();
815 backend.destroy(&ws_name).unwrap();
816
817 let infos = backend.list().unwrap();
818 assert!(
819 infos.is_empty(),
820 "destroyed workspace must not appear: {infos:?}"
821 );
822 }
823
824 #[test]
825 fn test_list_skips_non_workspace_dirs() {
826 let (_temp, root, epoch) = setup_repo_with_snapshot();
827 let backend = RefLinkBackend::new(root.clone());
828 let ws_name = WorkspaceId::new("real-ws").unwrap();
829
830 backend.create(&ws_name, &epoch).unwrap();
831
832 fs::create_dir_all(root.join("ws").join("not-a-ws")).unwrap();
834
835 let infos = backend.list().unwrap();
836 assert_eq!(
837 infos.len(),
838 1,
839 "should skip dirs without epoch file: {infos:?}"
840 );
841 assert_eq!(infos[0].id, ws_name);
842 }
843
844 #[test]
847 fn test_snapshot_empty_no_changes() {
848 let (_temp, root, epoch) = setup_repo_with_snapshot();
849 let backend = RefLinkBackend::new(root.clone());
850 let ws_name = WorkspaceId::new("snap-clean").unwrap();
851 backend.create(&ws_name, &epoch).unwrap();
852
853 let snap = backend.snapshot(&ws_name).unwrap();
854 assert!(snap.is_empty(), "no changes expected: {snap:?}");
855 }
856
857 #[test]
858 fn test_snapshot_added_file() {
859 let (_temp, root, epoch) = setup_repo_with_snapshot();
860 let backend = RefLinkBackend::new(root.clone());
861 let ws_name = WorkspaceId::new("snap-add").unwrap();
862 let info = backend.create(&ws_name, &epoch).unwrap();
863
864 fs::write(info.path.join("newfile.txt"), "hello").unwrap();
865
866 let snap = backend.snapshot(&ws_name).unwrap();
867 assert_eq!(snap.added.len(), 1, "expected 1 added: {snap:?}");
868 assert_eq!(snap.added[0], PathBuf::from("newfile.txt"));
869 assert!(snap.modified.is_empty());
870 assert!(snap.deleted.is_empty());
871 }
872
873 #[test]
874 fn test_snapshot_modified_file() {
875 let (_temp, root, epoch) = setup_repo_with_snapshot();
876 let backend = RefLinkBackend::new(root.clone());
877 let ws_name = WorkspaceId::new("snap-mod").unwrap();
878 let info = backend.create(&ws_name, &epoch).unwrap();
879
880 fs::write(info.path.join("README.md"), "# Modified").unwrap();
881
882 let snap = backend.snapshot(&ws_name).unwrap();
883 assert!(snap.added.is_empty(), "no adds: {snap:?}");
884 assert_eq!(snap.modified.len(), 1, "expected 1 modified: {snap:?}");
885 assert_eq!(snap.modified[0], PathBuf::from("README.md"));
886 assert!(snap.deleted.is_empty());
887 }
888
889 #[test]
890 fn test_snapshot_deleted_file() {
891 let (_temp, root, epoch) = setup_repo_with_snapshot();
892 let backend = RefLinkBackend::new(root.clone());
893 let ws_name = WorkspaceId::new("snap-del").unwrap();
894 let info = backend.create(&ws_name, &epoch).unwrap();
895
896 fs::remove_file(info.path.join("README.md")).unwrap();
897
898 let snap = backend.snapshot(&ws_name).unwrap();
899 assert!(snap.added.is_empty());
900 assert!(snap.modified.is_empty());
901 assert_eq!(snap.deleted.len(), 1, "expected 1 deleted: {snap:?}");
902 assert_eq!(snap.deleted[0], PathBuf::from("README.md"));
903 }
904
905 #[test]
906 fn test_snapshot_nested_file_modified() {
907 let (_temp, root, epoch) = setup_repo_with_snapshot();
908 let backend = RefLinkBackend::new(root.clone());
909 let ws_name = WorkspaceId::new("snap-nested").unwrap();
910 let info = backend.create(&ws_name, &epoch).unwrap();
911
912 fs::write(info.path.join("src").join("lib.rs"), "pub fn changed() {}").unwrap();
913
914 let snap = backend.snapshot(&ws_name).unwrap();
915 assert!(snap.added.is_empty());
916 assert_eq!(snap.modified.len(), 1, "expected 1 modified: {snap:?}");
917 assert_eq!(snap.modified[0], PathBuf::from("src/lib.rs"));
918 assert!(snap.deleted.is_empty());
919 }
920
921 #[test]
922 fn test_snapshot_epoch_file_excluded() {
923 let (_temp, root, epoch) = setup_repo_with_snapshot();
924 let backend = RefLinkBackend::new(root.clone());
925 let ws_name = WorkspaceId::new("snap-exclude").unwrap();
926 backend.create(&ws_name, &epoch).unwrap();
927
928 let snap = backend.snapshot(&ws_name).unwrap();
930 let has_epoch_file = snap
931 .added
932 .iter()
933 .chain(snap.modified.iter())
934 .chain(snap.deleted.iter())
935 .any(|p| p.file_name().is_some_and(|n| n == EPOCH_FILE));
936 assert!(!has_epoch_file, ".maw-epoch must be excluded: {snap:?}");
937 }
938
939 #[test]
940 fn test_snapshot_nonexistent_workspace() {
941 let (_temp, root, _epoch) = setup_repo_with_snapshot();
942 let backend = RefLinkBackend::new(root.clone());
943 let ws_name = WorkspaceId::new("no-such").unwrap();
944
945 let err = backend.snapshot(&ws_name).unwrap_err();
946 assert!(
947 matches!(err, ReflinkBackendError::NotFound { .. }),
948 "expected NotFound: {err}"
949 );
950 }
951
952 #[test]
955 fn test_status_clean_workspace() {
956 let (_temp, root, epoch) = setup_repo_with_snapshot();
957 let backend = RefLinkBackend::new(root.clone());
958 let ws_name = WorkspaceId::new("status-clean").unwrap();
959 backend.create(&ws_name, &epoch).unwrap();
960
961 let status = backend.status(&ws_name).unwrap();
962 assert_eq!(status.base_epoch, epoch);
963 assert!(
964 status.is_clean(),
965 "expected clean: {:?}",
966 status.dirty_files
967 );
968 assert!(!status.is_stale);
969 }
970
971 #[test]
972 fn test_status_modified_file() {
973 let (_temp, root, epoch) = setup_repo_with_snapshot();
974 let backend = RefLinkBackend::new(root.clone());
975 let ws_name = WorkspaceId::new("status-mod").unwrap();
976 let info = backend.create(&ws_name, &epoch).unwrap();
977
978 fs::write(info.path.join("README.md"), "# Modified").unwrap();
979
980 let status = backend.status(&ws_name).unwrap();
981 assert_eq!(status.dirty_count(), 1);
982 assert!(
983 status
984 .dirty_files
985 .iter()
986 .any(|p| p == &PathBuf::from("README.md")),
987 "expected README.md dirty: {:?}",
988 status.dirty_files
989 );
990 }
991
992 #[test]
993 fn test_status_nonexistent_workspace() {
994 let (_temp, root, _epoch) = setup_repo_with_snapshot();
995 let backend = RefLinkBackend::new(root.clone());
996 let ws_name = WorkspaceId::new("no-such").unwrap();
997
998 let err = backend.status(&ws_name).unwrap_err();
999 assert!(
1000 matches!(err, ReflinkBackendError::NotFound { .. }),
1001 "expected NotFound: {err}"
1002 );
1003 }
1004
1005 #[test]
1008 fn test_workspace_path() {
1009 let (_temp, root, _epoch) = setup_repo_with_snapshot();
1010 let backend = RefLinkBackend::new(root.clone());
1011 let ws_name = WorkspaceId::new("path-test").unwrap();
1012 assert_eq!(backend.workspace_path(&ws_name), root.join("ws/path-test"));
1013 }
1014
1015 #[test]
1018 fn test_diff_dirs_identical() {
1019 let temp = TempDir::new().unwrap();
1020 let base = temp.path().join("base");
1021 let ws = temp.path().join("ws");
1022 fs::create_dir_all(&base).unwrap();
1023 fs::create_dir_all(&ws).unwrap();
1024 fs::write(base.join("file.txt"), "hello").unwrap();
1025 fs::write(ws.join("file.txt"), "hello").unwrap();
1026
1027 let snap = diff_dirs(&base, &ws);
1028 assert!(snap.is_empty());
1029 }
1030
1031 #[test]
1032 fn test_diff_dirs_added() {
1033 let temp = TempDir::new().unwrap();
1034 let base = temp.path().join("base");
1035 let ws = temp.path().join("ws");
1036 fs::create_dir_all(&base).unwrap();
1037 fs::create_dir_all(&ws).unwrap();
1038 fs::write(ws.join("new.txt"), "new").unwrap();
1039
1040 let snap = diff_dirs(&base, &ws);
1041 assert_eq!(snap.added, vec![PathBuf::from("new.txt")]);
1042 assert!(snap.modified.is_empty());
1043 assert!(snap.deleted.is_empty());
1044 }
1045
1046 #[test]
1047 fn test_diff_dirs_modified() {
1048 let temp = TempDir::new().unwrap();
1049 let base = temp.path().join("base");
1050 let ws = temp.path().join("ws");
1051 fs::create_dir_all(&base).unwrap();
1052 fs::create_dir_all(&ws).unwrap();
1053 fs::write(base.join("file.txt"), "original").unwrap();
1054 fs::write(ws.join("file.txt"), "changed").unwrap();
1055
1056 let snap = diff_dirs(&base, &ws);
1057 assert!(snap.added.is_empty());
1058 assert_eq!(snap.modified, vec![PathBuf::from("file.txt")]);
1059 assert!(snap.deleted.is_empty());
1060 }
1061
1062 #[test]
1063 fn test_diff_dirs_deleted() {
1064 let temp = TempDir::new().unwrap();
1065 let base = temp.path().join("base");
1066 let ws = temp.path().join("ws");
1067 fs::create_dir_all(&base).unwrap();
1068 fs::create_dir_all(&ws).unwrap();
1069 fs::write(base.join("old.txt"), "old").unwrap();
1070
1071 let snap = diff_dirs(&base, &ws);
1072 assert!(snap.added.is_empty());
1073 assert!(snap.modified.is_empty());
1074 assert_eq!(snap.deleted, vec![PathBuf::from("old.txt")]);
1075 }
1076
1077 #[test]
1078 fn test_diff_dirs_missing_base() {
1079 let temp = TempDir::new().unwrap();
1081 let base = temp.path().join("nonexistent-base");
1082 let ws = temp.path().join("ws");
1083 fs::create_dir_all(&ws).unwrap();
1084 fs::write(ws.join("file.txt"), "hello").unwrap();
1085
1086 let snap = diff_dirs(&base, &ws);
1087 assert_eq!(snap.added, vec![PathBuf::from("file.txt")]);
1088 assert!(snap.modified.is_empty());
1089 assert!(snap.deleted.is_empty());
1090 }
1091
1092 #[test]
1093 fn test_diff_dirs_excludes_epoch_file() {
1094 let temp = TempDir::new().unwrap();
1095 let base = temp.path().join("base");
1096 let ws = temp.path().join("ws");
1097 fs::create_dir_all(&base).unwrap();
1098 fs::create_dir_all(&ws).unwrap();
1099 fs::write(ws.join(EPOCH_FILE), "a".repeat(40) + "\n").unwrap();
1101
1102 let snap = diff_dirs(&base, &ws);
1103 assert!(
1105 snap.is_empty(),
1106 ".maw-epoch must be excluded from diff: {snap:?}"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_recursive_copy_fallback() {
1112 let temp = TempDir::new().unwrap();
1113 let src = temp.path().join("src");
1114 let dst = temp.path().join("dst");
1115 fs::create_dir_all(src.join("subdir")).unwrap();
1116 fs::write(src.join("file.txt"), "hello").unwrap();
1117 fs::write(src.join("subdir").join("nested.txt"), "nested").unwrap();
1118
1119 RefLinkBackend::recursive_copy(&src, &dst).unwrap();
1120
1121 assert!(dst.join("file.txt").exists());
1122 assert!(dst.join("subdir").join("nested.txt").exists());
1123 assert_eq!(fs::read_to_string(dst.join("file.txt")).unwrap(), "hello");
1124 assert_eq!(
1125 fs::read_to_string(dst.join("subdir").join("nested.txt")).unwrap(),
1126 "nested"
1127 );
1128 }
1129}