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}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct MacOSInfo {
28 pub version: String,
29 pub has_fuse_t: bool,
30 pub fuse_t_version: Option<String>,
31 pub has_macfuse: bool,
32 pub macfuse_version: Option<String>,
33 pub has_unionfs: bool,
34 pub unionfs_path: Option<PathBuf>,
35}
36
37#[derive(Debug, Clone)]
38pub struct PlatformInfo {
39 pub platform: Platform,
40 #[cfg(test)]
41 pub arch: String,
42}
43
44impl Platform {
45 #[allow(dead_code)]
46 pub fn can_mount(&self) -> bool {
48 match self {
49 Platform::Linux(info) => info.has_mergerfs && info.fuse_available,
50 Platform::MacOS(info) => info.has_fuse_t || info.has_macfuse,
51 Platform::Unsupported(_) => false,
52 }
53 }
54
55 #[allow(dead_code)]
56 pub fn mount_tool_name(&self) -> Option<&'static str> {
58 match self {
59 Platform::Linux(_) => Some("mergerfs"),
60 Platform::MacOS(_) => Some("FUSE-T or macFUSE"),
61 Platform::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 detect_linux()
72 }
73
74 #[cfg(target_os = "macos")]
75 {
76 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() -> Result<PlatformInfo> {
92 let (distro, version) = detect_linux_distro();
94 info!("Detected Linux distribution: {} {}", distro, version);
95
96 let (has_mergerfs, mergerfs_version) = check_mergerfs();
98 if has_mergerfs {
99 info!("Found mergerfs version: {:?}", mergerfs_version);
100 } else {
101 info!("mergerfs not found");
102 }
103
104 let fuse_available = check_fuse_support();
106 if fuse_available {
107 info!("FUSE support detected");
108 } else {
109 info!("FUSE support not detected");
110 }
111
112 let has_fusermount = which::which("fusermount")
114 .or_else(|_| which::which("fusermount3"))
115 .is_ok();
116 if has_fusermount {
117 info!("fusermount detected");
118 }
119
120 let linux_info = LinuxInfo {
121 distro,
122 version,
123 has_mergerfs,
124 mergerfs_version,
125 fuse_available,
126 has_fusermount,
127 };
128
129 Ok(PlatformInfo {
130 platform: Platform::Linux(linux_info),
131 #[cfg(test)]
132 arch: std::env::consts::ARCH.to_string(),
133 })
134}
135
136#[cfg(target_os = "linux")]
137fn detect_linux_distro() -> (String, String) {
138 if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
140 let mut name = "Unknown".to_string();
141 let mut version = "Unknown".to_string();
142
143 for line in content.lines() {
144 if let Some(value) = line.strip_prefix("NAME=") {
145 name = value.trim_matches('"').to_string();
146 } else if let Some(value) = line.strip_prefix("VERSION=") {
147 version = value.trim_matches('"').to_string();
148 } else if let Some(value) = line.strip_prefix("VERSION_ID=")
149 && version == "Unknown"
150 {
151 version = value.trim_matches('"').to_string();
152 }
153 }
154
155 return (name, version);
156 }
157
158 if let Ok(output) = Command::new("lsb_release").args(["-d", "-r"]).output() {
160 let output_str = String::from_utf8_lossy(&output.stdout);
161 let lines: Vec<&str> = output_str.lines().collect();
162 let distro = lines
163 .first()
164 .and_then(|l| l.split(':').nth(1))
165 .map(|s| s.trim().to_string())
166 .unwrap_or_else(|| "Unknown".to_string());
167 let version = lines
168 .get(1)
169 .and_then(|l| l.split(':').nth(1))
170 .map(|s| s.trim().to_string())
171 .unwrap_or_else(|| "Unknown".to_string());
172 return (distro, version);
173 }
174
175 ("Unknown Linux".to_string(), "Unknown".to_string())
176}
177
178#[cfg(target_os = "linux")]
179fn check_mergerfs() -> (bool, Option<String>) {
180 match which::which("mergerfs") {
181 Ok(path) => {
182 debug!("Found mergerfs at: {:?}", path);
183 if let Ok(output) = Command::new("mergerfs").arg("-V").output() {
185 let version_str = String::from_utf8_lossy(&output.stdout);
186 if let Some(version_line) = version_str.lines().next() {
187 let version = version_line
188 .split_whitespace()
189 .find(|s| s.chars().any(|c| c.is_ascii_digit()))
190 .map(|s| s.to_string());
191 return (true, version);
192 }
193 }
194 (true, None)
195 }
196 Err(_) => (false, None),
197 }
198}
199
200#[cfg(target_os = "linux")]
201fn check_fuse_support() -> bool {
202 if Path::new("/sys/module/fuse").exists() {
204 return true;
205 }
206
207 if Path::new("/dev/fuse").exists() {
209 return true;
210 }
211
212 if let Ok(output) = Command::new("modinfo").arg("fuse").output() {
214 return output.status.success();
215 }
216
217 false
218}
219
220#[cfg(target_os = "macos")]
221fn detect_macos() -> Result<PlatformInfo> {
222 let version = get_macos_version();
224 info!("Detected macOS version: {}", version);
225
226 let (has_fuse_t, fuse_t_version) = check_fuse_t();
228 if has_fuse_t {
229 info!("Found FUSE-T version: {:?}", fuse_t_version);
230 }
231
232 let (has_macfuse, macfuse_version) = check_macfuse();
234 if has_macfuse {
235 info!("Found macFUSE version: {:?}", macfuse_version);
236 }
237
238 use crate::platform::macos::UNIONFS_BINARIES;
240 let unionfs_path = UNIONFS_BINARIES
241 .iter()
242 .find_map(|binary| which::which(binary).ok());
243 let has_unionfs = unionfs_path.is_some();
244 if has_unionfs {
245 info!("Found unionfs at: {:?}", unionfs_path);
246 }
247
248 let macos_info = MacOSInfo {
249 version,
250 has_fuse_t,
251 fuse_t_version,
252 has_macfuse,
253 macfuse_version,
254 has_unionfs,
255 unionfs_path,
256 };
257
258 Ok(PlatformInfo {
259 platform: Platform::MacOS(macos_info),
260 #[cfg(test)]
261 arch: std::env::consts::ARCH.to_string(),
262 })
263}
264
265#[cfg(target_os = "macos")]
266fn get_macos_version() -> String {
267 if let Ok(output) = Command::new("sw_vers").arg("-productVersion").output() {
268 String::from_utf8_lossy(&output.stdout).trim().to_string()
269 } else {
270 "Unknown".to_string()
271 }
272}
273
274#[cfg(target_os = "macos")]
275fn check_fuse_t() -> (bool, Option<String>) {
276 use crate::platform::macos::FUSE_T_FS_PATH;
277
278 let fuse_t_path = Path::new(FUSE_T_FS_PATH);
280 if fuse_t_path.exists() {
281 let plist_path = fuse_t_path.join("Contents/Info.plist");
283 if let Ok(content) = std::fs::read_to_string(&plist_path) {
284 if let Some(version_start) = content.find("<key>CFBundleShortVersionString</key>") {
286 if let Some(version_line) = content[version_start..].lines().nth(1) {
287 if let Some(version) = version_line
288 .trim()
289 .strip_prefix("<string>")
290 .and_then(|s| s.strip_suffix("</string>"))
291 {
292 debug!("Found FUSE-T version: {}", version);
293 return (true, Some(version.to_string()));
294 }
295 }
296 }
297 }
298 debug!("Found FUSE-T but could not determine version");
299 return (true, None);
300 }
301
302 if Path::new("/usr/local/bin/go-nfsv4").exists() {
304 debug!("Found go-nfsv4 binary (FUSE-T component)");
305 return (true, None);
306 }
307
308 (false, None)
309}
310
311#[cfg(target_os = "macos")]
312fn check_macfuse() -> (bool, Option<String>) {
313 let macfuse_path = Path::new("/Library/Filesystems/macfuse.fs");
315 if macfuse_path.exists() {
316 let plist_path = macfuse_path.join("Contents/Info.plist");
318 if let Ok(content) = std::fs::read_to_string(plist_path) {
319 if let Some(version_start) = content.find("<key>CFBundleShortVersionString</key>") {
321 if let Some(version_line) = content[version_start..].lines().nth(1) {
322 if let Some(version) = version_line
323 .trim()
324 .strip_prefix("<string>")
325 .and_then(|s| s.strip_suffix("</string>"))
326 {
327 return (true, Some(version.to_string()));
328 }
329 }
330 }
331 }
332 return (true, None);
333 }
334
335 (false, None)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn test_platform_detection() {
344 let info = detect_platform().unwrap();
345
346 match &info.platform {
348 Platform::Linux(_) => {
349 assert_eq!(std::env::consts::OS, "linux");
350 }
351 Platform::MacOS(_) => {
352 assert_eq!(std::env::consts::OS, "macos");
353 }
354 Platform::Unsupported(os) => {
355 assert_eq!(os, std::env::consts::OS);
356 }
357 }
358
359 assert!(!info.arch.is_empty());
361 }
362
363 #[test]
364 #[cfg(target_os = "linux")]
365 fn test_mount_tool_name_linux() {
366 let linux_platform = Platform::Linux(LinuxInfo {
367 distro: "Ubuntu".to_string(),
368 version: "22.04".to_string(),
369 has_mergerfs: true,
370 mergerfs_version: Some("2.33.5".to_string()),
371 fuse_available: true,
372 has_fusermount: true,
373 });
374 assert_eq!(linux_platform.mount_tool_name(), Some("mergerfs"));
375
376 let unsupported = Platform::Unsupported("windows".to_string());
377 assert_eq!(unsupported.mount_tool_name(), None);
378 }
379
380 #[test]
381 #[cfg(target_os = "macos")]
382 fn test_mount_tool_name_macos() {
383 let macos_platform = Platform::MacOS(MacOSInfo {
384 version: "13.0".to_string(),
385 has_fuse_t: true,
386 fuse_t_version: Some("1.0.0".to_string()),
387 has_macfuse: false,
388 macfuse_version: None,
389 has_unionfs: true,
390 unionfs_path: Some(PathBuf::from("/usr/local/bin/unionfs-fuse")),
391 });
392 assert_eq!(macos_platform.mount_tool_name(), Some("FUSE-T or macFUSE"));
393
394 let unsupported = Platform::Unsupported("windows".to_string());
395 assert_eq!(unsupported.mount_tool_name(), None);
396 }
397}