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    pub fn default_file_content() -> &'static str {
148        concat!(
149            "# dua-cli configuration\n",
150            "#\n",
151            "# Byte count format to use when --format and DUA_FORMAT are not set.\n",
152            "# Supported values: metric, binary, bytes, gb, gib, mb, mib.\n",
153            "# format = \"binary\"\n",
154            "#\n",
155            "# Controls whether Git-ignored entry detection is enabled in interactive mode.\n",
156            "# Supported values: true, false.\n",
157            "# If unset, behavior defaults to true.\n",
158            "# gitignore = true\n",
159            "#\n",
160            "# Controls whether cleanup heuristics are enabled in interactive mode.\n",
161            "# Supported values: true, false.\n",
162            "# If unset, behavior defaults to true.\n",
163            "# cleanup_heuristics = true\n",
164            "#\n",
165            "[keys]\n",
166            "# If true, pressing <Esc> in the main pane ascends to the parent directory.\n",
167            "# If false, <Esc> follows the default quit behavior.\n",
168            "esc_navigates_back = true\n",
169            "#\n",
170            "[notifications]\n",
171            "# Send terminal notifications when interactive operations finish while unfocused.\n",
172            "scan_finished = true\n",
173            "delete_finished = true\n",
174        )
175    }
176
177    /// Return the expected configuration file location for the current platform.
178    ///
179    /// The path is:
180    /// - Linux/Unix: `$XDG_CONFIG_HOME/dua-cli/config.toml` (or equivalent fallback)
181    /// - Windows: `%APPDATA%\\dua-cli\\config.toml`
182    /// - macOS: `~/Library/Application Support/dua-cli/config.toml`
183    ///
184    /// Returns an error if the platform config directory cannot be determined.
185    pub fn path() -> Result<PathBuf> {
186        // Use the OS-specific configuration directory (e.g. $XDG_CONFIG_HOME, %APPDATA%, or
187        // ~/Library/Application Support) as provided by the `dirs` crate.
188        let config_dir = dirs::config_dir()
189            .ok_or_else(|| anyhow!("platform config directory is unavailable"))?;
190        Ok(config_dir.join("dua-cli").join("config.toml"))
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::Config;
197
198    #[test]
199    fn notifications_default_to_enabled_and_can_be_disabled() {
200        let defaults: Config = toml::from_str("").expect("valid config");
201        assert!(defaults.notifications.scan_finished);
202        assert!(defaults.notifications.delete_finished);
203
204        let configured: Config = toml::from_str(
205            r#"
206            [notifications]
207            scan_finished = false
208            delete_finished = false
209            "#,
210        )
211        .expect("valid config");
212        assert!(!configured.notifications.scan_finished);
213        assert!(!configured.notifications.delete_finished);
214    }
215
216    #[test]
217    fn notifications_are_enabled_if_any_notification_is_enabled() {
218        let disabled: Config = toml::from_str(
219            r#"
220            [notifications]
221            scan_finished = false
222            delete_finished = false
223            "#,
224        )
225        .expect("valid config");
226        assert!(!disabled.notifications.any_enabled());
227
228        let partly_enabled: Config = toml::from_str(
229            r#"
230            [notifications]
231            scan_finished = false
232            "#,
233        )
234        .expect("valid config");
235        assert!(partly_enabled.notifications.any_enabled());
236    }
237
238    #[test]
239    fn parses_configured_byte_format() {
240        let config: Config = toml::from_str(
241            r#"
242            format = "mb"
243
244            [keys]
245            esc_navigates_back = false
246            "#,
247        )
248        .expect("valid config");
249
250        assert_eq!(config.format, Some(crate::ByteFormat::MB));
251        assert!(!config.keys.esc_navigates_back);
252    }
253
254    #[test]
255    fn parses_configured_gitignore() {
256        let config: Config = toml::from_str(
257            r#"
258            format = "mb"
259            gitignore = false
260
261            [keys]
262            esc_navigates_back = false
263            "#,
264        )
265        .expect("valid config");
266
267        assert_eq!(config.gitignore, Some(false));
268    }
269
270    #[test]
271    fn gitignore_defaults_to_enabled() {
272        let config: Config = toml::from_str(
273            r#"
274            format = "mb"
275
276            [keys]
277            esc_navigates_back = false
278            "#,
279        )
280        .expect("valid config");
281
282        assert_eq!(config.gitignore, None);
283    }
284
285    #[test]
286    fn parses_configured_cleanup_heuristics() {
287        let config: Config = toml::from_str(
288            r#"
289            format = "mb"
290            cleanup_heuristics = false
291
292            [keys]
293            esc_navigates_back = false
294            "#,
295        )
296        .expect("valid config");
297
298        assert_eq!(config.cleanup_heuristics, Some(false));
299    }
300
301    #[test]
302    fn cleanup_heuristics_defaults_to_enabled() {
303        let config: Config = toml::from_str(
304            r#"
305            format = "mb"
306
307            [keys]
308            esc_navigates_back = false
309            "#,
310        )
311        .expect("valid config");
312
313        assert_eq!(config.cleanup_heuristics, None);
314    }
315}