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}
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    // Used in tests, could be useful for diagnostics
47    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    // Could be used in error messages showing required tools
57    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    // Detect distribution
93    let (distro, version) = detect_linux_distro();
94    info!("Detected Linux distribution: {} {}", distro, version);
95
96    // Check for mergerfs
97    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    // Check for FUSE support
105    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    // Check for fusermount
113    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    // Try to read /etc/os-release (systemd standard)
139    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    // Fallback to lsb_release if available
159    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            // Try to get version
184            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    // Check if FUSE module is loaded
203    if Path::new("/sys/module/fuse").exists() {
204        return true;
205    }
206
207    // Check if we can load the module (requires privileges)
208    if Path::new("/dev/fuse").exists() {
209        return true;
210    }
211
212    // Try to check with modinfo
213    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    // Get macOS version
223    let version = get_macos_version();
224    info!("Detected macOS version: {}", version);
225
226    // Check for FUSE-T
227    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    // Check for macFUSE
233    let (has_macfuse, macfuse_version) = check_macfuse();
234    if has_macfuse {
235        info!("Found macFUSE version: {:?}", macfuse_version);
236    }
237
238    // Check for unionfs-fuse
239    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    // FUSE-T detection: Check for the FUSE-T filesystem bundle
279    let fuse_t_path = Path::new(FUSE_T_FS_PATH);
280    if fuse_t_path.exists() {
281        // Try to get version from Info.plist
282        let plist_path = fuse_t_path.join("Contents/Info.plist");
283        if let Ok(content) = std::fs::read_to_string(&plist_path) {
284            // Parse version from plist
285            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    // Also check for go-nfsv4 binary (FUSE-T component)
303    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    // Check for macFUSE installation
314    let macfuse_path = Path::new("/Library/Filesystems/macfuse.fs");
315    if macfuse_path.exists() {
316        // Try to get version
317        let plist_path = macfuse_path.join("Contents/Info.plist");
318        if let Ok(content) = std::fs::read_to_string(plist_path) {
319            // Parse version from plist (simplified)
320            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        // Should detect something
347        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        // Architecture should be detected
360        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}