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