1use anyhow::{Context, Result, anyhow};
2
3use serde::Deserialize;
4
5use std::path::PathBuf;
6
7#[derive(Debug, Default, Deserialize)]
35#[serde(default)]
36pub struct Config {
37 pub format: Option<crate::ByteFormat>,
39
40 pub keys: KeysConfig,
42
43 pub notifications: NotificationsConfig,
45
46 pub gitignore: Option<bool>,
51
52 pub cleanup_heuristics: Option<bool>,
57}
58
59#[derive(Debug, Deserialize)]
61#[serde(default)]
62pub struct NotificationsConfig {
63 pub scan_finished: bool,
65 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 pub fn any_enabled(&self) -> bool {
81 self.scan_finished || self.delete_finished
82 }
83}
84
85#[derive(Debug, Deserialize)]
87#[serde(default)]
88pub struct KeysConfig {
89 #[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 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 #[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 pub fn path() -> Result<PathBuf> {
187 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}