thoughts_tool/platform/
detector.rs

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)] // Needed for exhaustive matching but only constructed on non-Linux/macOS
13    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    // Absolute paths captured during detection
25    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    // Used in tests, could be useful for diagnostics
50    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    // Could be used in error messages showing required tools
60    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    // Detect distribution
96    let (distro, version) = detect_linux_distro();
97    info!("Detected Linux distribution: {} {}", distro, version);
98
99    // Check for mergerfs (now returns path and version)
100    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    // Check for FUSE support
113    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    // Check for fusermount (capture path)
121    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    // Try to read /etc/os-release (systemd standard)
153    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    // Fallback to lsb_release if available
173    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    // Check if FUSE module is loaded
218    if Path::new("/sys/module/fuse").exists() {
219        return true;
220    }
221
222    // Check if we can load the module (requires privileges)
223    if Path::new("/dev/fuse").exists() {
224        return true;
225    }
226
227    // Try to check with modinfo
228    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    // Get macOS version
238    let version = get_macos_version();
239    info!("Detected macOS version: {}", version);
240
241    // Check for FUSE-T
242    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    // Check for macFUSE
251    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    // Check for unionfs-fuse
260    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    // FUSE-T detection: Check for the FUSE-T filesystem bundle
303    let fuse_t_path = Path::new(FUSE_T_FS_PATH);
304    if fuse_t_path.exists() {
305        // Try to get version from Info.plist
306        let plist_path = fuse_t_path.join("Contents/Info.plist");
307        if let Ok(content) = std::fs::read_to_string(&plist_path) {
308            // Parse version from plist
309            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    // Also check for go-nfsv4 binary (FUSE-T component)
327    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    // Check for macFUSE installation
338    let macfuse_path = Path::new("/Library/Filesystems/macfuse.fs");
339    if macfuse_path.exists() {
340        // Try to get version
341        let plist_path = macfuse_path.join("Contents/Info.plist");
342        if let Ok(content) = std::fs::read_to_string(plist_path) {
343            // Parse version from plist (simplified)
344            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        // Should detect something
371        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        // Architecture should be detected
384        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        // Should strip leading 'v' or 'V'
410        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        // Should handle version without 'v' prefix
419        assert_eq!(
420            super::extract_mergerfs_version("mergerfs 2.40.2"),
421            Some("2.40.2".to_string())
422        );
423        // Should return None for text without version-like content
424        assert_eq!(super::extract_mergerfs_version("no version here"), None);
425        // Should handle empty string
426        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}