1use std::fmt;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16use crate::runner;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum FuseDriver {
22 Macfuse,
24 FuseT,
26 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DepStatus {
55 pub name: String,
57 pub present: bool,
59 pub path: Option<PathBuf>,
61 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#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct DepReport {
79 pub deps: Vec<DepStatus>,
81 pub ready: bool,
83 pub arch: String,
85 pub macos_version: Option<String>,
87}
88
89impl fmt::Display for DepReport {
90 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
126pub 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 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 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 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 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
221fn 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
230pub 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 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}