use std::fmt;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::runner;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FuseDriver {
Macfuse,
FuseT,
#[default]
Auto,
}
impl From<&str> for FuseDriver {
fn from(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"macfuse" => Self::Macfuse,
"fuse-t" | "fuset" => Self::FuseT,
_ => Self::Auto,
}
}
}
impl FuseDriver {
pub fn brew_install_command(&self) -> String {
match self {
Self::Macfuse => "brew install --cask macfuse".into(),
Self::FuseT => "brew install --cask fuse-t".into(),
Self::Auto => "brew install --cask macfuse # or fuse-t on Apple Silicon".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepStatus {
pub name: String,
pub present: bool,
pub path: Option<PathBuf>,
pub install_hint: Option<String>,
}
impl DepStatus {
fn missing(name: &str, hint: impl Into<String>) -> Self {
Self {
name: name.to_string(),
present: false,
path: None,
install_hint: Some(hint.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepReport {
pub deps: Vec<DepStatus>,
pub ready: bool,
pub arch: String,
pub macos_version: Option<String>,
}
impl fmt::Display for DepReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"Platform: macOS {} on {}",
self.macos_version.as_deref().unwrap_or("unknown"),
self.arch
)?;
writeln!(f, "\nDependencies:")?;
for d in &self.deps {
let icon = if d.present { "✓" } else { "✗" };
let loc = d
.path
.as_ref()
.map(|p| format!(" ({})", p.display()))
.unwrap_or_default();
writeln!(
f,
" {} {:<20} {}",
icon,
d.name,
if d.present {
loc
} else {
format!(
"missing — {}",
d.install_hint.as_deref().unwrap_or("no hint")
)
}
)?;
}
writeln!(f, "\nReady: {}\n", self.ready)?;
Ok(())
}
}
pub fn report() -> Result<DepReport> {
let arch = std::env::consts::ARCH.to_string();
let macos = shell_value("sw_vers", &["-productVersion"]);
let mut deps: Vec<DepStatus> = Vec::new();
for (name, hint) in [
("diskutil", "System tool — should be at /usr/sbin/diskutil"),
("hdiutil", "System tool — should be at /usr/bin/hdiutil"),
] {
deps.push(match runner::which(name) {
Ok(p) => DepStatus {
name: name.to_string(),
present: true,
path: Some(p),
install_hint: None,
},
Err(_) => DepStatus::missing(name, hint),
});
}
for (name, hint) in [
(
"ntfs-3g",
"brew install ntfs-3g (may also require `brew install --cask macfuse`)",
),
("newfs_ntfs", "ships with `brew install ntfs-3g`"),
(
"mkntfs",
"ships with `brew install ntfs-3g` (alias of newfs_ntfs)",
),
("ntfsfix", "ships with `brew install ntfs-3g`"),
("fsck_ntfs", "ships with `brew install ntfs-3g`"),
] {
deps.push(match runner::which(name) {
Ok(p) => DepStatus {
name: name.to_string(),
present: true,
path: Some(p),
install_hint: None,
},
Err(_) => DepStatus::missing(name, hint),
});
}
deps.push(match runner::which("rsync") {
Ok(p) => DepStatus {
name: "rsync".to_string(),
present: true,
path: Some(p),
install_hint: None,
},
Err(_) => DepStatus::missing("rsync", "brew install rsync"),
});
let macfuse_path = PathBuf::from("/Library/Filesystems/macfuse.fs/Contents/Resources/ntfs");
let fuset_path = PathBuf::from("/Library/Filesystems/fuset.fs/Contents/Resources/ntfs");
let fuse_present = macfuse_path.exists() || fuset_path.exists();
deps.push(if fuse_present {
DepStatus {
name: "fuse-driver".to_string(),
present: true,
path: Some(if macfuse_path.exists() {
macfuse_path
} else {
fuset_path
}),
install_hint: None,
}
} else {
DepStatus::missing(
"fuse-driver",
"brew install --cask macfuse (Intel + Apple Silicon) OR brew install --cask fuse-t (Apple Silicon)",
)
});
let ready = deps.iter().all(|d| d.present);
Ok(DepReport {
deps,
ready,
arch,
macos_version: macos,
})
}
fn shell_value(cmd: &str, args: &[&str]) -> Option<String> {
use crate::runner::{RunOptions, run};
match run(cmd, args, &RunOptions::default()) {
Ok(out) if out.success() => Some(out.stdout.trim().to_string()),
_ => None,
}
}
pub fn require_ready() -> Result<()> {
let report = report()?;
if report.ready {
Ok(())
} else {
Err(Error::MissingDependency {
binary: "ntfs-mac dependencies".into(),
detail: report
.deps
.iter()
.filter(|d| !d.present)
.map(|d| d.name.clone())
.collect::<Vec<_>>()
.join(", "),
hint: Some(
"Run `ntfs-mac doctor` for details, or `./scripts/install.sh` to install.".into(),
),
io: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuse_driver_from_str_roundtrip() {
assert_eq!(FuseDriver::from("macfuse"), FuseDriver::Macfuse);
assert_eq!(FuseDriver::from("fuse-t"), FuseDriver::FuseT);
assert_eq!(FuseDriver::from("FUSE-T"), FuseDriver::FuseT);
assert_eq!(FuseDriver::from("auto"), FuseDriver::Auto);
assert_eq!(FuseDriver::from("??"), FuseDriver::Auto);
}
#[test]
fn dep_report_renders_without_panic() {
let report = DepReport {
deps: vec![DepStatus::missing("ntfs-3g", "brew install ntfs-3g")],
ready: false,
arch: "aarch64".into(),
macos_version: Some("26.6.2".into()),
};
let text = report.to_string();
assert!(text.contains("ntfs-3g"));
assert!(text.contains("aarch64"));
}
}