1use std::fmt;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13use crate::runner;
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum FuseDriver {
19 Macfuse,
21 FuseT,
23 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct DepStatus {
52 pub name: String,
54 pub present: bool,
56 pub path: Option<PathBuf>,
58 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#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DepReport {
76 pub deps: Vec<DepStatus>,
78 pub ready: bool,
80 pub arch: String,
82 pub macos_version: Option<String>,
84}
85
86impl fmt::Display for DepReport {
87 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
123pub 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 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 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 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 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
218fn 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
227pub 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 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}