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