1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20pub struct PlatformCapabilities {
21 pub schema_version: u32,
23 pub reflink_supported: bool,
25 pub overlay_userns_supported: bool,
27 pub fuse_overlayfs_available: bool,
29 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#[must_use]
49pub fn cache_path(repo_root: &Path) -> PathBuf {
50 repo_root.join(".manifold").join("platform-capabilities")
51}
52
53#[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#[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#[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#[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#[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#[must_use]
149pub const fn auto_select_backend(
150 repo_file_count: usize,
151 caps: &PlatformCapabilities,
152) -> BackendKind {
153 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 let reflink_candidate = caps.reflink_supported && repo_file_count > REF_LINK_THRESHOLD_FILES;
162 if reflink_candidate {
163 return BackendKind::Reflink;
164 }
165
166 BackendKind::GitWorktree
168}
169
170#[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 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 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 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 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 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 #[test]
482 fn auto_selection_on_current_platform_returns_valid_backend() {
483 let caps = detect_platform_capabilities();
484
485 for &size in &[0_usize, 1_000, 30_000, 100_000, 500_000] {
487 let kind = auto_select_backend(size, &caps);
488 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 #[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 #[test]
519 fn config_override_for_all_backend_types() {
520 let caps_none = PlatformCapabilities::default();
521
522 assert_eq!(
524 resolve_backend_kind(BackendKind::GitWorktree, 0, &caps_none),
525 BackendKind::GitWorktree
526 );
527
528 assert_eq!(
530 resolve_backend_kind(BackendKind::Copy, 0, &caps_none),
531 BackendKind::Copy
532 );
533
534 assert_eq!(
536 resolve_backend_kind(BackendKind::Reflink, 0, &caps_none),
537 BackendKind::Copy
538 );
539
540 assert_eq!(
542 resolve_backend_kind(BackendKind::Overlay, 0, &caps_none),
543 BackendKind::Copy
544 );
545
546 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 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}