Skip to main content

ntfs_mac_core/
deps.rs

1//! Dependency discovery: ntfs-3g, macFUSE / FUSE-T, diskutil, hdiutil,
2//! rsync, mkntfs/newfs_ntfs, ntfsfix, fsck_ntfs.
3//!
4//! Everything is optional at compile time — this module only reports
5//! what is actually present so the CLI can give actionable hints.
6
7use std::fmt;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13use crate::runner;
14
15/// Which FUSE driver the user should install on macOS.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum FuseDriver {
19    /// `brew install --cask macfuse` (Intel + Apple Silicon, dext-based).
20    Macfuse,
21    /// `brew install --cask fuse-t` (Apple Silicon native, FUSE-T).
22    FuseT,
23    /// Let ntfs-3g decide.
24    #[default]
25    Auto,
26}
27
28impl From<&str> for FuseDriver {
29    fn from(s: &str) -> Self {
30        match s.to_ascii_lowercase().as_str() {
31            "macfuse" => Self::Macfuse,
32            "fuse-t" | "fuset" => Self::FuseT,
33            _ => Self::Auto,
34        }
35    }
36}
37
38impl FuseDriver {
39    /// Homebrew install hint.
40    pub fn brew_install_command(&self) -> String {
41        match self {
42            Self::Macfuse => "brew install --cask macfuse".into(),
43            Self::FuseT => "brew install --cask fuse-t".into(),
44            Self::Auto => "brew install --cask macfuse  # or fuse-t on Apple Silicon".into(),
45        }
46    }
47}
48
49/// Report for a single dependency.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct DepStatus {
52    /// Display name.
53    pub name: String,
54    /// Whether it is present.
55    pub present: bool,
56    /// Location on disk, when present.
57    pub path: Option<PathBuf>,
58    /// Suggested fix command, when absent.
59    pub install_hint: Option<String>,
60}
61
62impl DepStatus {
63    fn missing(name: &str, hint: impl Into<String>) -> Self {
64        Self {
65            name: name.to_string(),
66            present: false,
67            path: None,
68            install_hint: Some(hint.into()),
69        }
70    }
71}
72
73/// Report for all dependencies.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DepReport {
76    /// Every probed dependency.
77    pub deps: Vec<DepStatus>,
78    /// Overall readiness: `true` only if every dep is present.
79    pub ready: bool,
80    /// Architecture detection (`x86_64` or `aarch64`).
81    pub arch: String,
82    /// macOS version string from `sw_vers -productVersion`.
83    pub macos_version: Option<String>,
84}
85
86impl fmt::Display for DepReport {
87    /// Human-readable summary. Used by `ntfs-mac doctor`.
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        writeln!(
90            f,
91            "Platform: macOS {} on {}",
92            self.macos_version.as_deref().unwrap_or("unknown"),
93            self.arch
94        )?;
95        writeln!(f, "\nDependencies:")?;
96        for d in &self.deps {
97            let icon = if d.present { "✓" } else { "✗" };
98            let loc = d
99                .path
100                .as_ref()
101                .map(|p| format!("  ({})", p.display()))
102                .unwrap_or_default();
103            writeln!(
104                f,
105                "  {} {:<20} {}",
106                icon,
107                d.name,
108                if d.present {
109                    loc
110                } else {
111                    format!(
112                        "missing — {}",
113                        d.install_hint.as_deref().unwrap_or("no hint")
114                    )
115                }
116            )?;
117        }
118        writeln!(f, "\nReady: {}\n", self.ready)?;
119        Ok(())
120    }
121}
122
123/// Probe all known dependencies. Does not fail — missing deps are
124/// reported, not raised. Only `diskutil` / `hdiutil` (missing = fatal
125/// system state) can produce an [`Error`].
126pub fn report() -> Result<DepReport> {
127    let arch = std::env::consts::ARCH.to_string();
128    let macos = shell_value("sw_vers", &["-productVersion"]);
129
130    let mut deps: Vec<DepStatus> = Vec::new();
131
132    // Core macOS tools — always present; missing here means the OS is
133    // misconfigured.
134    for (name, hint) in [
135        ("diskutil", "System tool — should be at /usr/sbin/diskutil"),
136        ("hdiutil", "System tool — should be at /usr/bin/hdiutil"),
137    ] {
138        deps.push(match runner::which(name) {
139            Ok(p) => DepStatus {
140                name: name.to_string(),
141                present: true,
142                path: Some(p),
143                install_hint: None,
144            },
145            Err(_) => DepStatus::missing(name, hint),
146        });
147    }
148
149    // ntfs-3g and its sub-tools.
150    for (name, hint) in [
151        (
152            "ntfs-3g",
153            "brew install ntfs-3g   (may also require `brew install --cask macfuse`)",
154        ),
155        ("newfs_ntfs", "ships with `brew install ntfs-3g`"),
156        (
157            "mkntfs",
158            "ships with `brew install ntfs-3g` (alias of newfs_ntfs)",
159        ),
160        ("ntfsfix", "ships with `brew install ntfs-3g`"),
161        ("fsck_ntfs", "ships with `brew install ntfs-3g`"),
162    ] {
163        deps.push(match runner::which(name) {
164            Ok(p) => DepStatus {
165                name: name.to_string(),
166                present: true,
167                path: Some(p),
168                install_hint: None,
169            },
170            Err(_) => DepStatus::missing(name, hint),
171        });
172    }
173
174    // rsync is highly recommended for `copy` (progress + rsync semantics).
175    deps.push(match runner::which("rsync") {
176        Ok(p) => DepStatus {
177            name: "rsync".to_string(),
178            present: true,
179            path: Some(p),
180            install_hint: None,
181        },
182        Err(_) => DepStatus::missing("rsync", "brew install rsync"),
183    });
184
185    // FUSE driver: macFUSE or FUSE-T. Presence is inferred from the
186    // filesystem driver directory.
187    let macfuse_path = PathBuf::from("/Library/Filesystems/macfuse.fs/Contents/Resources/ntfs");
188    let fuset_path = PathBuf::from("/Library/Filesystems/fuset.fs/Contents/Resources/ntfs");
189    let fuse_present = macfuse_path.exists() || fuset_path.exists();
190    deps.push(if fuse_present {
191        DepStatus {
192            name: "fuse-driver".to_string(),
193            present: true,
194            path: Some(if macfuse_path.exists() {
195                macfuse_path
196            } else {
197                fuset_path
198            }),
199            install_hint: None,
200        }
201    } else {
202        DepStatus::missing(
203            "fuse-driver",
204            "brew install --cask macfuse   (Intel + Apple Silicon)   OR   brew install --cask fuse-t   (Apple Silicon)",
205        )
206    });
207
208    let ready = deps.iter().all(|d| d.present);
209
210    Ok(DepReport {
211        deps,
212        ready,
213        arch,
214        macos_version: macos,
215    })
216}
217
218/// Small helper to capture the stdout of a read-only system probe.
219fn shell_value(cmd: &str, args: &[&str]) -> Option<String> {
220    use crate::runner::{RunOptions, run};
221    match run(cmd, args, &RunOptions::default()) {
222        Ok(out) if out.success() => Some(out.stdout.trim().to_string()),
223        _ => None,
224    }
225}
226
227/// Convenience: return [`Error::MissingDependency`] if the report is
228/// not ready. Used by CLI commands that need the full stack.
229pub fn require_ready() -> Result<()> {
230    let report = report()?;
231    if report.ready {
232        Ok(())
233    } else {
234        Err(Error::MissingDependency {
235            binary: "ntfs-mac dependencies".into(),
236            detail: report
237                .deps
238                .iter()
239                .filter(|d| !d.present)
240                .map(|d| d.name.clone())
241                .collect::<Vec<_>>()
242                .join(", "),
243            hint: Some(
244                "Run `ntfs-mac doctor` for details, or `./scripts/install.sh` to install.".into(),
245            ),
246            io: None,
247        })
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn fuse_driver_from_str_roundtrip() {
257        assert_eq!(FuseDriver::from("macfuse"), FuseDriver::Macfuse);
258        assert_eq!(FuseDriver::from("fuse-t"), FuseDriver::FuseT);
259        assert_eq!(FuseDriver::from("FUSE-T"), FuseDriver::FuseT);
260        assert_eq!(FuseDriver::from("auto"), FuseDriver::Auto);
261        assert_eq!(FuseDriver::from("??"), FuseDriver::Auto);
262    }
263
264    #[test]
265    fn dep_report_renders_without_panic() {
266        // The test machine may or may not have the deps; we only
267        // assert the rendering path is exercised.
268        let report = DepReport {
269            deps: vec![DepStatus::missing("ntfs-3g", "brew install ntfs-3g")],
270            ready: false,
271            arch: "aarch64".into(),
272            macos_version: Some("26.6.2".into()),
273        };
274        let text = report.to_string();
275        assert!(text.contains("ntfs-3g"));
276        assert!(text.contains("aarch64"));
277    }
278}