Skip to main content

ntfs_mac_core/
config.rs

1//! Persistent configuration at `~/.config/ntfs-mac/config.toml`.
2//!
3//! Kept intentionally tiny — 5 fields, no schema versioning. Add
4//! fields freely; unknown fields are ignored on read.
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::{ConfigError, Error, Result};
11
12/// Default config location. Override via `XDG_CONFIG_HOME` if set.
13pub fn default_config_path() -> PathBuf {
14    if let Some(home) = dirs::config_dir() {
15        home.join("ntfs-mac").join("config.toml")
16    } else {
17        // Fallback for the (very rare) case where config_dir() returns
18        // None — still deterministic so tests can rely on it.
19        PathBuf::from("/tmp/ntfs-mac/config.toml")
20    }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Config {
25    /// Default mount point base. NTFS volumes mount to
26    /// `<base>/<volume_label>` by default.
27    #[serde(default = "default_mount_base")]
28    pub mount_base: String,
29
30    /// Extra `ntfs-3g` mount options, space-separated.
31    /// Examples: `"noowners", "uid=501", "gid=20"`.
32    #[serde(default)]
33    pub mount_options: Vec<String>,
34
35    /// Preferred FUSE driver when both macFUSE and FUSE-T are
36    /// installed. `"fuse-t"` (Apple Silicon default), `"macfuse"`, or
37    /// `"auto"` (let ntfs-3g decide).
38    #[serde(default = "default_fuse_driver")]
39    pub fuse_driver: String,
40
41    /// Require interactive confirmation before `format`/`fix` even
42    /// when the caller supplies a token. Set to `false` only in
43    /// scripted/CI environments.
44    #[serde(default = "default_true")]
45    pub require_confirmation: bool,
46
47    /// Emit coloured terminal output. Only affects the CLI.
48    #[serde(default = "default_true")]
49    pub color: bool,
50}
51
52impl Default for Config {
53    fn default() -> Self {
54        Self {
55            mount_base: default_mount_base(),
56            mount_options: Vec::new(),
57            fuse_driver: default_fuse_driver(),
58            require_confirmation: default_true(),
59            color: default_true(),
60        }
61    }
62}
63
64fn default_mount_base() -> String {
65    "/Volumes".to_string()
66}
67
68fn default_fuse_driver() -> String {
69    "auto".to_string()
70}
71
72fn default_true() -> bool {
73    true
74}
75
76impl Config {
77    /// Load config from the default path. Missing file → defaults.
78    pub fn load() -> Result<Self> {
79        load_config(&default_config_path())
80    }
81
82    /// Save to the default path. Creates parent dirs.
83    pub fn save(&self) -> Result<()> {
84        save_config(self, &default_config_path())
85    }
86
87    /// Override the mount base (used by CLI `--mount-base`).
88    #[must_use]
89    pub fn with_mount_base(mut self, base: String) -> Self {
90        self.mount_base = base;
91        self
92    }
93}
94
95/// Load a TOML config from `path`. Missing file returns defaults.
96pub fn load_config(path: &std::path::Path) -> Result<Config> {
97    let Ok(bytes) = std::fs::read(path) else {
98        return Ok(Config::default());
99    };
100    match toml::from_str(&String::from_utf8_lossy(&bytes)) {
101        Ok(c) => Ok(c),
102        Err(e) => Err(Error::Serde(e.to_string())),
103    }
104}
105
106/// Save a config to `path`, creating parent dirs.
107pub fn save_config(cfg: &Config, path: &std::path::Path) -> Result<()> {
108    if let Some(parent) = path.parent() {
109        std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
110            path: path.to_path_buf(),
111            reason: e.to_string(),
112        })?;
113    }
114    let text = toml::to_string_pretty(cfg).map_err(|e| ConfigError::Write {
115        path: path.to_path_buf(),
116        reason: e.to_string(),
117    })?;
118    std::fs::write(path, text).map_err(|e| ConfigError::Write {
119        path: path.to_path_buf(),
120        reason: e.to_string(),
121    })?;
122    Ok(())
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn defaults_are_sensible() {
131        let c = Config::default();
132        assert_eq!(c.mount_base, "/Volumes");
133        assert_eq!(c.fuse_driver, "auto");
134        assert!(c.require_confirmation);
135        assert!(c.color);
136        assert!(c.mount_options.is_empty());
137    }
138
139    #[test]
140    fn round_trip_persists_fields() {
141        let tmp = tempfile::tempdir().unwrap();
142        let path = tmp.path().join("config.toml");
143        let mut cfg = Config::default();
144        cfg.mount_base = "/tmp/vol".into();
145        cfg.mount_options.push("noowners".into());
146        cfg.require_confirmation = false;
147        save_config(&cfg, &path).unwrap();
148        let loaded = load_config(&path).unwrap();
149        assert_eq!(loaded.mount_base, "/tmp/vol");
150        assert_eq!(loaded.mount_options, vec!["noowners".to_string()]);
151        assert!(!loaded.require_confirmation);
152    }
153
154    #[test]
155    fn missing_file_returns_defaults() {
156        let cfg = load_config(std::path::Path::new("/nonexistent/path/config.toml")).unwrap();
157        assert_eq!(cfg.mount_base, "/Volumes");
158    }
159
160    #[test]
161    fn unknown_fields_are_ignored() {
162        let tmp = tempfile::tempdir().unwrap();
163        let path = tmp.path().join("config.toml");
164        std::fs::write(
165            &path,
166            r#"
167            mount_base = "/Volumes"
168            totally_unknown_field = 42
169            "#,
170        )
171        .unwrap();
172        let cfg = load_config(&path).unwrap();
173        assert_eq!(cfg.mount_base, "/Volumes");
174    }
175}