Skip to main content

maw/backend/
mod.rs

1//! Workspace backend trait and common types.
2//!
3//! Defines the interface that all workspace backends must implement.
4//! This is the API contract between maw's CLI layer and the underlying
5//! isolation mechanism (git worktrees, copy-on-write snapshots, or other backends).
6
7pub mod copy;
8pub mod git;
9pub mod reflink;
10
11use std::path::PathBuf;
12
13use crate::model::types::{EpochId, WorkspaceId, WorkspaceInfo};
14
15/// A workspace backend implementation.
16///
17/// The `WorkspaceBackend` trait defines the interface for creating, managing,
18/// and querying workspaces. Implementations of this trait are responsible for
19/// the actual isolation mechanism (e.g., git worktrees, reflinks, overlays).
20///
21/// # Key Invariants
22///
23/// - **Workspace isolation**: Each workspace's working copy is independent.
24///   Changes in one workspace don't affect others until explicitly merged.
25/// - **Workspace uniqueness**: No two active workspaces can have the same name
26///   within a given repository.
27/// - **Epoch tracking**: Each workspace is anchored to an epoch (a specific
28///   repository state). Workspaces can become stale if the repository advances.
29#[allow(clippy::missing_errors_doc)]
30pub trait WorkspaceBackend {
31    /// The error type returned by backend operations.
32    type Error: std::error::Error + Send + Sync + 'static;
33
34    /// Create a new workspace.
35    ///
36    /// Creates a new workspace with the given name, anchored to the provided epoch.
37    /// The workspace is initialized with a clean working copy at that epoch.
38    ///
39    /// # Arguments
40    /// * `name` - Unique workspace identifier (must be a valid [`WorkspaceId`])
41    /// * `epoch` - The repository state this workspace is based on
42    ///
43    /// # Returns
44    /// Complete information about the newly created workspace, including its
45    /// path and initial state.
46    ///
47    /// # Invariants
48    /// - The returned `WorkspaceInfo` has state [`WorkspaceState::Active`]
49    /// - The workspace directory exists and is ready for use
50    /// - No workspace with the same name exists before the call
51    /// - The workspace is isolated from all other workspaces
52    fn create(&self, name: &WorkspaceId, epoch: &EpochId) -> Result<WorkspaceInfo, Self::Error>;
53
54    /// Destroy a workspace.
55    ///
56    /// Removes the workspace from the system. The workspace directory and all
57    /// its contents are deleted. The workspace becomes unavailable for future
58    /// operations.
59    ///
60    /// # Arguments
61    /// * `name` - Identifier of the workspace to destroy
62    ///
63    /// # Invariants
64    /// - The workspace directory is fully removed
65    /// - The workspace can no longer be accessed via any backend method
66    /// - Destroying a non-existent workspace is a no-op (idempotent)
67    fn destroy(&self, name: &WorkspaceId) -> Result<(), Self::Error>;
68
69    /// List all workspaces.
70    ///
71    /// Returns information about all active workspaces in the repository.
72    /// Does not include destroyed workspaces.
73    ///
74    /// # Returns
75    /// A vector of [`WorkspaceInfo`] for all active workspaces,
76    /// or empty vector if no workspaces exist.
77    ///
78    /// # Invariants
79    /// - Only active workspaces are included
80    /// - Order is consistent but unspecified
81    fn list(&self) -> Result<Vec<WorkspaceInfo>, Self::Error>;
82
83    /// Get the current status of a workspace.
84    ///
85    /// Returns detailed information about the workspace's current state,
86    /// including its epoch, dirty files, and staleness.
87    ///
88    /// # Arguments
89    /// * `name` - Identifier of the workspace to query
90    ///
91    /// # Invariants
92    /// - The returned status reflects the workspace's current state
93    /// - For a stale workspace, `is_stale` is `true` and `behind_epochs`
94    ///   indicates how many epochs the workspace is behind
95    /// - For a destroyed workspace, returns an error (not a status)
96    fn status(&self, name: &WorkspaceId) -> Result<WorkspaceStatus, Self::Error>;
97
98    /// Capture all changes in the workspace.
99    ///
100    /// Scans the workspace for all modified, added, and deleted files.
101    /// Returns a snapshot of changes that can be committed or discarded.
102    ///
103    /// # Arguments
104    /// * `name` - Identifier of the workspace to snapshot
105    ///
106    /// # Returns
107    /// A [`SnapshotResult`] containing the list of changed paths and their
108    /// change kinds (add, modify, delete).
109    ///
110    /// # Invariants
111    /// - Only working copy changes are included; committed changes are not
112    /// - All reported paths are relative to the workspace root
113    /// - The snapshot is point-in-time; changes made after the snapshot are not included
114    fn snapshot(&self, name: &WorkspaceId) -> Result<SnapshotResult, Self::Error>;
115
116    /// Get the absolute path to a workspace.
117    ///
118    /// Returns the absolute filesystem path where the workspace's files are stored.
119    /// Does not verify that the workspace exists.
120    ///
121    /// # Arguments
122    /// * `name` - Identifier of the workspace
123    ///
124    /// # Returns
125    /// An absolute [`PathBuf`] to the workspace root directory.
126    ///
127    /// # Invariants
128    /// - The path is absolute (not relative)
129    /// - The path is consistent: repeated calls return equal paths
130    /// - The path may not exist if the workspace has been destroyed
131    fn workspace_path(&self, name: &WorkspaceId) -> PathBuf;
132
133    /// Check if a workspace exists.
134    ///
135    /// Returns `true` if a workspace with the given name exists and is active,
136    /// `false` otherwise.
137    ///
138    /// # Arguments
139    /// * `name` - Identifier of the workspace
140    ///
141    /// # Invariants
142    /// - Returns `true` only if the workspace is active and accessible
143    /// - Destroyed or non-existent workspaces return `false`
144    /// - This is a lightweight check; no I/O is guaranteed
145    fn exists(&self, name: &WorkspaceId) -> bool;
146}
147
148/// Detailed status information about a workspace.
149///
150/// Captures the current state of a workspace, including its epoch,
151/// whether it is stale, and which files have been modified.
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct WorkspaceStatus {
154    /// The epoch this workspace is based on.
155    pub base_epoch: EpochId,
156    /// Paths to all dirty (modified) files in the working copy,
157    /// relative to the workspace root.
158    pub dirty_files: Vec<PathBuf>,
159    /// Whether this workspace is stale (behind the current repository epoch).
160    pub is_stale: bool,
161}
162
163impl WorkspaceStatus {
164    /// Create a new workspace status.
165    ///
166    /// # Arguments
167    /// * `base_epoch` - The epoch this workspace is based on
168    /// * `dirty_files` - List of modified file paths (relative to workspace root)
169    /// * `is_stale` - Whether the workspace is behind the current epoch
170    #[must_use]
171    pub const fn new(base_epoch: EpochId, dirty_files: Vec<PathBuf>, is_stale: bool) -> Self {
172        Self {
173            base_epoch,
174            dirty_files,
175            is_stale,
176        }
177    }
178
179    /// Returns `true` if there are no dirty files.
180    #[must_use]
181    #[allow(dead_code)]
182    pub const fn is_clean(&self) -> bool {
183        self.dirty_files.is_empty()
184    }
185
186    /// Returns the number of dirty files.
187    #[must_use]
188    #[allow(dead_code)]
189    pub const fn dirty_count(&self) -> usize {
190        self.dirty_files.len()
191    }
192}
193
194/// The result of a workspace snapshot operation.
195///
196/// Contains all changes detected in a workspace's working copy,
197/// categorized by type (added, modified, deleted).
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct SnapshotResult {
200    /// Added files (relative to workspace root).
201    pub added: Vec<PathBuf>,
202    /// Modified files (relative to workspace root).
203    pub modified: Vec<PathBuf>,
204    /// Deleted files (relative to workspace root).
205    pub deleted: Vec<PathBuf>,
206}
207
208impl SnapshotResult {
209    /// Create a new snapshot result with the given changes.
210    ///
211    /// # Arguments
212    /// * `added` - Paths to files that were added
213    /// * `modified` - Paths to files that were modified
214    /// * `deleted` - Paths to files that were deleted
215    #[must_use]
216    pub const fn new(added: Vec<PathBuf>, modified: Vec<PathBuf>, deleted: Vec<PathBuf>) -> Self {
217        Self {
218            added,
219            modified,
220            deleted,
221        }
222    }
223
224    /// All changed files (added + modified + deleted).
225    #[must_use]
226    pub fn all_changed(&self) -> Vec<&PathBuf> {
227        self.added
228            .iter()
229            .chain(self.modified.iter())
230            .chain(self.deleted.iter())
231            .collect()
232    }
233
234    /// Total count of all changes.
235    #[must_use]
236    pub const fn change_count(&self) -> usize {
237        self.added.len() + self.modified.len() + self.deleted.len()
238    }
239
240    /// Returns `true` if there are no changes.
241    #[must_use]
242    pub const fn is_empty(&self) -> bool {
243        self.change_count() == 0
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn workspace_status_is_clean() {
253        let status = WorkspaceStatus::new(EpochId::new(&"a".repeat(40)).unwrap(), vec![], false);
254        assert!(status.is_clean());
255        assert_eq!(status.dirty_count(), 0);
256    }
257
258    #[test]
259    fn workspace_status_dirty() {
260        let dirty_files = vec![PathBuf::from("file1.rs"), PathBuf::from("file2.rs")];
261        let status = WorkspaceStatus::new(
262            EpochId::new(&"b".repeat(40)).unwrap(),
263            dirty_files.clone(),
264            false,
265        );
266        assert!(!status.is_clean());
267        assert_eq!(status.dirty_count(), 2);
268        assert_eq!(status.dirty_files, dirty_files);
269    }
270
271    #[test]
272    fn workspace_status_stale() {
273        let status = WorkspaceStatus::new(EpochId::new(&"c".repeat(40)).unwrap(), vec![], true);
274        assert!(status.is_stale);
275        assert!(status.is_clean());
276    }
277
278    #[test]
279    fn snapshot_result_empty() {
280        let snapshot = SnapshotResult::new(vec![], vec![], vec![]);
281        assert!(snapshot.is_empty());
282        assert_eq!(snapshot.change_count(), 0);
283        assert!(snapshot.all_changed().is_empty());
284    }
285
286    #[test]
287    fn snapshot_result_added() {
288        let added = vec![PathBuf::from("src/main.rs"), PathBuf::from("Cargo.toml")];
289        let snapshot = SnapshotResult::new(added.clone(), vec![], vec![]);
290        assert!(!snapshot.is_empty());
291        assert_eq!(snapshot.change_count(), 2);
292        assert_eq!(snapshot.added, added);
293        assert!(snapshot.modified.is_empty());
294        assert!(snapshot.deleted.is_empty());
295    }
296
297    #[test]
298    fn snapshot_result_modified() {
299        let modified = vec![PathBuf::from("src/lib.rs")];
300        let snapshot = SnapshotResult::new(vec![], modified.clone(), vec![]);
301        assert!(!snapshot.is_empty());
302        assert_eq!(snapshot.change_count(), 1);
303        assert_eq!(snapshot.modified, modified);
304    }
305
306    #[test]
307    fn snapshot_result_deleted() {
308        let deleted = vec![PathBuf::from("old_file.rs")];
309        let snapshot = SnapshotResult::new(vec![], vec![], deleted.clone());
310        assert!(!snapshot.is_empty());
311        assert_eq!(snapshot.change_count(), 1);
312        assert_eq!(snapshot.deleted, deleted);
313    }
314
315    #[test]
316    fn snapshot_result_mixed() {
317        let added = vec![PathBuf::from("new.rs")];
318        let modified = vec![PathBuf::from("src/main.rs")];
319        let deleted = vec![PathBuf::from("deprecated.rs")];
320        let snapshot = SnapshotResult::new(added, modified, deleted);
321        assert!(!snapshot.is_empty());
322        assert_eq!(snapshot.change_count(), 3);
323
324        let all = snapshot.all_changed();
325        assert_eq!(all.len(), 3);
326        assert!(all.contains(&&PathBuf::from("new.rs")));
327        assert!(all.contains(&&PathBuf::from("src/main.rs")));
328        assert!(all.contains(&&PathBuf::from("deprecated.rs")));
329    }
330}
331pub mod overlay;
332pub mod platform;
333
334// ---------------------------------------------------------------------------
335// AnyBackend — polymorphic backend enum
336// ---------------------------------------------------------------------------
337
338use copy::CopyBackend;
339use git::GitWorktreeBackend;
340use overlay::OverlayBackend;
341use reflink::RefLinkBackend;
342
343use crate::config::BackendKind;
344
345// ---------------------------------------------------------------------------
346// AnyBackendError
347// ---------------------------------------------------------------------------
348
349/// Error type for [`AnyBackend`] — boxes the underlying backend error.
350#[derive(Debug)]
351pub struct AnyBackendError(pub Box<dyn std::error::Error + Send + Sync + 'static>);
352
353impl std::fmt::Display for AnyBackendError {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        self.0.fmt(f)
356    }
357}
358
359impl std::error::Error for AnyBackendError {
360    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
361        self.0.source()
362    }
363}
364
365// ---------------------------------------------------------------------------
366// AnyBackend
367// ---------------------------------------------------------------------------
368
369/// A concrete backend selected at runtime based on platform capabilities and
370/// configuration. Dispatches to the appropriate implementation.
371///
372/// Using an enum (rather than `Box<dyn WorkspaceBackend>`) avoids dynamic
373/// dispatch and keeps the `Error` associated type monomorphic.
374pub enum AnyBackend {
375    /// Git worktree backend — always available.
376    GitWorktree(GitWorktreeBackend),
377    /// Reflink (`CoW`) backend — requires a CoW-capable filesystem.
378    Reflink(RefLinkBackend),
379    /// `OverlayFS` backend — Linux only.
380    Overlay(OverlayBackend),
381    /// Plain recursive-copy backend — universal fallback.
382    Copy(CopyBackend),
383}
384
385impl AnyBackend {
386    /// Construct the appropriate backend for the resolved (non-Auto) kind and repo root.
387    ///
388    /// If `kind` is `BackendKind::Auto` (which should be resolved before calling
389    /// this function), falls back to `GitWorktree`.
390    ///
391    /// # Errors
392    /// Returns an error if the overlay backend is selected but is not supported
393    /// on this platform (not Linux, or no mount strategy available).
394    pub fn from_kind(kind: BackendKind, root: PathBuf) -> anyhow::Result<Self> {
395        match kind {
396            BackendKind::GitWorktree | BackendKind::Auto => {
397                Ok(Self::GitWorktree(GitWorktreeBackend::new(root)))
398            }
399            BackendKind::Reflink => Ok(Self::Reflink(RefLinkBackend::new(root))),
400            BackendKind::Overlay => {
401                let backend = OverlayBackend::new(root).map_err(|e| anyhow::anyhow!("{e}"))?;
402                Ok(Self::Overlay(backend))
403            }
404            BackendKind::Copy => Ok(Self::Copy(CopyBackend::new(root))),
405        }
406    }
407}
408
409/// Helper: convert a backend-specific error into [`AnyBackendError`].
410fn wrap_err<E>(e: E) -> AnyBackendError
411where
412    E: std::error::Error + Send + Sync + 'static,
413{
414    AnyBackendError(Box::new(e))
415}
416
417impl WorkspaceBackend for AnyBackend {
418    type Error = AnyBackendError;
419
420    fn create(&self, name: &WorkspaceId, epoch: &EpochId) -> Result<WorkspaceInfo, Self::Error> {
421        match self {
422            Self::GitWorktree(b) => b.create(name, epoch).map_err(wrap_err),
423            Self::Reflink(b) => b.create(name, epoch).map_err(wrap_err),
424            Self::Overlay(b) => b.create(name, epoch).map_err(wrap_err),
425            Self::Copy(b) => b.create(name, epoch).map_err(wrap_err),
426        }
427    }
428
429    fn destroy(&self, name: &WorkspaceId) -> Result<(), Self::Error> {
430        match self {
431            Self::GitWorktree(b) => b.destroy(name).map_err(wrap_err),
432            Self::Reflink(b) => b.destroy(name).map_err(wrap_err),
433            Self::Overlay(b) => b.destroy(name).map_err(wrap_err),
434            Self::Copy(b) => b.destroy(name).map_err(wrap_err),
435        }
436    }
437
438    fn list(&self) -> Result<Vec<WorkspaceInfo>, Self::Error> {
439        match self {
440            Self::GitWorktree(b) => b.list().map_err(wrap_err),
441            Self::Reflink(b) => b.list().map_err(wrap_err),
442            Self::Overlay(b) => b.list().map_err(wrap_err),
443            Self::Copy(b) => b.list().map_err(wrap_err),
444        }
445    }
446
447    fn status(&self, name: &WorkspaceId) -> Result<WorkspaceStatus, Self::Error> {
448        match self {
449            Self::GitWorktree(b) => b.status(name).map_err(wrap_err),
450            Self::Reflink(b) => b.status(name).map_err(wrap_err),
451            Self::Overlay(b) => b.status(name).map_err(wrap_err),
452            Self::Copy(b) => b.status(name).map_err(wrap_err),
453        }
454    }
455
456    fn snapshot(&self, name: &WorkspaceId) -> Result<SnapshotResult, Self::Error> {
457        match self {
458            Self::GitWorktree(b) => b.snapshot(name).map_err(wrap_err),
459            Self::Reflink(b) => b.snapshot(name).map_err(wrap_err),
460            Self::Overlay(b) => b.snapshot(name).map_err(wrap_err),
461            Self::Copy(b) => b.snapshot(name).map_err(wrap_err),
462        }
463    }
464
465    fn workspace_path(&self, name: &WorkspaceId) -> PathBuf {
466        match self {
467            Self::GitWorktree(b) => b.workspace_path(name),
468            Self::Reflink(b) => b.workspace_path(name),
469            Self::Overlay(b) => b.workspace_path(name),
470            Self::Copy(b) => b.workspace_path(name),
471        }
472    }
473
474    fn exists(&self, name: &WorkspaceId) -> bool {
475        match self {
476            Self::GitWorktree(b) => b.exists(name),
477            Self::Reflink(b) => b.exists(name),
478            Self::Overlay(b) => b.exists(name),
479            Self::Copy(b) => b.exists(name),
480        }
481    }
482}