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