Skip to main content

ntfs_mac_core/
config.rs

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