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}