1use crate::error::Result;
2use std::path::Path;
3use std::path::PathBuf;
4use std::process::Command;
5use tracing::debug;
6use tracing::info;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum Platform {
10 Linux(LinuxInfo),
11 MacOS(MacOSInfo),
12 Unsupported(String),
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct LinuxInfo {
17 pub distro: String,
18 pub version: String,
19 pub has_mergerfs: bool,
20 pub mergerfs_version: Option<String>,
21 pub fuse_available: bool,
22 pub has_fusermount: bool,
23 pub mergerfs_path: Option<PathBuf>,
25 pub fusermount_path: Option<PathBuf>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct MacOSInfo {
30 pub version: String,
31 pub has_fuse_t: bool,
32 pub fuse_t_version: Option<String>,
33 pub has_macfuse: bool,
34 pub macfuse_version: Option<String>,
35 pub has_unionfs: bool,
36 pub unionfs_path: Option<PathBuf>,
37}
38
39#[derive(Debug, Clone)]
40pub struct PlatformInfo {
41 pub platform: Platform,
42 #[cfg(test)]
43 pub arch: String,
44}
45
46impl Platform {
47 pub fn can_mount(&self) -> bool {
49 match self {
50 Self::Linux(info) => info.has_mergerfs && info.fuse_available,
51 Self::MacOS(info) => info.has_fuse_t || info.has_macfuse,
52 Self::Unsupported(_) => false,
53 }
54 }
55
56 pub fn mount_tool_name(&self) -> Option<&'static str> {
58 match self {
59 Self::Linux(_) => Some("mergerfs"),
60 Self::MacOS(_) => Some("FUSE-T or macFUSE"),
61 Self::Unsupported(_) => None,
62 }
63 }
64}
65
66pub fn detect_platform() -> Result<PlatformInfo> {
67 debug!("Starting platform detection");
68
69 #[cfg(target_os = "linux")]
70 {
71 Ok(detect_linux())
72 }
73
74 #[cfg(target_os = "macos")]
75 {
76 Ok(detect_macos())
77 }
78
79 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
80 {
81 let os = std::env::consts::OS;
82 Ok(PlatformInfo {
83 platform: Platform::Unsupported(os.to_string()),
84 #[cfg(test)]
85 arch: std::env::consts::ARCH.to_string(),
86 })
87 }
88}
89
90#[cfg(target_os = "linux")]
91fn detect_linux() -> PlatformInfo {
92 let (distro, version) = detect_linux_distro();
94 info!("Detected Linux distribution: {} {}", distro, version);
95
96 let (mergerfs_path, mergerfs_version) = check_mergerfs();
98 let has_mergerfs = mergerfs_path.is_some();
99 if let Some(path) = &mergerfs_path {
100 info!(
101 "Found mergerfs at {} version: {}",
102 path.display(),
103 mergerfs_version.as_deref().unwrap_or("unknown")
104 );
105 } else {
106 info!("mergerfs not found");
107 }
108
109 let fuse_available = check_fuse_support();
111 if fuse_available {
112 info!("FUSE support detected");
113 } else {
114 info!("FUSE support not detected");
115 }
116
117 let fusermount_path = which::which("fusermount")
119 .or_else(|_| which::which("fusermount3"))
120 .ok();
121 let has_fusermount = fusermount_path.is_some();
122 if let Some(path) = &fusermount_path {
123 info!("fusermount detected at {}", path.display());
124 }
125
126 let linux_info = LinuxInfo {
127 distro,
128 version,
129 has_mergerfs,
130 mergerfs_version,
131 fuse_available,
132 has_fusermount,
133 mergerfs_path,
134 fusermount_path,
135 };
136
137 PlatformInfo {
138 platform: Platform::Linux(linux_info),
139 #[cfg(test)]
140 arch: std::env::consts::ARCH.to_string(),
141 }
142}
143
144#[cfg(target_os = "linux")]
145fn detect_linux_distro() -> (String, String) {
146 if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
148 let mut name = "Unknown".to_string();
149 let mut version = "Unknown".to_string();
150
151 for line in content.lines() {
152 if let Some(value) = line.strip_prefix("NAME=") {
153 name = value.trim_matches('"').to_string();
154 } else if let Some(value) = line.strip_prefix("VERSION=") {
155 version = value.trim_matches('"').to_string();
156 } else if let Some(value) = line.strip_prefix("VERSION_ID=")
157 && version == "Unknown"
158 {
159 version = value.trim_matches('"').to_string();
160 }
161 }
162
163 return (name, version);
164 }
165
166 if let Ok(output) = Command::new("lsb_release").args(["-d", "-r"]).output() {
168 let output_str = String::from_utf8_lossy(&output.stdout);
169 let lines: Vec<&str> = output_str.lines().collect();
170 let distro = lines
171 .first()
172 .and_then(|l| l.split(':').nth(1))
173 .map_or_else(|| "Unknown".to_string(), |s| s.trim().to_string());
174 let version = lines
175 .get(1)
176 .and_then(|l| l.split(':').nth(1))
177 .map_or_else(|| "Unknown".to_string(), |s| s.trim().to_string());
178 return (distro, version);
179 }
180
181 ("Unknown Linux".to_string(), "Unknown".to_string())
182}
183
184#[cfg(target_os = "linux")]
185fn check_mergerfs() -> (Option<PathBuf>, Option<String>) {
186 match which::which("mergerfs") {
187 Ok(path) => {
188 debug!("Found mergerfs at: {:?}", path);
189 let version = Command::new(&path).arg("-V").output().ok().and_then(|out| {
190 let stdout = String::from_utf8_lossy(&out.stdout);
191 let stderr = String::from_utf8_lossy(&out.stderr);
192 extract_mergerfs_version(&stdout).or_else(|| extract_mergerfs_version(&stderr))
193 });
194 (Some(path), version)
195 }
196 Err(_) => (None, None),
197 }
198}
199
200#[cfg(target_os = "linux")]
201fn extract_mergerfs_version(text: &str) -> Option<String> {
202 text.split_whitespace()
203 .find(|s| s.chars().any(|c| c.is_ascii_digit()))
204 .map(|tok| tok.trim_start_matches(['v', 'V']).to_string())
205}
206
207#[cfg(target_os = "linux")]
208fn check_fuse_support() -> bool {
209 if Path::new("/sys/module/fuse").exists() {
211 return true;
212 }
213
214 if Path::new("/dev/fuse").exists() {
216 return true;
217 }
218
219 if let Ok(output) = Command::new("modinfo").arg("fuse").output() {
221 return output.status.success();
222 }
223
224 false
225}
226
227#[cfg(target_os = "macos")]
228fn detect_macos() -> PlatformInfo {
229 let version = get_macos_version();
231 info!("Detected macOS version: {}", version);
232
233 let (has_fuse_t, fuse_t_version) = check_fuse_t();
235 if has_fuse_t {
236 info!(
237 "Found FUSE-T version: {}",
238 fuse_t_version.as_deref().unwrap_or("unknown")
239 );
240 }
241
242 let (has_macfuse, macfuse_version) = check_macfuse();
244 if has_macfuse {
245 info!(
246 "Found macFUSE version: {}",
247 macfuse_version.as_deref().unwrap_or("unknown")
248 );
249 }
250
251 use crate::platform::macos::UNIONFS_BINARIES;
253 let unionfs_path = UNIONFS_BINARIES
254 .iter()
255 .find_map(|binary| which::which(binary).ok());
256 let has_unionfs = unionfs_path.is_some();
257 if let Some(path) = &unionfs_path {
258 info!("Found unionfs at: {}", path.display());
259 }
260
261 let macos_info = MacOSInfo {
262 version,
263 has_fuse_t,
264 fuse_t_version,
265 has_macfuse,
266 macfuse_version,
267 has_unionfs,
268 unionfs_path,
269 };
270
271 PlatformInfo {
272 platform: Platform::MacOS(macos_info),
273 #[cfg(test)]
274 arch: std::env::consts::ARCH.to_string(),
275 }
276}
277
278#[cfg(target_os = "macos")]
279fn get_macos_version() -> String {
280 if let Ok(output) = Command::new("sw_vers").arg("-productVersion").output() {
281 String::from_utf8_lossy(&output.stdout).trim().to_string()
282 } else {
283 "Unknown".to_string()
284 }
285}
286
287#[cfg(target_os = "macos")]
288fn check_fuse_t() -> (bool, Option<String>) {
289 use crate::platform::macos::FUSE_T_FS_PATH;
290
291 let fuse_t_path = Path::new(FUSE_T_FS_PATH);
293 if fuse_t_path.exists() {
294 let plist_path = fuse_t_path.join("Contents/Info.plist");
296 if let Ok(content) = std::fs::read_to_string(&plist_path) {
297 if let Some(version_start) = content.find("<key>CFBundleShortVersionString</key>") {
299 if let Some(version_line) = content[version_start..].lines().nth(1) {
300 if let Some(version) = version_line
301 .trim()
302 .strip_prefix("<string>")
303 .and_then(|s| s.strip_suffix("</string>"))
304 {
305 debug!("Found FUSE-T version: {}", version);
306 return (true, Some(version.to_string()));
307 }
308 }
309 }
310 }
311 debug!("Found FUSE-T but could not determine version");
312 return (true, None);
313 }
314
315 if Path::new("/usr/local/bin/go-nfsv4").exists() {
317 debug!("Found go-nfsv4 binary (FUSE-T component)");
318 return (true, None);
319 }
320
321 (false, None)
322}
323
324#[cfg(target_os = "macos")]
325fn check_macfuse() -> (bool, Option<String>) {
326 let macfuse_path = Path::new("/Library/Filesystems/macfuse.fs");
328 if macfuse_path.exists() {
329 let plist_path = macfuse_path.join("Contents/Info.plist");
331 if let Ok(content) = std::fs::read_to_string(plist_path) {
332 if let Some(version_start) = content.find("<key>CFBundleShortVersionString</key>") {
334 if let Some(version_line) = content[version_start..].lines().nth(1) {
335 if let Some(version) = version_line
336 .trim()
337 .strip_prefix("<string>")
338 .and_then(|s| s.strip_suffix("</string>"))
339 {
340 return (true, Some(version.to_string()));
341 }
342 }
343 }
344 }
345 return (true, None);
346 }
347
348 (false, None)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_platform_detection() {
357 let info = detect_platform().unwrap();
358
359 match &info.platform {
361 Platform::Linux(_) => {
362 assert_eq!(std::env::consts::OS, "linux");
363 }
364 Platform::MacOS(_) => {
365 assert_eq!(std::env::consts::OS, "macos");
366 }
367 Platform::Unsupported(os) => {
368 assert_eq!(os, std::env::consts::OS);
369 }
370 }
371
372 assert!(!info.arch.is_empty());
374 }
375
376 #[test]
377 #[cfg(target_os = "linux")]
378 fn test_mount_tool_name_linux() {
379 let linux_platform = Platform::Linux(LinuxInfo {
380 distro: "Ubuntu".to_string(),
381 version: "22.04".to_string(),
382 has_mergerfs: true,
383 mergerfs_version: Some("2.33.5".to_string()),
384 fuse_available: true,
385 has_fusermount: true,
386 mergerfs_path: Some(PathBuf::from("/usr/bin/mergerfs")),
387 fusermount_path: Some(PathBuf::from("/bin/fusermount")),
388 });
389 assert_eq!(linux_platform.mount_tool_name(), Some("mergerfs"));
390
391 let unsupported = Platform::Unsupported("windows".to_string());
392 assert_eq!(unsupported.mount_tool_name(), None);
393 }
394
395 #[test]
396 #[cfg(target_os = "linux")]
397 fn test_extract_mergerfs_version() {
398 assert_eq!(
400 super::extract_mergerfs_version("mergerfs v2.40.2"),
401 Some("2.40.2".to_string())
402 );
403 assert_eq!(
404 super::extract_mergerfs_version("mergerfs V2.40.2"),
405 Some("2.40.2".to_string())
406 );
407 assert_eq!(
409 super::extract_mergerfs_version("mergerfs 2.40.2"),
410 Some("2.40.2".to_string())
411 );
412 assert_eq!(super::extract_mergerfs_version("no version here"), None);
414 assert_eq!(super::extract_mergerfs_version(""), None);
416 }
417
418 #[test]
419 #[cfg(target_os = "macos")]
420 fn test_mount_tool_name_macos() {
421 let macos_platform = Platform::MacOS(MacOSInfo {
422 version: "13.0".to_string(),
423 has_fuse_t: true,
424 fuse_t_version: Some("1.0.0".to_string()),
425 has_macfuse: false,
426 macfuse_version: None,
427 has_unionfs: true,
428 unionfs_path: Some(PathBuf::from("/usr/local/bin/unionfs-fuse")),
429 });
430 assert_eq!(macos_platform.mount_tool_name(), Some("FUSE-T or macFUSE"));
431
432 let unsupported = Platform::Unsupported("windows".to_string());
433 assert_eq!(unsupported.mount_tool_name(), None);
434 }
435}