1use std::fmt;
11use std::path::PathBuf;
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{Error, Result};
17use crate::runner;
18
19const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum FuseDriver {
33 Macfuse,
35 FuseT,
37 #[default]
39 Auto,
40}
41
42impl From<&str> for FuseDriver {
43 fn from(s: &str) -> Self {
44 match s.to_ascii_lowercase().as_str() {
45 "macfuse" => Self::Macfuse,
46 "fuse-t" | "fuset" => Self::FuseT,
47 _ => Self::Auto,
48 }
49 }
50}
51
52impl FuseDriver {
53 pub fn brew_install_command(&self) -> String {
55 match self {
56 Self::Macfuse => "brew install --cask macfuse".into(),
57 Self::FuseT => "brew install --cask fuse-t".into(),
58 Self::Auto => "brew install --cask macfuse # or fuse-t on Apple Silicon".into(),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DepStatus {
66 pub name: String,
68 pub present: bool,
70 pub path: Option<PathBuf>,
72 pub install_hint: Option<String>,
74}
75
76impl DepStatus {
77 fn missing(name: &str, hint: impl Into<String>) -> Self {
78 Self {
79 name: name.to_string(),
80 present: false,
81 path: None,
82 install_hint: Some(hint.into()),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DepReport {
90 pub deps: Vec<DepStatus>,
92 pub ready: bool,
94 pub arch: String,
96 pub macos_version: Option<String>,
98}
99
100impl fmt::Display for DepReport {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 writeln!(
104 f,
105 "Platform: macOS {} on {}",
106 self.macos_version.as_deref().unwrap_or("unknown"),
107 self.arch
108 )?;
109 writeln!(f, "\nDependencies:")?;
110 for d in &self.deps {
111 let icon = if d.present { "✓" } else { "✗" };
112 let loc = d
113 .path
114 .as_ref()
115 .map(|p| format!(" ({})", p.display()))
116 .unwrap_or_default();
117 writeln!(
118 f,
119 " {} {:<20} {}",
120 icon,
121 d.name,
122 if d.present {
123 loc
124 } else {
125 format!(
126 "missing — {}",
127 d.install_hint.as_deref().unwrap_or("no hint")
128 )
129 }
130 )?;
131 }
132 writeln!(f, "\nReady: {}\n", self.ready)?;
133 Ok(())
134 }
135}
136
137pub fn report() -> Result<DepReport> {
141 let arch = std::env::consts::ARCH.to_string();
142 let macos = shell_value("sw_vers", &["-productVersion"]);
143
144 let mut deps: Vec<DepStatus> = Vec::new();
145
146 for (name, hint) in [
149 ("diskutil", "System tool — should be at /usr/sbin/diskutil"),
150 ("hdiutil", "System tool — should be at /usr/bin/hdiutil"),
151 ] {
152 deps.push(match runner::which(name) {
153 Ok(p) => DepStatus {
154 name: name.to_string(),
155 present: true,
156 path: Some(p),
157 install_hint: None,
158 },
159 Err(_) => DepStatus::missing(name, hint),
160 });
161 }
162
163 for (name, hint) in [
165 (
166 "ntfs-3g",
167 "brew install ntfs-3g (may also require `brew install --cask macfuse`)",
168 ),
169 ("newfs_ntfs", "ships with `brew install ntfs-3g`"),
170 (
171 "mkntfs",
172 "ships with `brew install ntfs-3g` (alias of newfs_ntfs)",
173 ),
174 ("ntfsfix", "ships with `brew install ntfs-3g`"),
175 ("fsck_ntfs", "ships with `brew install ntfs-3g`"),
176 ] {
177 deps.push(match runner::which(name) {
178 Ok(p) => DepStatus {
179 name: name.to_string(),
180 present: true,
181 path: Some(p),
182 install_hint: None,
183 },
184 Err(_) => DepStatus::missing(name, hint),
185 });
186 }
187
188 deps.push(match runner::which("rsync") {
190 Ok(p) => DepStatus {
191 name: "rsync".to_string(),
192 present: true,
193 path: Some(p),
194 install_hint: None,
195 },
196 Err(_) => DepStatus::missing("rsync", "brew install rsync"),
197 });
198
199 let macfuse_path = PathBuf::from("/Library/Filesystems/macfuse.fs/Contents/Resources/ntfs");
202 let fuset_path = PathBuf::from("/Library/Filesystems/fuset.fs/Contents/Resources/ntfs");
203 let fuse_present = macfuse_path.exists() || fuset_path.exists();
204 deps.push(if fuse_present {
205 DepStatus {
206 name: "fuse-driver".to_string(),
207 present: true,
208 path: Some(if macfuse_path.exists() {
209 macfuse_path
210 } else {
211 fuset_path
212 }),
213 install_hint: None,
214 }
215 } else {
216 DepStatus::missing(
217 "fuse-driver",
218 "brew install --cask macfuse (Intel + Apple Silicon) OR brew install --cask fuse-t (Apple Silicon)",
219 )
220 });
221
222 let ready = deps.iter().all(|d| d.present);
223
224 Ok(DepReport {
225 deps,
226 ready,
227 arch,
228 macos_version: macos,
229 })
230}
231
232fn shell_value(cmd: &str, args: &[&str]) -> Option<String> {
237 use crate::runner::{RunOptions, run};
238 let opts = RunOptions {
239 timeout: Some(PROBE_TIMEOUT),
240 ..Default::default()
241 };
242 match run(cmd, args, &opts) {
243 Ok(out) if out.success() => Some(out.stdout.trim().to_string()),
244 _ => None,
245 }
246}
247
248pub fn require_ready() -> Result<()> {
251 let report = report()?;
252 if report.ready {
253 Ok(())
254 } else {
255 Err(Error::MissingDependency {
256 binary: "ntfs-mac dependencies".into(),
257 detail: report
258 .deps
259 .iter()
260 .filter(|d| !d.present)
261 .map(|d| d.name.clone())
262 .collect::<Vec<_>>()
263 .join(", "),
264 hint: Some(
265 "Run `ntfs-mac doctor` for details, or `./scripts/install.sh` to install.".into(),
266 ),
267 io: None,
268 })
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn fuse_driver_from_str_roundtrip() {
278 assert_eq!(FuseDriver::from("macfuse"), FuseDriver::Macfuse);
279 assert_eq!(FuseDriver::from("fuse-t"), FuseDriver::FuseT);
280 assert_eq!(FuseDriver::from("FUSE-T"), FuseDriver::FuseT);
281 assert_eq!(FuseDriver::from("auto"), FuseDriver::Auto);
282 assert_eq!(FuseDriver::from("??"), FuseDriver::Auto);
283 }
284
285 #[test]
286 fn dep_report_renders_without_panic() {
287 let report = DepReport {
290 deps: vec![DepStatus::missing("ntfs-3g", "brew install ntfs-3g")],
291 ready: false,
292 arch: "aarch64".into(),
293 macos_version: Some("26.6.2".into()),
294 };
295 let text = report.to_string();
296 assert!(text.contains("ntfs-3g"));
297 assert!(text.contains("aarch64"));
298 }
299
300 #[test]
301 fn probe_timeout_is_bounded_for_the_ui() {
302 assert!(
306 PROBE_TIMEOUT <= Duration::from_secs(10),
307 "a probe longer than 10s will look hung in the GUI"
308 );
309 }
310
311 #[test]
312 fn shell_value_degrades_gracefully() {
313 assert_eq!(
316 shell_value("definitely-not-a-real-binary-ntfs-mac", &[]),
317 None
318 );
319 }
320}