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#[derive(Debug, Default, Deserialize)]
31#[serde(default)]
32pub struct Config {
33    /// Byte count format to use when `--format` and `DUA_FORMAT` are not set.
34    pub format: Option<crate::ByteFormat>,
35
36    /// Keybinding-related settings.
37    pub keys: KeysConfig,
38
39    /// Whether Git-ignored entry detection is enabled.
40    ///
41    /// Supported values: `true` and `false`.
42    /// If unset, defaults to `true`.
43    pub gitignore: Option<bool>,
44
45    /// Whether cleanup heuristics are enabled.
46    ///
47    /// Supported values: `true` and `false`.
48    /// If unset, defaults to `true`.
49    pub cleanup_heuristics: Option<bool>,
50}
51
52/// Keyboard interaction settings.
53#[derive(Debug, Deserialize)]
54#[serde(default)]
55pub struct KeysConfig {
56    /// Changes `<Esc>` behavior in the interactive UI.
57    ///
58    /// If `true`, pressing `<Esc>` in the main pane ascends to the parent directory.
59    /// If `false`, pressing `<Esc>` follows the default quit behavior, as if `q` was pressed.
60    ///
61    /// Default: `true`.
62    #[serde(default = "default_esc_navigates_back")]
63    pub esc_navigates_back: bool,
64}
65
66fn default_esc_navigates_back() -> bool {
67    true
68}
69
70impl Default for KeysConfig {
71    fn default() -> Self {
72        Self {
73            esc_navigates_back: default_esc_navigates_back(),
74        }
75    }
76}
77
78impl Config {
79    /// Load configuration from disk.
80    ///
81    /// Behavior:
82    /// - If no platform configuration directory is available, returns defaults.
83    /// - If the config file does not exist, returns defaults.
84    /// - If the config file exists but cannot be read, returns an error with path context.
85    /// - If TOML parsing fails, returns an error with path context.
86    ///
87    /// Unknown keys are ignored. Missing supported keys fall back to defaults.
88    pub fn load() -> Result<Self> {
89        let Ok(path) = Self::path() else {
90            log::info!("Configuration path couldn't be determined. Using defaults.");
91            return Ok(Config::default());
92        };
93
94        let contents = match std::fs::read_to_string(&path) {
95            Ok(c) => c,
96            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
97                log::info!(
98                    "Configuration not loaded from {}: file not found. Using defaults.",
99                    path.display()
100                );
101                return Ok(Config::default());
102            }
103            Err(e) => {
104                return Err(e)
105                    .with_context(|| format!("Failed to read config at {}", path.display()));
106            }
107        };
108
109        toml::from_str(&contents)
110            .with_context(|| format!("Failed to parse config at {}", path.display()))
111    }
112
113    /// Default TOML content used when initializing a new configuration file.
114    pub fn default_file_content() -> &'static str {
115        concat!(
116            "# dua-cli configuration\n",
117            "#\n",
118            "# Byte count format to use when --format and DUA_FORMAT are not set.\n",
119            "# Supported values: metric, binary, bytes, gb, gib, mb, mib.\n",
120            "# format = \"binary\"\n",
121            "#\n",
122            "# Controls whether Git-ignored entry detection is enabled in interactive mode.\n",
123            "# Supported values: true, false.\n",
124            "# If unset, behavior defaults to true.\n",
125            "# gitignore = true\n",
126            "#\n",
127            "# Controls whether cleanup heuristics are enabled in interactive mode.\n",
128            "# Supported values: true, false.\n",
129            "# If unset, behavior defaults to true.\n",
130            "# cleanup_heuristics = true\n",
131            "#\n",
132            "[keys]\n",
133            "# If true, pressing <Esc> in the main pane ascends to the parent directory.\n",
134            "# If false, <Esc> follows the default quit behavior.\n",
135            "esc_navigates_back = true\n",
136        )
137    }
138
139    /// Return the expected configuration file location for the current platform.
140    ///
141    /// The path is:
142    /// - Linux/Unix: `$XDG_CONFIG_HOME/dua-cli/config.toml` (or equivalent fallback)
143    /// - Windows: `%APPDATA%\\dua-cli\\config.toml`
144    /// - macOS: `~/Library/Application Support/dua-cli/config.toml`
145    ///
146    /// Returns an error if the platform config directory cannot be determined.
147    pub fn path() -> Result<PathBuf> {
148        // Use the OS-specific configuration directory (e.g. $XDG_CONFIG_HOME, %APPDATA%, or
149        // ~/Library/Application Support) as provided by the `dirs` crate.
150        let config_dir = dirs::config_dir()
151            .ok_or_else(|| anyhow!("platform config directory is unavailable"))?;
152        Ok(config_dir.join("dua-cli").join("config.toml"))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::Config;
159
160    #[test]
161    fn parses_configured_byte_format() {
162        let config: Config = toml::from_str(
163            r#"
164            format = "mb"
165
166            [keys]
167            esc_navigates_back = false
168            "#,
169        )
170        .expect("valid config");
171
172        assert_eq!(config.format, Some(crate::ByteFormat::MB));
173        assert!(!config.keys.esc_navigates_back);
174    }
175
176    #[test]
177    fn parses_configured_gitignore() {
178        let config: Config = toml::from_str(
179            r#"
180            format = "mb"
181            gitignore = false
182
183            [keys]
184            esc_navigates_back = false
185            "#,
186        )
187        .expect("valid config");
188
189        assert_eq!(config.gitignore, Some(false));
190    }
191
192    #[test]
193    fn gitignore_defaults_to_enabled() {
194        let config: Config = toml::from_str(
195            r#"
196            format = "mb"
197
198            [keys]
199            esc_navigates_back = false
200            "#,
201        )
202        .expect("valid config");
203
204        assert_eq!(config.gitignore, None);
205    }
206
207    #[test]
208    fn parses_configured_cleanup_heuristics() {
209        let config: Config = toml::from_str(
210            r#"
211            format = "mb"
212            cleanup_heuristics = false
213
214            [keys]
215            esc_navigates_back = false
216            "#,
217        )
218        .expect("valid config");
219
220        assert_eq!(config.cleanup_heuristics, Some(false));
221    }
222
223    #[test]
224    fn cleanup_heuristics_defaults_to_enabled() {
225        let config: Config = toml::from_str(
226            r#"
227            format = "mb"
228
229            [keys]
230            esc_navigates_back = false
231            "#,
232        )
233        .expect("valid config");
234
235        assert_eq!(config.cleanup_heuristics, None);
236    }
237}