Skip to main content

thoughts_tool/platform/
detector.rs

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)] // Needed for exhaustive matching but only constructed on non-Linux/macOS
15    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    // Absolute paths captured during detection
27    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    // Used in tests, could be useful for diagnostics
52    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    // Could be used in error messages showing required tools
62    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    // Detect distribution
98    let (distro, version) = detect_linux_distro();
99    info!("Detected Linux distribution: {} {}", distro, version);
100
101    // Check for mergerfs (now returns path and version)
102    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    // Check for FUSE support
115    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    // Check for fusermount (capture path)
123    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    // Try to read /etc/os-release (systemd standard)
155    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    // Fallback to lsb_release if available
175    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    // Check if FUSE module is loaded
220    if Path::new("/sys/module/fuse").exists() {
221        return true;
222    }
223
224    // Check if we can load the module (requires privileges)
225    if Path::new("/dev/fuse").exists() {
226        return true;
227    }
228
229    // Try to check with modinfo
230    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    // Get macOS version
240    let version = get_macos_version();
241    info!("Detected macOS version: {}", version);
242
243    // Check for FUSE-T
244    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    // Check for macFUSE
253    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    // Check for unionfs-fuse
262    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    // FUSE-T detection: Check for the FUSE-T filesystem bundle
305    let fuse_t_path = Path::new(FUSE_T_FS_PATH);
306    if fuse_t_path.exists() {
307        // Try to get version from Info.plist
308        let plist_path = fuse_t_path.join("Contents/Info.plist");
309        if let Ok(content) = std::fs::read_to_string(&plist_path) {
310            // Parse version from plist
311            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    // Also check for go-nfsv4 binary (FUSE-T component)
329    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    // Check for macFUSE installation
340    let macfuse_path = Path::new("/Library/Filesystems/macfuse.fs");
341    if macfuse_path.exists() {
342        // Try to get version
343        let plist_path = macfuse_path.join("Contents/Info.plist");
344        if let Ok(content) = std::fs::read_to_string(plist_path) {
345            // Parse version from plist (simplified)
346            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        // Should detect something
373        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        // Architecture should be detected
386        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        // Should strip leading 'v' or 'V'
412        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        // Should handle version without 'v' prefix
421        assert_eq!(
422            super::extract_mergerfs_version("mergerfs 2.40.2"),
423            Some("2.40.2".to_string())
424        );
425        // Should return None for text without version-like content
426        assert_eq!(super::extract_mergerfs_version("no version here"), None);
427        // Should handle empty string
428        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}