Skip to main content

maw/backend/
reflink.rs

1//! Reflink (copy-on-write) workspace backend.
2//!
3//! Implements [`WorkspaceBackend`] using `cp --reflink=auto` to create
4//! workspaces from immutable epoch snapshot directories. On Btrfs, XFS, and
5//! APFS (with the appropriate `cp` from GNU coreutils or macOS), the copy is
6//! nearly instant because disk blocks are shared until modified.
7//!
8//! # Directory layout
9//!
10//! ```text
11//! repo-root/
12//! ├── .manifold/
13//! │   └── epochs/
14//! │       └── e-{hash}/   ← immutable epoch snapshot (source for reflinks)
15//! └── ws/
16//!     └── <name>/         ← workspace (reflink copy of epoch snapshot)
17//!         └── .maw-epoch  ← stores the base epoch OID (40 hex chars + newline)
18//! ```
19//!
20//! # Fallback behaviour
21//!
22//! If `cp --reflink=always` fails (non-CoW filesystem), `create` retries with
23//! `cp --reflink=auto` which silently falls back to a regular copy. This means
24//! the backend works on any filesystem — it just isn't instant on non-CoW ones.
25
26use 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
34// ---------------------------------------------------------------------------
35// Constants
36// ---------------------------------------------------------------------------
37
38/// Hidden metadata file written into each workspace root.
39///
40/// Contains the base epoch OID (exactly 40 lowercase hex characters) followed
41/// by a newline. This file is excluded from snapshot comparisons.
42const EPOCH_FILE: &str = ".maw-epoch";
43
44// ---------------------------------------------------------------------------
45// Error type
46// ---------------------------------------------------------------------------
47
48/// Errors from the reflink workspace backend.
49#[derive(Debug)]
50pub enum ReflinkBackendError {
51    /// An I/O error occurred.
52    Io(std::io::Error),
53    /// A subprocess (e.g. `cp`) failed.
54    Command {
55        command: String,
56        stderr: String,
57        exit_code: Option<i32>,
58    },
59    /// Workspace not found.
60    NotFound { name: String },
61    /// The epoch snapshot directory does not exist.
62    EpochSnapshotMissing { epoch: String },
63    /// The workspace is missing the `.maw-epoch` metadata file.
64    MissingEpochFile { workspace: String },
65    /// The epoch ID stored in `.maw-epoch` is malformed.
66    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
127// ---------------------------------------------------------------------------
128// RefLinkBackend
129// ---------------------------------------------------------------------------
130
131/// A workspace backend that uses reflink (`CoW`) copies of epoch snapshots.
132///
133/// Each workspace is a `cp --reflink=auto` copy of the immutable epoch
134/// snapshot directory located at `.manifold/epochs/e-{epoch_hash}/`.
135///
136/// # Thread safety
137///
138/// `RefLinkBackend` is `Send + Sync`. All state is derived from the
139/// filesystem; no interior mutability is used.
140pub struct RefLinkBackend {
141    /// Absolute path to the repository root (contains `.git/`, `ws/`, `.manifold/`).
142    root: PathBuf,
143}
144
145impl RefLinkBackend {
146    /// Create a new `RefLinkBackend` rooted at `root`.
147    ///
148    /// `root` must be the repository root — the directory that contains `.git/`
149    /// and `.manifold/`.
150    #[must_use]
151    pub const fn new(root: PathBuf) -> Self {
152        Self { root }
153    }
154
155    // -----------------------------------------------------------------------
156    // Private helpers
157    // -----------------------------------------------------------------------
158
159    /// `ws/` directory under the repo root.
160    fn workspaces_dir(&self) -> PathBuf {
161        self.root.join("ws")
162    }
163
164    /// Path to the epoch snapshot directory for a given epoch.
165    ///
166    /// e.g. `/repo/.manifold/epochs/e-abc123.../`
167    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    /// Read the base epoch from a workspace's `.maw-epoch` file.
175    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    /// Write the base epoch to a workspace's `.maw-epoch` file.
191    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    /// Read `refs/manifold/epoch/current` from git.
199    ///
200    /// Returns `None` if the ref does not exist (Manifold not yet initialized).
201    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    /// Copy `src` into `dst` using `cp --reflink=auto -r`.
218    ///
219    /// On `CoW` filesystems (Btrfs, XFS, APFS) this is nearly instant.
220    /// Falls back silently to a regular copy on non-CoW filesystems.
221    ///
222    /// `src` must be an existing directory. `dst` must not already exist.
223    fn reflink_copy(src: &Path, dst: &Path) -> Result<(), ReflinkBackendError> {
224        // First try --reflink=auto (most portable: GNU coreutils / macOS `cp`)
225        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 --reflink=auto fails (e.g., option not recognised on some
238                // systems), fall through to the portable recursive copy below.
239                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(_) => {} // cp not found — fall through to Rust fs copy
252        }
253
254        // Fallback: portable recursive copy via Rust std::fs
255        Self::recursive_copy(src, dst)
256    }
257
258    /// Portable recursive directory copy using `std::fs`.
259    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                    // Best-effort on non-Unix: copy the file instead
275                    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
285// ---------------------------------------------------------------------------
286// WorkspaceBackend impl
287// ---------------------------------------------------------------------------
288
289impl WorkspaceBackend for RefLinkBackend {
290    type Error = ReflinkBackendError;
291
292    /// Create a workspace by reflinking the epoch snapshot.
293    ///
294    /// Steps:
295    /// 1. Verify the epoch snapshot exists at `.manifold/epochs/e-{hash}/`.
296    /// 2. Copy it to `ws/<name>/` using `cp --reflink=auto -r`.
297    /// 3. Write the epoch OID to `ws/<name>/.maw-epoch`.
298    ///
299    /// If a valid workspace already exists (idempotency), returns its info.
300    fn create(&self, name: &WorkspaceId, epoch: &EpochId) -> Result<WorkspaceInfo, Self::Error> {
301        let ws_path = self.workspace_path(name);
302
303        // Idempotency: if workspace already exists with correct epoch, return it.
304        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            // Partial/mismatched workspace: remove and recreate.
318            std::fs::remove_dir_all(&ws_path)?;
319        }
320
321        // Verify the epoch snapshot exists.
322        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        // Ensure the ws/ parent directory exists.
330        let ws_dir = self.workspaces_dir();
331        std::fs::create_dir_all(&ws_dir)?;
332
333        // Reflink-copy the snapshot into the workspace directory.
334        Self::reflink_copy(&snapshot_path, &ws_path)?;
335
336        // Write the base epoch identifier into the workspace.
337        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    /// Destroy a workspace by removing its directory.
350    ///
351    /// Idempotent: destroying a non-existent workspace is a no-op.
352    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    /// List all active workspaces by scanning `ws/`.
361    ///
362    /// A directory under `ws/` is considered a workspace if:
363    /// - Its name is a valid `WorkspaceId`.
364    /// - It contains a readable `.maw-epoch` file with a valid epoch OID.
365    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; // Skip directories with non-conforming names
386            };
387
388            let Ok(epoch) = Self::read_epoch_file(&path, &name_str) else {
389                continue; // Not a valid workspace (no metadata file)
390            };
391
392            let state = match &current_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    /// Get the current status of a workspace.
412    ///
413    /// Reports the base epoch, dirty files (by comparing workspace against the
414    /// epoch snapshot), and whether the workspace is stale.
415    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    /// Snapshot (diff) the workspace against its base epoch snapshot.
445    ///
446    /// Compares the workspace directory tree against the immutable epoch
447    /// snapshot at `.manifold/epochs/e-{hash}/`.
448    ///
449    /// Files excluded from comparison:
450    /// - `.maw-epoch` (backend metadata)
451    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
475// ---------------------------------------------------------------------------
476// Directory diff
477// ---------------------------------------------------------------------------
478
479/// Names excluded from workspace snapshots.
480///
481/// These are backend-internal metadata files that should never appear in the
482/// diff output, even though they live inside the workspace directory.
483const EXCLUDED_NAMES: &[&str] = &[EPOCH_FILE];
484
485/// Diff two directory trees.
486///
487/// `base_dir` is the immutable epoch snapshot. `ws_dir` is the workspace.
488/// Returns a `SnapshotResult` with paths relative to `ws_dir`.
489///
490/// If `base_dir` does not exist (epoch snapshot missing or not yet created),
491/// all files in `ws_dir` are treated as additions.
492fn diff_dirs(base_dir: &Path, ws_dir: &Path) -> SnapshotResult {
493    // Collect all files in the base snapshot (relative paths).
494    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    // Collect all files in the workspace (relative paths), excluding metadata.
501    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    // Files in workspace: added or modified
508    for rel in &ws_files {
509        if base_files.contains(rel) {
510            // Both exist — compare content
511            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    // Files in base but not workspace: deleted
522    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
535/// Recursively collect relative file paths under `root`.
536///
537/// Skips directories and any top-level entries whose names appear in
538/// `exclude_names`.
539fn 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        // Skip excluded entries (checked against unqualified filename only)
555        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            // Relative path from root
563            if let Ok(rel) = path.strip_prefix(root) {
564                files.push(rel.to_path_buf());
565            }
566        }
567        // Symlinks: count as files (strip_prefix will work because is_file()
568        // follows symlinks). If the symlink target is a directory, is_dir()
569        // returns true and we recurse. This matches common expectations.
570    }
571}
572
573/// Return `true` if both files have identical byte content.
574///
575/// Returns `false` on any I/O error (treat as different).
576fn 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// ---------------------------------------------------------------------------
584// Tests
585// ---------------------------------------------------------------------------
586
587#[cfg(test)]
588#[allow(clippy::redundant_clone)]
589mod tests {
590    use super::*;
591    use std::fs;
592    use tempfile::TempDir;
593
594    /// Helper: set up a temporary directory with a fake epoch snapshot.
595    ///
596    /// Returns the temp dir, the repo root path, and the epoch ID.
597    fn setup_repo_with_snapshot() -> (TempDir, PathBuf, EpochId) {
598        let temp = TempDir::new().unwrap();
599        let root = temp.path().to_path_buf();
600
601        // Fake 40-char hex epoch OID
602        let epoch_oid = "a".repeat(40);
603        let epoch = EpochId::new(&epoch_oid).unwrap();
604
605        // Create epoch snapshot directory with some files
606        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    // -- create tests --
620
621    #[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        // Workspace contains snapshot files
632        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        // Epoch file written
636        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        // Create workspace with wrong epoch file
658        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        // Create should replace with correct epoch
664        let info = backend.create(&ws_name, &epoch).unwrap();
665        assert_eq!(info.epoch, epoch);
666        // Old content removed
667        assert!(!ws_path.join("stale.txt").exists());
668        // Snapshot content present
669        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        // Use an epoch that has no snapshot
679        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    // -- exists tests --
688
689    #[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        // No .maw-epoch file → not a valid workspace
713
714        let ws_name = WorkspaceId::new("incomplete").unwrap();
715        assert!(!backend.exists(&ws_name));
716    }
717
718    // -- destroy tests --
719
720    #[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(); // second call is a no-op
743    }
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(); // should not error
751    }
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    // -- list tests --
767
768    #[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        // Create a directory with no .maw-epoch (not a workspace)
833        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    // -- snapshot tests --
845
846    #[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        // The .maw-epoch file must not appear in the snapshot
929        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    // -- status tests --
953
954    #[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    // -- workspace_path tests --
1006
1007    #[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    // -- diff_dirs tests --
1016
1017    #[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        // If the epoch snapshot doesn't exist, all workspace files are "added"
1080        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        // .maw-epoch only in ws (as it would be after create)
1100        fs::write(ws.join(EPOCH_FILE), "a".repeat(40) + "\n").unwrap();
1101
1102        let snap = diff_dirs(&base, &ws);
1103        // .maw-epoch is excluded, so snap should be empty
1104        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}