Skip to main content

maw/backend/
platform.rs

1//! Platform capability detection for workspace backend selection.
2//!
3//! Detects runtime capabilities needed by `CoW` backends and caches the result
4//! in `.manifold/platform-capabilities`.
5
6use std::ffi::OsStr;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config::BackendKind;
13
14const CACHE_SCHEMA_VERSION: u32 = 1;
15const REF_LINK_THRESHOLD_FILES: usize = 30_000;
16const OVERLAY_THRESHOLD_FILES: usize = 100_000;
17
18/// Detected host/platform capabilities for workspace backend selection.
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20pub struct PlatformCapabilities {
21    /// Cache schema version for future migrations.
22    pub schema_version: u32,
23    /// Runtime reflink capability (best-effort test via `cp --reflink=always`).
24    pub reflink_supported: bool,
25    /// Runtime `OverlayFS` in user namespace capability.
26    pub overlay_userns_supported: bool,
27    /// `fuse-overlayfs` binary availability on compatible Linux kernels.
28    pub fuse_overlayfs_available: bool,
29    /// Parsed kernel major.minor (Linux only).
30    pub kernel_major: Option<u32>,
31    pub kernel_minor: Option<u32>,
32}
33
34impl Default for PlatformCapabilities {
35    fn default() -> Self {
36        Self {
37            schema_version: CACHE_SCHEMA_VERSION,
38            reflink_supported: false,
39            overlay_userns_supported: false,
40            fuse_overlayfs_available: false,
41            kernel_major: None,
42            kernel_minor: None,
43        }
44    }
45}
46
47/// Resolve path for platform capability cache.
48#[must_use]
49pub fn cache_path(repo_root: &Path) -> PathBuf {
50    repo_root.join(".manifold").join("platform-capabilities")
51}
52
53/// Load cached capabilities if present and valid.
54#[must_use]
55pub fn load_cached(repo_root: &Path) -> Option<PlatformCapabilities> {
56    let path = cache_path(repo_root);
57    let bytes = std::fs::read(path).ok()?;
58    let caps = serde_json::from_slice::<PlatformCapabilities>(&bytes).ok()?;
59    if caps.schema_version == CACHE_SCHEMA_VERSION {
60        Some(caps)
61    } else {
62        None
63    }
64}
65
66/// Detect capabilities (or read from cache), then persist cache.
67#[must_use]
68pub fn detect_or_load(repo_root: &Path) -> PlatformCapabilities {
69    if let Some(cached) = load_cached(repo_root) {
70        return cached;
71    }
72
73    let detected = detect_platform_capabilities();
74    let _ = persist_cache(repo_root, &detected);
75    detected
76}
77
78/// Persist capability cache to `.manifold/platform-capabilities`.
79#[allow(clippy::missing_errors_doc)]
80pub fn persist_cache(repo_root: &Path, caps: &PlatformCapabilities) -> std::io::Result<()> {
81    let path = cache_path(repo_root);
82    if let Some(parent) = path.parent() {
83        std::fs::create_dir_all(parent)?;
84    }
85    let payload = serde_json::to_vec_pretty(caps)
86        .map_err(|e| std::io::Error::other(format!("serialize capabilities: {e}")))?;
87    std::fs::write(path, payload)
88}
89
90/// Run runtime platform capability detection.
91#[must_use]
92pub fn detect_platform_capabilities() -> PlatformCapabilities {
93    let (kernel_major, kernel_minor) = linux_kernel_version();
94    let reflink_supported = detect_reflink_support();
95    let overlay_userns_supported = detect_overlay_userns_support(kernel_major, kernel_minor);
96    let fuse_overlayfs_available = detect_fuse_overlayfs(kernel_major, kernel_minor);
97
98    PlatformCapabilities {
99        schema_version: CACHE_SCHEMA_VERSION,
100        reflink_supported,
101        overlay_userns_supported,
102        fuse_overlayfs_available,
103        kernel_major,
104        kernel_minor,
105    }
106}
107
108/// Resolve backend kind from config + platform capabilities.
109///
110/// Auto selection follows design doc §7.5 order:
111/// 1. git-worktree
112/// 2. reflink (when supported and repo > 30k files)
113/// 3. overlay (when supported and repo > 100k files)
114/// 4. copy fallback
115#[must_use]
116pub const fn resolve_backend_kind(
117    configured: BackendKind,
118    repo_file_count: usize,
119    caps: &PlatformCapabilities,
120) -> BackendKind {
121    match configured {
122        BackendKind::Auto => auto_select_backend(repo_file_count, caps),
123        BackendKind::Reflink => {
124            if caps.reflink_supported {
125                BackendKind::Reflink
126            } else {
127                BackendKind::Copy
128            }
129        }
130        BackendKind::Overlay => {
131            if caps.overlay_userns_supported || caps.fuse_overlayfs_available {
132                BackendKind::Overlay
133            } else {
134                BackendKind::Copy
135            }
136        }
137        other => other,
138    }
139}
140
141/// Auto-select backend using §7.5 priority.
142///
143/// Selection order (highest priority first):
144/// 1. `git-worktree` — always available; default for repos < 30k files.
145/// 2. `reflink`      — if CoW-capable filesystem and repo > 30k files.
146/// 3. `overlay`      — if Linux overlayfs available and repo > 100k files.
147/// 4. `copy`         — universal fallback (plain recursive copy).
148#[must_use]
149pub const fn auto_select_backend(
150    repo_file_count: usize,
151    caps: &PlatformCapabilities,
152) -> BackendKind {
153    // Overlay: Linux + overlayfs + large repo (highest CoW benefit)
154    let overlay_candidate = (caps.overlay_userns_supported || caps.fuse_overlayfs_available)
155        && repo_file_count > OVERLAY_THRESHOLD_FILES;
156    if overlay_candidate {
157        return BackendKind::Overlay;
158    }
159
160    // Reflink: CoW filesystem + medium/large repo
161    let reflink_candidate = caps.reflink_supported && repo_file_count > REF_LINK_THRESHOLD_FILES;
162    if reflink_candidate {
163        return BackendKind::Reflink;
164    }
165
166    // Default: git-worktree (always works, fast for smaller repos)
167    BackendKind::GitWorktree
168}
169
170/// Count tracked + untracked repo files (best-effort), excluding `.git` and `ws`.
171#[must_use]
172pub fn estimate_repo_file_count(repo_root: &Path) -> Option<usize> {
173    fn walk(path: &Path, count: &mut usize) -> std::io::Result<()> {
174        for entry in std::fs::read_dir(path)? {
175            let entry = entry?;
176            let p = entry.path();
177            let name = entry.file_name();
178            if name == OsStr::new(".git") || name == OsStr::new("ws") {
179                continue;
180            }
181            if p.is_dir() {
182                walk(&p, count)?;
183            } else {
184                *count += 1;
185            }
186        }
187        Ok(())
188    }
189
190    let mut count = 0;
191    walk(repo_root, &mut count).ok()?;
192    Some(count)
193}
194
195fn command_available(cmd: &str) -> bool {
196    Command::new("sh")
197        .args(["-c", &format!("command -v {cmd} >/dev/null 2>&1")])
198        .status()
199        .map(|s| s.success())
200        .unwrap_or(false)
201}
202
203fn detect_reflink_support() -> bool {
204    if !command_available("cp") {
205        return false;
206    }
207
208    let Ok(dir) = tempfile::tempdir() else {
209        return false;
210    };
211
212    let src = dir.path().join("src.tmp");
213    let dst = dir.path().join("dst.tmp");
214    if std::fs::write(&src, b"reflink-check").is_err() {
215        return false;
216    }
217
218    Command::new("cp")
219        .arg("--reflink=always")
220        .arg(&src)
221        .arg(&dst)
222        .stdout(Stdio::null())
223        .stderr(Stdio::null())
224        .status()
225        .map(|s| s.success())
226        .unwrap_or(false)
227}
228
229fn detect_overlay_userns_support(kernel_major: Option<u32>, kernel_minor: Option<u32>) -> bool {
230    if std::env::consts::OS != "linux" {
231        return false;
232    }
233    if !kernel_at_least(kernel_major, kernel_minor, 5, 11) {
234        return false;
235    }
236    if !command_available("unshare") || !command_available("mount") || !command_available("umount")
237    {
238        return false;
239    }
240
241    let Ok(dir) = tempfile::tempdir() else {
242        return false;
243    };
244    let lower = dir.path().join("lower");
245    let upper = dir.path().join("upper");
246    let work = dir.path().join("work");
247    let merged = dir.path().join("merged");
248
249    if std::fs::create_dir_all(&lower).is_err()
250        || std::fs::create_dir_all(&upper).is_err()
251        || std::fs::create_dir_all(&work).is_err()
252        || std::fs::create_dir_all(&merged).is_err()
253    {
254        return false;
255    }
256    if std::fs::write(lower.join("probe"), b"ok").is_err() {
257        return false;
258    }
259
260    let shell_cmd = format!(
261        "mount -t overlay overlay -o lowerdir='{}',upperdir='{}',workdir='{}' '{}' && umount '{}'",
262        lower.display(),
263        upper.display(),
264        work.display(),
265        merged.display(),
266        merged.display()
267    );
268
269    Command::new("unshare")
270        .args(["-Ur", "sh", "-c", &shell_cmd])
271        .stdout(Stdio::null())
272        .stderr(Stdio::null())
273        .status()
274        .map(|s| s.success())
275        .unwrap_or(false)
276}
277
278fn detect_fuse_overlayfs(kernel_major: Option<u32>, kernel_minor: Option<u32>) -> bool {
279    if std::env::consts::OS != "linux" {
280        return false;
281    }
282    if !kernel_at_least(kernel_major, kernel_minor, 4, 18) {
283        return false;
284    }
285    command_available("fuse-overlayfs")
286}
287
288const fn kernel_at_least(
289    kernel_major: Option<u32>,
290    kernel_minor: Option<u32>,
291    min_major: u32,
292    min_minor: u32,
293) -> bool {
294    match (kernel_major, kernel_minor) {
295        (Some(major), Some(minor)) => {
296            major > min_major || (major == min_major && minor >= min_minor)
297        }
298        _ => false,
299    }
300}
301
302fn linux_kernel_version() -> (Option<u32>, Option<u32>) {
303    if std::env::consts::OS != "linux" {
304        return (None, None);
305    }
306
307    let output = match Command::new("uname").arg("-r").output() {
308        Ok(output) if output.status.success() => output,
309        _ => return (None, None),
310    };
311
312    let release = String::from_utf8_lossy(&output.stdout);
313    parse_kernel_version(&release)
314}
315
316fn parse_kernel_version(release: &str) -> (Option<u32>, Option<u32>) {
317    let release = release.trim();
318    let mut parts = release.split('.');
319    let major = parts.next().and_then(|p| p.parse::<u32>().ok());
320
321    let minor_str = parts.next().map(|m| {
322        m.chars()
323            .take_while(char::is_ascii_digit)
324            .collect::<String>()
325    });
326    let minor = minor_str.and_then(|s| s.parse::<u32>().ok());
327
328    (major, minor)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn parse_kernel_version_basic() {
337        assert_eq!(parse_kernel_version("6.8.9"), (Some(6), Some(8)));
338        assert_eq!(parse_kernel_version("5.15.153-1-lts"), (Some(5), Some(15)));
339        assert_eq!(parse_kernel_version("not-a-version"), (None, None));
340    }
341
342    #[test]
343    fn kernel_at_least_works() {
344        assert!(kernel_at_least(Some(5), Some(11), 5, 11));
345        assert!(kernel_at_least(Some(6), Some(1), 5, 11));
346        assert!(!kernel_at_least(Some(5), Some(10), 5, 11));
347        assert!(!kernel_at_least(None, Some(10), 5, 11));
348    }
349
350    #[test]
351    fn cache_roundtrip() {
352        let dir = tempfile::tempdir().unwrap();
353        let caps = PlatformCapabilities {
354            schema_version: CACHE_SCHEMA_VERSION,
355            reflink_supported: true,
356            overlay_userns_supported: false,
357            fuse_overlayfs_available: true,
358            kernel_major: Some(6),
359            kernel_minor: Some(8),
360        };
361
362        persist_cache(dir.path(), &caps).unwrap();
363        let loaded = load_cached(dir.path()).unwrap();
364        assert_eq!(loaded, caps);
365    }
366
367    #[test]
368    fn resolve_backend_fallbacks() {
369        let caps = PlatformCapabilities {
370            schema_version: CACHE_SCHEMA_VERSION,
371            reflink_supported: false,
372            overlay_userns_supported: false,
373            fuse_overlayfs_available: false,
374            kernel_major: Some(6),
375            kernel_minor: Some(8),
376        };
377
378        assert_eq!(
379            resolve_backend_kind(BackendKind::Reflink, 50_000, &caps),
380            BackendKind::Copy
381        );
382        assert_eq!(
383            resolve_backend_kind(BackendKind::Overlay, 120_000, &caps),
384            BackendKind::Copy
385        );
386    }
387
388    #[test]
389    fn auto_selection_git_worktree_for_small_repos() {
390        let caps_all = PlatformCapabilities {
391            schema_version: CACHE_SCHEMA_VERSION,
392            reflink_supported: true,
393            overlay_userns_supported: true,
394            fuse_overlayfs_available: true,
395            kernel_major: Some(6),
396            kernel_minor: Some(8),
397        };
398        // Repos under 30k files → always git-worktree regardless of caps.
399        assert_eq!(
400            auto_select_backend(10_000, &caps_all),
401            BackendKind::GitWorktree
402        );
403        assert_eq!(
404            auto_select_backend(29_999, &caps_all),
405            BackendKind::GitWorktree
406        );
407    }
408
409    #[test]
410    fn auto_selection_reflink_for_medium_repos() {
411        let caps = PlatformCapabilities {
412            schema_version: CACHE_SCHEMA_VERSION,
413            reflink_supported: true,
414            overlay_userns_supported: false,
415            fuse_overlayfs_available: false,
416            kernel_major: Some(6),
417            kernel_minor: Some(8),
418        };
419        // 30k–100k files with reflink: pick reflink.
420        assert_eq!(auto_select_backend(30_001, &caps), BackendKind::Reflink);
421        assert_eq!(auto_select_backend(99_999, &caps), BackendKind::Reflink);
422    }
423
424    #[test]
425    fn auto_selection_overlay_for_large_repos() {
426        let caps = PlatformCapabilities {
427            schema_version: CACHE_SCHEMA_VERSION,
428            reflink_supported: true,
429            overlay_userns_supported: true,
430            fuse_overlayfs_available: true,
431            kernel_major: Some(6),
432            kernel_minor: Some(8),
433        };
434        // > 100k files with overlay support: pick overlay.
435        assert_eq!(auto_select_backend(100_001, &caps), BackendKind::Overlay);
436        assert_eq!(auto_select_backend(1_000_000, &caps), BackendKind::Overlay);
437    }
438
439    #[test]
440    fn auto_selection_falls_back_to_reflink_when_no_overlay() {
441        let caps = PlatformCapabilities {
442            schema_version: CACHE_SCHEMA_VERSION,
443            reflink_supported: true,
444            overlay_userns_supported: false,
445            fuse_overlayfs_available: false,
446            kernel_major: Some(6),
447            kernel_minor: Some(8),
448        };
449        // Large repo but no overlay → reflink.
450        assert_eq!(auto_select_backend(200_000, &caps), BackendKind::Reflink);
451    }
452
453    #[test]
454    fn auto_selection_falls_back_to_git_worktree_when_no_cow_caps() {
455        let caps = PlatformCapabilities {
456            schema_version: CACHE_SCHEMA_VERSION,
457            reflink_supported: false,
458            overlay_userns_supported: false,
459            fuse_overlayfs_available: false,
460            kernel_major: None,
461            kernel_minor: None,
462        };
463        // No CoW caps at all → git-worktree for any repo size.
464        assert_eq!(auto_select_backend(50_000, &caps), BackendKind::GitWorktree);
465        assert_eq!(
466            auto_select_backend(500_000, &caps),
467            BackendKind::GitWorktree
468        );
469    }
470
471    #[test]
472    fn detect_capabilities_smoke_test() {
473        let caps = detect_platform_capabilities();
474        assert_eq!(caps.schema_version, CACHE_SCHEMA_VERSION);
475    }
476
477    /// Acceptance test: auto-selection produces a valid backend for the current platform.
478    ///
479    /// This test runs against the actual platform capabilities — it validates that
480    /// the selection is consistent and produces a recognized backend kind.
481    #[test]
482    fn auto_selection_on_current_platform_returns_valid_backend() {
483        let caps = detect_platform_capabilities();
484
485        // Test a range of repo sizes.
486        for &size in &[0_usize, 1_000, 30_000, 100_000, 500_000] {
487            let kind = auto_select_backend(size, &caps);
488            // Must be one of the valid concrete backend kinds.
489            assert!(
490                matches!(
491                    kind,
492                    BackendKind::GitWorktree
493                        | BackendKind::Reflink
494                        | BackendKind::Overlay
495                        | BackendKind::Copy
496                ),
497                "auto_select_backend({size}, caps) returned {kind:?}, expected a concrete kind"
498            );
499        }
500    }
501
502    /// Acceptance test: `resolve_backend_kind(Auto, ...)` never returns `Auto`.
503    #[test]
504    fn resolve_backend_kind_never_returns_auto() {
505        let caps = detect_platform_capabilities();
506        let resolved = resolve_backend_kind(BackendKind::Auto, 50_000, &caps);
507        assert_ne!(
508            resolved,
509            BackendKind::Auto,
510            "resolved backend should never be Auto"
511        );
512    }
513
514    /// Acceptance test: config override works for all backend types.
515    ///
516    /// When the config explicitly sets a backend, `resolve_backend_kind` must
517    /// return that backend (or its fallback on unsupported platforms).
518    #[test]
519    fn config_override_for_all_backend_types() {
520        let caps_none = PlatformCapabilities::default();
521
522        // git-worktree: always passes through.
523        assert_eq!(
524            resolve_backend_kind(BackendKind::GitWorktree, 0, &caps_none),
525            BackendKind::GitWorktree
526        );
527
528        // copy: always passes through.
529        assert_eq!(
530            resolve_backend_kind(BackendKind::Copy, 0, &caps_none),
531            BackendKind::Copy
532        );
533
534        // reflink: falls back to copy when not supported.
535        assert_eq!(
536            resolve_backend_kind(BackendKind::Reflink, 0, &caps_none),
537            BackendKind::Copy
538        );
539
540        // overlay: falls back to copy when not supported.
541        assert_eq!(
542            resolve_backend_kind(BackendKind::Overlay, 0, &caps_none),
543            BackendKind::Copy
544        );
545
546        // reflink: passes through when supported.
547        let caps_reflink = PlatformCapabilities {
548            reflink_supported: true,
549            ..PlatformCapabilities::default()
550        };
551        assert_eq!(
552            resolve_backend_kind(BackendKind::Reflink, 0, &caps_reflink),
553            BackendKind::Reflink
554        );
555
556        // overlay: passes through when supported.
557        let caps_overlay = PlatformCapabilities {
558            overlay_userns_supported: true,
559            ..PlatformCapabilities::default()
560        };
561        assert_eq!(
562            resolve_backend_kind(BackendKind::Overlay, 0, &caps_overlay),
563            BackendKind::Overlay
564        );
565    }
566}