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 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 pub fn path() -> Result<PathBuf> {
186 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}