1use crate::core::tags::default_tag_names;
5use color_eyre::eyre::{Result, WrapErr};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9fn config_home() -> Option<PathBuf> {
13 std::env::var_os("XDG_CONFIG_HOME")
14 .map(PathBuf::from)
15 .filter(|p| !p.as_os_str().is_empty())
16 .or_else(dirs::config_dir)
17}
18
19#[derive(Debug, Clone, Default)]
21pub struct CliOptions {
22 pub tags: Option<Vec<String>>,
24 pub include: Option<Vec<String>>,
26 pub exclude: Option<Vec<String>>,
28 pub json: bool,
30 pub flat: bool,
32 pub no_color: bool,
34 pub ignore_case: bool,
36 pub no_require_colon: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42#[serde(default)]
43pub struct Config {
44 pub tags: Vec<String>,
46 pub include: Vec<String>,
48 pub exclude: Vec<String>,
50 pub json: bool,
52 pub flat: bool,
54 pub no_color: bool,
56 pub custom_pattern: Option<String>,
59 pub ignore_case: bool,
61 pub require_colon: bool,
63}
64
65impl Config {
66 pub fn new() -> Self {
69 Self {
70 tags: default_tag_names(),
71 include: Vec::new(),
72 exclude: Vec::new(),
73 json: false,
74 flat: false,
75 no_color: false,
76 custom_pattern: None,
77 ignore_case: false,
78 require_colon: true,
79 }
80 }
81
82 pub fn load(start_path: &Path) -> Result<Option<Self>> {
93 let local_configs = [
94 start_path.join(".todorc"),
95 start_path.join(".todorc.json"),
96 start_path.join(".todorc.toml"),
97 ];
98
99 for config_path in &local_configs {
100 if config_path.exists() {
101 return Self::load_from_file(config_path).map(Some);
102 }
103 }
104
105 if let Some(parent) = start_path.parent()
106 && parent != start_path
107 && let Ok(Some(config)) = Self::load(parent)
108 {
109 return Ok(Some(config));
110 }
111
112 if let Some(config_dir) = config_home() {
113 let global_configs = [
114 config_dir.join("todo-tree").join("config.json"),
115 config_dir.join("todo-tree").join("config.toml"),
116 ];
117
118 for config_path in &global_configs {
119 if config_path.exists() {
120 return Self::load_from_file(config_path).map(Some);
121 }
122 }
123 }
124
125 Ok(None)
126 }
127
128 pub fn load_from_file(path: &Path) -> Result<Self> {
132 let content = std::fs::read_to_string(path)
133 .wrap_err_with(|| format!("Failed to read config file: {}", path.display()))?;
134
135 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
136 let parse_result: Result<Self> = if extension == "toml" {
137 toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e))
138 } else {
139 serde_json::from_str(&content)
140 .map_err(|e| color_eyre::eyre::eyre!(e))
141 .or_else(|_| toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e)))
142 };
143
144 parse_result.wrap_err_with(|| format!("Failed to parse config: {}", path.display()))
145 }
146
147 pub fn merge_with_cli(&mut self, cli: CliOptions) {
149 if let Some(tags) = cli.tags
150 && !tags.is_empty()
151 {
152 self.tags = tags;
153 }
154
155 if let Some(include) = cli.include
156 && !include.is_empty()
157 {
158 self.include = include;
159 }
160
161 if let Some(exclude) = cli.exclude
162 && !exclude.is_empty()
163 {
164 self.exclude.extend(exclude);
165 }
166
167 if cli.json {
168 self.json = true;
169 }
170
171 if cli.flat {
172 self.flat = true;
173 }
174
175 if cli.no_color {
176 self.no_color = true;
177 }
178
179 if cli.ignore_case {
180 self.ignore_case = true;
181 }
182
183 if cli.no_require_colon {
184 self.require_colon = false;
185 }
186 }
187
188 pub fn load_or_default(path: &Path, config_path: Option<&Path>) -> Result<Self> {
192 if let Some(config_path) = config_path {
193 return Self::load_from_file(config_path);
194 }
195
196 match Self::load(path)? {
197 Some(config) => Ok(config),
198 None => Ok(Self::new()),
199 }
200 }
201
202 pub fn save_in_cwd(&self) -> Result<()> {
206 let current_dir = std::env::current_dir()?;
207 let config_files = [
208 current_dir.join(".todorc"),
209 current_dir.join(".todorc.json"),
210 current_dir.join(".todorc.toml"),
211 ];
212
213 for path in &config_files {
214 if path.exists() {
215 return self.save(path);
216 }
217 }
218
219 let path = current_dir.join(".todorc.json");
220 self.save(&path)
221 }
222
223 pub fn save(&self, path: &Path) -> Result<()> {
226 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
227 let content = if extension == "toml" {
228 toml::to_string_pretty(self)?
229 } else {
230 serde_json::to_string_pretty(self)?
231 };
232
233 std::fs::write(path, content)
234 .wrap_err_with(|| format!("Failed to write config file: {}", path.display()))?;
235
236 Ok(())
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use std::fs;
244 use std::time::{SystemTime, UNIX_EPOCH};
245
246 static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
249
250 fn temp_path(name: &str) -> std::path::PathBuf {
251 let unique = SystemTime::now()
252 .duration_since(UNIX_EPOCH)
253 .unwrap()
254 .as_nanos();
255 std::env::temp_dir().join(format!("todo_tree_config_test_{name}_{unique}"))
256 }
257
258 #[test]
259 fn load_from_file_parses_json() {
260 let path = temp_path("json").with_extension("json");
261 fs::write(&path, r#"{"tags": ["TODO", "FIXME"], "ignore_case": true}"#).unwrap();
262
263 let config = Config::load_from_file(&path).unwrap();
264 let _ = fs::remove_file(&path);
265
266 assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
267 assert!(config.ignore_case);
268 }
269
270 #[test]
271 fn config_home_prefers_xdg_config_home_when_set() {
272 let _lock = XDG_ENV_LOCK.lock().unwrap();
273 let dir = temp_path("xdg");
274
275 unsafe {
279 std::env::set_var("XDG_CONFIG_HOME", &dir);
280 }
281 let resolved = config_home();
282 unsafe {
283 std::env::remove_var("XDG_CONFIG_HOME");
284 }
285
286 assert_eq!(resolved, Some(dir));
287 }
288
289 #[test]
290 fn load_from_file_parses_toml() {
291 let path = temp_path("toml").with_extension("toml");
292 fs::write(&path, "tags = [\"TODO\", \"FIXME\"]\nignore_case = true\n").unwrap();
293
294 let config = Config::load_from_file(&path).unwrap();
295 let _ = fs::remove_file(&path);
296
297 assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
298 assert!(config.ignore_case);
299 }
300
301 #[test]
302 fn save_then_load_round_trips_toml() {
303 let path = temp_path("roundtrip").with_extension("toml");
304 let mut config = Config::new();
305 config.tags = vec!["NOTE".to_string()];
306
307 config.save(&path).unwrap();
308 let loaded = Config::load_from_file(&path).unwrap();
309 let _ = fs::remove_file(&path);
310
311 assert_eq!(loaded.tags, vec!["NOTE".to_string()]);
312 }
313
314 #[test]
315 fn load_does_not_recognize_yaml_files() {
316 let dir = temp_path("yaml_dir");
317 fs::create_dir_all(&dir).unwrap();
318 fs::write(dir.join(".todorc.yaml"), "tags:\n - TODO\n").unwrap();
319
320 let result = Config::load(&dir).unwrap();
321 let _ = fs::remove_dir_all(&dir);
322
323 assert!(
324 result.is_none() || result.unwrap().tags != vec!["TODO".to_string()],
325 ".todorc.yaml must no longer be picked up as a config file"
326 );
327 }
328
329 #[test]
330 fn config_home_falls_back_to_platform_dir_when_xdg_unset() {
331 let _lock = XDG_ENV_LOCK.lock().unwrap();
332 let previous = std::env::var_os("XDG_CONFIG_HOME");
333 unsafe {
334 std::env::remove_var("XDG_CONFIG_HOME");
335 }
336 let resolved = config_home();
337 unsafe {
338 match &previous {
339 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
340 None => std::env::remove_var("XDG_CONFIG_HOME"),
341 }
342 }
343
344 assert_eq!(resolved, dirs::config_dir());
345 }
346
347 #[test]
348 fn config_home_falls_back_when_xdg_is_empty() {
349 let _lock = XDG_ENV_LOCK.lock().unwrap();
350 let previous = std::env::var_os("XDG_CONFIG_HOME");
351 unsafe {
352 std::env::set_var("XDG_CONFIG_HOME", "");
353 }
354 let resolved = config_home();
355 unsafe {
356 match &previous {
357 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
358 None => std::env::remove_var("XDG_CONFIG_HOME"),
359 }
360 }
361
362 assert_eq!(resolved, dirs::config_dir());
363 }
364
365 #[test]
366 fn new_returns_strict_defaults() {
367 let config = Config::new();
368 assert_eq!(config.tags, default_tag_names());
369 assert!(config.include.is_empty());
370 assert!(config.exclude.is_empty());
371 assert!(!config.json);
372 assert!(!config.flat);
373 assert!(!config.no_color);
374 assert!(config.custom_pattern.is_none());
375 assert!(!config.ignore_case);
376 assert!(config.require_colon);
377 }
378
379 #[test]
380 fn load_returns_none_when_nothing_found_anywhere() {
381 let _lock = XDG_ENV_LOCK.lock().unwrap();
382 let empty_xdg = temp_path("empty_xdg");
386 fs::create_dir_all(&empty_xdg).unwrap();
387 let scan_dir = temp_path("no_config_anywhere");
388 fs::create_dir_all(&scan_dir).unwrap();
389
390 let previous = std::env::var_os("XDG_CONFIG_HOME");
391 unsafe {
392 std::env::set_var("XDG_CONFIG_HOME", &empty_xdg);
393 }
394 let result = Config::load(&scan_dir);
395 unsafe {
396 match &previous {
397 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
398 None => std::env::remove_var("XDG_CONFIG_HOME"),
399 }
400 }
401 let _ = fs::remove_dir_all(&empty_xdg);
402 let _ = fs::remove_dir_all(&scan_dir);
403
404 assert!(result.unwrap().is_none());
405 }
406
407 #[test]
408 fn load_falls_back_to_global_config_dir() {
409 let _lock = XDG_ENV_LOCK.lock().unwrap();
410 let xdg_dir = temp_path("global_xdg");
411 let todo_tree_dir = xdg_dir.join("todo-tree");
412 fs::create_dir_all(&todo_tree_dir).unwrap();
413 fs::write(todo_tree_dir.join("config.json"), r#"{"tags": ["GLOBAL"]}"#).unwrap();
414
415 let scan_dir = temp_path("global_scan_target");
416 fs::create_dir_all(&scan_dir).unwrap();
417
418 let previous = std::env::var_os("XDG_CONFIG_HOME");
419 unsafe {
420 std::env::set_var("XDG_CONFIG_HOME", &xdg_dir);
421 }
422 let result = Config::load(&scan_dir);
423 unsafe {
424 match &previous {
425 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
426 None => std::env::remove_var("XDG_CONFIG_HOME"),
427 }
428 }
429 let _ = fs::remove_dir_all(&xdg_dir);
430 let _ = fs::remove_dir_all(&scan_dir);
431
432 let config = result
433 .unwrap()
434 .expect("expected the global config to be found");
435 assert_eq!(config.tags, vec!["GLOBAL".to_string()]);
436 }
437
438 #[test]
439 fn load_finds_exact_todorc_filename() {
440 let dir = temp_path("exact_todorc");
441 fs::create_dir_all(&dir).unwrap();
442 fs::write(dir.join(".todorc"), r#"{"tags": ["NOTE"]}"#).unwrap();
443
444 let config = Config::load(&dir).unwrap().expect("expected a config");
445 let _ = fs::remove_dir_all(&dir);
446
447 assert_eq!(config.tags, vec!["NOTE".to_string()]);
448 }
449
450 #[test]
451 fn load_recurses_into_parent_directories() {
452 let dir = temp_path("parent_recursion");
453 let child = dir.join("child");
454 fs::create_dir_all(&child).unwrap();
455 fs::write(dir.join(".todorc.json"), r#"{"tags": ["PARENT"]}"#).unwrap();
456
457 let config = Config::load(&child).unwrap().expect("expected a config");
458 let _ = fs::remove_dir_all(&dir);
459
460 assert_eq!(config.tags, vec!["PARENT".to_string()]);
461 }
462
463 #[test]
464 fn load_from_file_errors_on_missing_file() {
465 let path = temp_path("missing").with_extension("json");
466 assert!(Config::load_from_file(&path).is_err());
467 }
468
469 #[test]
470 fn load_from_file_errors_on_unparseable_content() {
471 let path = temp_path("garbage").with_extension("toml");
472 fs::write(&path, "not: valid { toml or json").unwrap();
473
474 let result = Config::load_from_file(&path);
475 let _ = fs::remove_file(&path);
476
477 assert!(result.is_err());
478 }
479
480 #[test]
481 fn save_writes_json_for_non_toml_extension() {
482 let path = temp_path("save_json").with_extension("json");
483 let config = Config::new();
484
485 config.save(&path).unwrap();
486 let content = fs::read_to_string(&path).unwrap();
487 let _ = fs::remove_file(&path);
488
489 assert!(content.trim_start().starts_with('{'));
490 }
491
492 #[test]
493 fn merge_with_cli_applies_every_override() {
494 let mut config = Config::new();
495 config.exclude = vec!["existing/**".to_string()];
496
497 config.merge_with_cli(CliOptions {
498 tags: Some(vec!["CUSTOM".to_string()]),
499 include: Some(vec!["*.rs".to_string()]),
500 exclude: Some(vec!["extra/**".to_string()]),
501 json: true,
502 flat: true,
503 no_color: true,
504 ignore_case: true,
505 no_require_colon: true,
506 });
507
508 assert_eq!(config.tags, vec!["CUSTOM".to_string()]);
509 assert_eq!(config.include, vec!["*.rs".to_string()]);
510 assert_eq!(
511 config.exclude,
512 vec!["existing/**".to_string(), "extra/**".to_string()]
513 );
514 assert!(config.json);
515 assert!(config.flat);
516 assert!(config.no_color);
517 assert!(config.ignore_case);
518 assert!(!config.require_colon);
519 }
520
521 #[test]
522 fn merge_with_cli_is_a_no_op_with_default_options() {
523 let config_before = Config::new();
524 let mut config = Config::new();
525
526 config.merge_with_cli(CliOptions::default());
527
528 assert_eq!(config.tags, config_before.tags);
529 assert_eq!(config.include, config_before.include);
530 assert_eq!(config.exclude, config_before.exclude);
531 assert_eq!(config.json, config_before.json);
532 assert_eq!(config.flat, config_before.flat);
533 assert_eq!(config.no_color, config_before.no_color);
534 assert_eq!(config.ignore_case, config_before.ignore_case);
535 assert_eq!(config.require_colon, config_before.require_colon);
536 }
537
538 #[test]
539 fn merge_with_cli_ignores_empty_tag_and_include_overrides() {
540 let mut config = Config::new();
541 let original_tags = config.tags.clone();
542
543 config.merge_with_cli(CliOptions {
544 tags: Some(vec![]),
545 include: Some(vec![]),
546 exclude: Some(vec![]),
547 ..Default::default()
548 });
549
550 assert_eq!(config.tags, original_tags);
551 assert!(config.include.is_empty());
552 assert!(config.exclude.is_empty());
553 }
554}