Skip to main content

ntfs_mac_core/
deps.rs

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