Skip to main content

dua/
config.rs

1use anyhow::{Context, Result, anyhow};
2
3use serde::Deserialize;
4
5use std::path::PathBuf;
6
7/// Runtime configuration used by interactive and CLI components.
8///
9/// The configuration file is optional. If it cannot be found, defaults are used.
10/// See [`Config::load`] for details on fallback and error behavior.
11///
12/// Expected TOML structure:
13///
14/// ```toml
15/// format = "binary"
16///
17/// # Controls whether Git-ignored entry detection is enabled in interactive mode.
18/// # Supported values: true, false.
19/// # If unset, behavior defaults to true.
20/// # gitignore = true
21///
22/// # Controls whether cleanup heuristics are enabled in interactive mode.
23/// # Supported values: true, false.
24/// # If unset, behavior defaults to true.
25/// # cleanup_heuristics = true
26///
27/// [keys]
28/// esc_navigates_back = true
29///
30/// [notifications]
31/// scan_finished = true
32/// delete_finished = true
33/// ```
34#[derive(Debug, Default, Deserialize)]
35#[serde(default)]
36pub struct Config {
37    /// Byte count format to use when `--format` and `DUA_FORMAT` are not set.
38    pub format: Option<crate::ByteFormat>,
39
40    /// Keybinding-related settings.
41    pub keys: KeysConfig,
42
43    /// Interactive completion-notification settings.
44    pub notifications: NotificationsConfig,
45
46    /// Whether Git-ignored entry detection is enabled.
47    ///
48    /// Supported values: `true` and `false`.
49    /// If unset, defaults to `true`.
50    pub gitignore: Option<bool>,
51
52    /// Whether cleanup heuristics are enabled.
53    ///
54    /// Supported values: `true` and `false`.
55    /// If unset, defaults to `true`.
56    pub cleanup_heuristics: Option<bool>,
57}
58
59/// Completion notifications emitted by interactive mode.
60#[derive(Debug, Deserialize)]
61#[serde(default)]
62pub struct NotificationsConfig {
63    /// Notify after initial scans and refreshes finish.
64    pub scan_finished: bool,
65    /// Notify after deletion or trash operations finish.
66    pub delete_finished: bool,
67}
68
69impl Default for NotificationsConfig {
70    fn default() -> Self {
71        Self {
72            scan_finished: true,
73            delete_finished: true,
74        }
75    }
76}
77
78impl NotificationsConfig {
79    /// Whether any notification needs terminal focus tracking.
80    pub fn any_enabled(&self) -> bool {
81        self.scan_finished || self.delete_finished
82    }
83}
84
85/// Keyboard interaction settings.
86#[derive(Debug, Deserialize)]
87#[serde(default)]
88pub struct KeysConfig {
89    /// Changes `<Esc>` behavior in the interactive UI.
90    ///
91    /// If `true`, pressing `<Esc>` in the main pane ascends to the parent directory.
92    /// If `false`, pressing `<Esc>` follows the default quit behavior, as if `q` was pressed.
93    ///
94    /// Default: `true`.
95    #[serde(default = "default_esc_navigates_back")]
96    pub esc_navigates_back: bool,
97}
98
99fn default_esc_navigates_back() -> bool {
100    true
101}
102
103impl Default for KeysConfig {
104    fn default() -> Self {
105        Self {
106            esc_navigates_back: default_esc_navigates_back(),
107        }
108    }
109}
110
111impl Config {
112    /// Load configuration from disk.
113    ///
114    /// Behavior:
115    /// - If no platform configuration directory is available, returns defaults.
116    /// - If the config file does not exist, returns defaults.
117    /// - If the config file exists but cannot be read, returns an error with path context.
118    /// - If TOML parsing fails, returns an error with path context.
119    ///
120    /// Unknown keys are ignored. Missing supported keys fall back to defaults.
121    pub fn load() -> Result<Self> {
122        let Ok(path) = Self::path() else {
123            log::info!("Configuration path couldn't be determined. Using defaults.");
124            return Ok(Config::default());
125        };
126
127        let contents = match std::fs::read_to_string(&path) {
128            Ok(c) => c,
129            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
130                log::info!(
131                    "Configuration not loaded from {}: file not found. Using defaults.",
132                    path.display()
133                );
134                return Ok(Config::default());
135            }
136            Err(e) => {
137                return Err(e)
138                    .with_context(|| format!("Failed to read config at {}", path.display()));
139            }
140        };
141
142        toml::from_str(&contents)
143            .with_context(|| format!("Failed to parse config at {}", path.display()))
144    }
145
146    /// Default TOML content used when initializing a new configuration file.
147    #[must_use]
148    pub fn default_file_content() -> &'static str {
149        concat!(
150            "# dua-cli configuration\n",
151            "#\n",
152            "# Byte count format to use when --format and DUA_FORMAT are not set.\n",
153            "# Supported values: metric, binary, bytes, gb, gib, mb, mib.\n",
154            "# format = \"binary\"\n",
155            "#\n",
156            "# Controls whether Git-ignored entry detection is enabled in interactive mode.\n",
157            "# Supported values: true, false.\n",
158            "# If unset, behavior defaults to true.\n",
159            "# gitignore = true\n",
160            "#\n",
161            "# Controls whether cleanup heuristics are enabled in interactive mode.\n",
162            "# Supported values: true, false.\n",
163            "# If unset, behavior defaults to true.\n",
164            "# cleanup_heuristics = true\n",
165            "#\n",
166            "[keys]\n",
167            "# If true, pressing <Esc> in the main pane ascends to the parent directory.\n",
168            "# If false, <Esc> follows the default quit behavior.\n",
169            "esc_navigates_back = true\n",
170            "#\n",
171            "[notifications]\n",
172            "# Send terminal notifications when interactive operations finish while unfocused.\n",
173            "scan_finished = true\n",
174            "delete_finished = true\n",
175        )
176    }
177
178    /// Return the expected configuration file location for the current platform.
179    ///
180    /// The path is:
181    /// - Linux/Unix: `$XDG_CONFIG_HOME/dua-cli/config.toml` (or equivalent fallback)
182    /// - Windows: `%APPDATA%\\dua-cli\\config.toml`
183    /// - macOS: `~/Library/Application Support/dua-cli/config.toml`
184    ///
185    /// Returns an error if the platform config directory cannot be determined.
186    pub fn path() -> Result<PathBuf> {
187        // Use the OS-specific configuration directory (e.g. $XDG_CONFIG_HOME, %APPDATA%, or
188        // ~/Library/Application Support) as provided by the `dirs` crate.
189        let config_dir = dirs::config_dir()
190            .ok_or_else(|| anyhow!("platform config directory is unavailable"))?;
191        Ok(config_dir.join("dua-cli").join("config.toml"))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::Config;
198
199    #[test]
200    fn notifications_default_to_enabled_and_can_be_disabled() {
201        let defaults: Config = toml::from_str("").expect("valid config");
202        assert!(defaults.notifications.scan_finished);
203        assert!(defaults.notifications.delete_finished);
204
205        let configured: Config = toml::from_str(
206            r"
207            [notifications]
208            scan_finished = false
209            delete_finished = false
210            ",
211        )
212        .expect("valid config");
213        assert!(!configured.notifications.scan_finished);
214        assert!(!configured.notifications.delete_finished);
215    }
216
217    #[test]
218    fn notifications_are_enabled_if_any_notification_is_enabled() {
219        let disabled: Config = toml::from_str(
220            r"
221            [notifications]
222            scan_finished = false
223            delete_finished = false
224            ",
225        )
226        .expect("valid config");
227        assert!(!disabled.notifications.any_enabled());
228
229        let partly_enabled: Config = toml::from_str(
230            r"
231            [notifications]
232            scan_finished = false
233            ",
234        )
235        .expect("valid config");
236        assert!(partly_enabled.notifications.any_enabled());
237    }
238
239    #[test]
240    fn parses_configured_byte_format() {
241        let config: Config = toml::from_str(
242            r#"
243            format = "mb"
244
245            [keys]
246            esc_navigates_back = false
247            "#,
248        )
249        .expect("valid config");
250
251        assert_eq!(config.format, Some(crate::ByteFormat::MB));
252        assert!(!config.keys.esc_navigates_back);
253    }
254
255    #[test]
256    fn parses_configured_gitignore() {
257        let config: Config = toml::from_str(
258            r#"
259            format = "mb"
260            gitignore = false
261
262            [keys]
263            esc_navigates_back = false
264            "#,
265        )
266        .expect("valid config");
267
268        assert_eq!(config.gitignore, Some(false));
269    }
270
271    #[test]
272    fn gitignore_defaults_to_enabled() {
273        let config: Config = toml::from_str(
274            r#"
275            format = "mb"
276
277            [keys]
278            esc_navigates_back = false
279            "#,
280        )
281        .expect("valid config");
282
283        assert_eq!(config.gitignore, None);
284    }
285
286    #[test]
287    fn parses_configured_cleanup_heuristics() {
288        let config: Config = toml::from_str(
289            r#"
290            format = "mb"
291            cleanup_heuristics = false
292
293            [keys]
294            esc_navigates_back = false
295            "#,
296        )
297        .expect("valid config");
298
299        assert_eq!(config.cleanup_heuristics, Some(false));
300    }
301
302    #[test]
303    fn cleanup_heuristics_defaults_to_enabled() {
304        let config: Config = toml::from_str(
305            r#"
306            format = "mb"
307
308            [keys]
309            esc_navigates_back = false
310            "#,
311        )
312        .expect("valid config");
313
314        assert_eq!(config.cleanup_heuristics, None);
315    }
316}