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
19fn env_list(name: &str) -> Option<Vec<String>> {
23 let value = std::env::var(name).ok()?;
24 let items: Vec<String> = value
25 .split(',')
26 .map(str::trim)
27 .filter(|s| !s.is_empty())
28 .map(String::from)
29 .collect();
30
31 if items.is_empty() { None } else { Some(items) }
32}
33
34fn env_bool(name: &str) -> Option<bool> {
38 let value = std::env::var(name).ok()?;
39 Some(!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false"))
40}
41
42#[derive(Debug, Clone, Default)]
44pub struct CliOptions {
45 pub tags: Option<Vec<String>>,
47 pub include: Option<Vec<String>>,
49 pub exclude: Option<Vec<String>>,
51 pub json: bool,
53 pub flat: bool,
55 pub no_color: bool,
57 pub ignore_case: bool,
59 pub no_require_colon: bool,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65#[serde(default)]
66pub struct Config {
67 pub tags: Vec<String>,
69 pub include: Vec<String>,
71 pub exclude: Vec<String>,
73 pub json: bool,
75 pub flat: bool,
77 pub no_color: bool,
79 pub custom_pattern: Option<String>,
82 pub ignore_case: bool,
84 pub require_colon: bool,
86}
87
88impl Config {
89 pub fn new() -> Self {
92 Self {
93 tags: default_tag_names(),
94 include: Vec::new(),
95 exclude: Vec::new(),
96 json: false,
97 flat: false,
98 no_color: false,
99 custom_pattern: None,
100 ignore_case: false,
101 require_colon: true,
102 }
103 }
104
105 pub fn load(start_path: &Path) -> Result<Option<Self>> {
116 let local_configs = [
117 start_path.join(".todorc"),
118 start_path.join(".todorc.json"),
119 start_path.join(".todorc.toml"),
120 ];
121
122 for config_path in &local_configs {
123 if config_path.exists() {
124 return Self::load_from_file(config_path).map(Some);
125 }
126 }
127
128 if let Some(parent) = start_path.parent()
129 && parent != start_path
130 && let Ok(Some(config)) = Self::load(parent)
131 {
132 return Ok(Some(config));
133 }
134
135 if let Some(config_dir) = config_home() {
136 let global_configs = [
137 config_dir.join("todo-tree").join("config.json"),
138 config_dir.join("todo-tree").join("config.toml"),
139 ];
140
141 for config_path in &global_configs {
142 if config_path.exists() {
143 return Self::load_from_file(config_path).map(Some);
144 }
145 }
146 }
147
148 Ok(None)
149 }
150
151 pub fn load_from_file(path: &Path) -> Result<Self> {
155 let content = std::fs::read_to_string(path).wrap_err_with(|| {
156 format!(
157 "Failed to read config file: {}. Check that it exists and you have permission to read it.",
158 path.display()
159 )
160 })?;
161
162 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
163 let parse_result: Result<Self> = if extension == "toml" {
164 toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e))
165 } else {
166 serde_json::from_str(&content)
167 .map_err(|e| color_eyre::eyre::eyre!(e))
168 .or_else(|_| toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e)))
169 };
170
171 parse_result.wrap_err_with(|| {
172 format!(
173 "Failed to parse config: {}. Check that it's valid {} and that its keys match the documented .todorc options.",
174 path.display(),
175 if extension == "toml" { "TOML" } else { "JSON (or TOML)" }
176 )
177 })
178 }
179
180 pub fn merge_with_cli(&mut self, cli: CliOptions) {
182 if let Some(tags) = cli.tags
183 && !tags.is_empty()
184 {
185 self.tags = tags;
186 }
187
188 if let Some(include) = cli.include
189 && !include.is_empty()
190 {
191 self.include = include;
192 }
193
194 if let Some(exclude) = cli.exclude
195 && !exclude.is_empty()
196 {
197 self.exclude.extend(exclude);
198 }
199
200 if cli.json {
201 self.json = true;
202 }
203
204 if cli.flat {
205 self.flat = true;
206 }
207
208 if cli.no_color {
209 self.no_color = true;
210 }
211
212 if cli.ignore_case {
213 self.ignore_case = true;
214 }
215
216 if cli.no_require_colon {
217 self.require_colon = false;
218 }
219 }
220
221 pub fn load_or_default(path: &Path, config_path: Option<&Path>) -> Result<Self> {
229 let mut config = if let Some(config_path) = config_path {
230 Self::load_from_file(config_path)?
231 } else {
232 match Self::load(path)? {
233 Some(config) => config,
234 None => Self::new(),
235 }
236 };
237
238 config.apply_env_overrides();
239 Ok(config)
240 }
241
242 fn apply_env_overrides(&mut self) {
248 if let Some(tags) = env_list("TODO_TREE_TAGS") {
249 self.tags = tags;
250 }
251 if let Some(include) = env_list("TODO_TREE_INCLUDE") {
252 self.include = include;
253 }
254 if let Some(exclude) = env_list("TODO_TREE_EXCLUDE") {
255 self.exclude.extend(exclude);
256 }
257 if let Some(value) = env_bool("TODO_TREE_JSON") {
258 self.json = value;
259 }
260 if let Some(value) = env_bool("TODO_TREE_FLAT") {
261 self.flat = value;
262 }
263 if let Some(value) = env_bool("TODO_TREE_NO_COLOR") {
264 self.no_color = value;
265 }
266 if let Some(value) = env_bool("TODO_TREE_IGNORE_CASE") {
267 self.ignore_case = value;
268 }
269 if let Some(value) = env_bool("TODO_TREE_REQUIRE_COLON") {
270 self.require_colon = value;
271 }
272 }
273
274 pub fn save_in_cwd(&self) -> Result<()> {
278 let current_dir = std::env::current_dir()?;
279 let config_files = [
280 current_dir.join(".todorc"),
281 current_dir.join(".todorc.json"),
282 current_dir.join(".todorc.toml"),
283 ];
284
285 for path in &config_files {
286 if path.exists() {
287 return self.save(path);
288 }
289 }
290
291 let path = current_dir.join(".todorc.json");
292 self.save(&path)
293 }
294
295 pub fn save(&self, path: &Path) -> Result<()> {
298 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
299 let content = if extension == "toml" {
300 toml::to_string_pretty(self)?
301 } else {
302 serde_json::to_string_pretty(self)?
303 };
304
305 std::fs::write(path, content)
306 .wrap_err_with(|| format!("Failed to write config file: {}", path.display()))?;
307
308 Ok(())
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use std::fs;
316 use std::time::{SystemTime, UNIX_EPOCH};
317
318 static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
321
322 fn temp_path(name: &str) -> std::path::PathBuf {
323 let unique = SystemTime::now()
324 .duration_since(UNIX_EPOCH)
325 .unwrap()
326 .as_nanos();
327 std::env::temp_dir().join(format!("todo_tree_config_test_{name}_{unique}"))
328 }
329
330 #[test]
331 fn load_from_file_parses_json() {
332 let path = temp_path("json").with_extension("json");
333 fs::write(&path, r#"{"tags": ["TODO", "FIXME"], "ignore_case": true}"#).unwrap();
334
335 let config = Config::load_from_file(&path).unwrap();
336 let _ = fs::remove_file(&path);
337
338 assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
339 assert!(config.ignore_case);
340 }
341
342 #[test]
343 fn config_home_prefers_xdg_config_home_when_set() {
344 let _lock = XDG_ENV_LOCK.lock().unwrap();
345 let dir = temp_path("xdg");
346
347 unsafe {
351 std::env::set_var("XDG_CONFIG_HOME", &dir);
352 }
353 let resolved = config_home();
354 unsafe {
355 std::env::remove_var("XDG_CONFIG_HOME");
356 }
357
358 assert_eq!(resolved, Some(dir));
359 }
360
361 #[test]
362 fn load_from_file_parses_toml() {
363 let path = temp_path("toml").with_extension("toml");
364 fs::write(&path, "tags = [\"TODO\", \"FIXME\"]\nignore_case = true\n").unwrap();
365
366 let config = Config::load_from_file(&path).unwrap();
367 let _ = fs::remove_file(&path);
368
369 assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
370 assert!(config.ignore_case);
371 }
372
373 #[test]
374 fn save_then_load_round_trips_toml() {
375 let path = temp_path("roundtrip").with_extension("toml");
376 let mut config = Config::new();
377 config.tags = vec!["NOTE".to_string()];
378
379 config.save(&path).unwrap();
380 let loaded = Config::load_from_file(&path).unwrap();
381 let _ = fs::remove_file(&path);
382
383 assert_eq!(loaded.tags, vec!["NOTE".to_string()]);
384 }
385
386 #[test]
387 fn load_does_not_recognize_yaml_files() {
388 let dir = temp_path("yaml_dir");
389 fs::create_dir_all(&dir).unwrap();
390 fs::write(dir.join(".todorc.yaml"), "tags:\n - TODO\n").unwrap();
391
392 let result = Config::load(&dir).unwrap();
393 let _ = fs::remove_dir_all(&dir);
394
395 assert!(
396 result.is_none() || result.unwrap().tags != vec!["TODO".to_string()],
397 ".todorc.yaml must no longer be picked up as a config file"
398 );
399 }
400
401 #[test]
402 fn config_home_falls_back_to_platform_dir_when_xdg_unset() {
403 let _lock = XDG_ENV_LOCK.lock().unwrap();
404 let previous = std::env::var_os("XDG_CONFIG_HOME");
405 unsafe {
406 std::env::remove_var("XDG_CONFIG_HOME");
407 }
408 let resolved = config_home();
409 unsafe {
410 match &previous {
411 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
412 None => std::env::remove_var("XDG_CONFIG_HOME"),
413 }
414 }
415
416 assert_eq!(resolved, dirs::config_dir());
417 }
418
419 #[test]
420 fn config_home_falls_back_when_xdg_is_empty() {
421 let _lock = XDG_ENV_LOCK.lock().unwrap();
422 let previous = std::env::var_os("XDG_CONFIG_HOME");
423 unsafe {
424 std::env::set_var("XDG_CONFIG_HOME", "");
425 }
426 let resolved = config_home();
427 unsafe {
428 match &previous {
429 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
430 None => std::env::remove_var("XDG_CONFIG_HOME"),
431 }
432 }
433
434 assert_eq!(resolved, dirs::config_dir());
435 }
436
437 #[test]
438 fn new_returns_strict_defaults() {
439 let config = Config::new();
440 assert_eq!(config.tags, default_tag_names());
441 assert!(config.include.is_empty());
442 assert!(config.exclude.is_empty());
443 assert!(!config.json);
444 assert!(!config.flat);
445 assert!(!config.no_color);
446 assert!(config.custom_pattern.is_none());
447 assert!(!config.ignore_case);
448 assert!(config.require_colon);
449 }
450
451 #[test]
452 fn load_returns_none_when_nothing_found_anywhere() {
453 let _lock = XDG_ENV_LOCK.lock().unwrap();
454 let empty_xdg = temp_path("empty_xdg");
458 fs::create_dir_all(&empty_xdg).unwrap();
459 let scan_dir = temp_path("no_config_anywhere");
460 fs::create_dir_all(&scan_dir).unwrap();
461
462 let previous = std::env::var_os("XDG_CONFIG_HOME");
463 unsafe {
464 std::env::set_var("XDG_CONFIG_HOME", &empty_xdg);
465 }
466 let result = Config::load(&scan_dir);
467 unsafe {
468 match &previous {
469 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
470 None => std::env::remove_var("XDG_CONFIG_HOME"),
471 }
472 }
473 let _ = fs::remove_dir_all(&empty_xdg);
474 let _ = fs::remove_dir_all(&scan_dir);
475
476 assert!(result.unwrap().is_none());
477 }
478
479 #[test]
480 fn load_falls_back_to_global_config_dir() {
481 let _lock = XDG_ENV_LOCK.lock().unwrap();
482 let xdg_dir = temp_path("global_xdg");
483 let todo_tree_dir = xdg_dir.join("todo-tree");
484 fs::create_dir_all(&todo_tree_dir).unwrap();
485 fs::write(todo_tree_dir.join("config.json"), r#"{"tags": ["GLOBAL"]}"#).unwrap();
486
487 let scan_dir = temp_path("global_scan_target");
488 fs::create_dir_all(&scan_dir).unwrap();
489
490 let previous = std::env::var_os("XDG_CONFIG_HOME");
491 unsafe {
492 std::env::set_var("XDG_CONFIG_HOME", &xdg_dir);
493 }
494 let result = Config::load(&scan_dir);
495 unsafe {
496 match &previous {
497 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
498 None => std::env::remove_var("XDG_CONFIG_HOME"),
499 }
500 }
501 let _ = fs::remove_dir_all(&xdg_dir);
502 let _ = fs::remove_dir_all(&scan_dir);
503
504 let config = result
505 .unwrap()
506 .expect("expected the global config to be found");
507 assert_eq!(config.tags, vec!["GLOBAL".to_string()]);
508 }
509
510 #[test]
511 fn load_finds_exact_todorc_filename() {
512 let dir = temp_path("exact_todorc");
513 fs::create_dir_all(&dir).unwrap();
514 fs::write(dir.join(".todorc"), r#"{"tags": ["NOTE"]}"#).unwrap();
515
516 let config = Config::load(&dir).unwrap().expect("expected a config");
517 let _ = fs::remove_dir_all(&dir);
518
519 assert_eq!(config.tags, vec!["NOTE".to_string()]);
520 }
521
522 #[test]
523 fn load_recurses_into_parent_directories() {
524 let dir = temp_path("parent_recursion");
525 let child = dir.join("child");
526 fs::create_dir_all(&child).unwrap();
527 fs::write(dir.join(".todorc.json"), r#"{"tags": ["PARENT"]}"#).unwrap();
528
529 let config = Config::load(&child).unwrap().expect("expected a config");
530 let _ = fs::remove_dir_all(&dir);
531
532 assert_eq!(config.tags, vec!["PARENT".to_string()]);
533 }
534
535 #[test]
536 fn load_from_file_errors_on_missing_file() {
537 let path = temp_path("missing").with_extension("json");
538 assert!(Config::load_from_file(&path).is_err());
539 }
540
541 #[test]
542 fn load_from_file_errors_on_unparseable_content() {
543 let path = temp_path("garbage").with_extension("toml");
544 fs::write(&path, "not: valid { toml or json").unwrap();
545
546 let result = Config::load_from_file(&path);
547 let _ = fs::remove_file(&path);
548
549 assert!(result.is_err());
550 }
551
552 #[test]
553 fn save_writes_json_for_non_toml_extension() {
554 let path = temp_path("save_json").with_extension("json");
555 let config = Config::new();
556
557 config.save(&path).unwrap();
558 let content = fs::read_to_string(&path).unwrap();
559 let _ = fs::remove_file(&path);
560
561 assert!(content.trim_start().starts_with('{'));
562 }
563
564 #[test]
565 fn merge_with_cli_applies_every_override() {
566 let mut config = Config::new();
567 config.exclude = vec!["existing/**".to_string()];
568
569 config.merge_with_cli(CliOptions {
570 tags: Some(vec!["CUSTOM".to_string()]),
571 include: Some(vec!["*.rs".to_string()]),
572 exclude: Some(vec!["extra/**".to_string()]),
573 json: true,
574 flat: true,
575 no_color: true,
576 ignore_case: true,
577 no_require_colon: true,
578 });
579
580 assert_eq!(config.tags, vec!["CUSTOM".to_string()]);
581 assert_eq!(config.include, vec!["*.rs".to_string()]);
582 assert_eq!(
583 config.exclude,
584 vec!["existing/**".to_string(), "extra/**".to_string()]
585 );
586 assert!(config.json);
587 assert!(config.flat);
588 assert!(config.no_color);
589 assert!(config.ignore_case);
590 assert!(!config.require_colon);
591 }
592
593 #[test]
594 fn merge_with_cli_is_a_no_op_with_default_options() {
595 let config_before = Config::new();
596 let mut config = Config::new();
597
598 config.merge_with_cli(CliOptions::default());
599
600 assert_eq!(config.tags, config_before.tags);
601 assert_eq!(config.include, config_before.include);
602 assert_eq!(config.exclude, config_before.exclude);
603 assert_eq!(config.json, config_before.json);
604 assert_eq!(config.flat, config_before.flat);
605 assert_eq!(config.no_color, config_before.no_color);
606 assert_eq!(config.ignore_case, config_before.ignore_case);
607 assert_eq!(config.require_colon, config_before.require_colon);
608 }
609
610 static TODO_TREE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
612
613 fn clear_todo_tree_env() {
616 for var in [
617 "TODO_TREE_TAGS",
618 "TODO_TREE_INCLUDE",
619 "TODO_TREE_EXCLUDE",
620 "TODO_TREE_JSON",
621 "TODO_TREE_FLAT",
622 "TODO_TREE_NO_COLOR",
623 "TODO_TREE_IGNORE_CASE",
624 "TODO_TREE_REQUIRE_COLON",
625 ] {
626 unsafe {
628 std::env::remove_var(var);
629 }
630 }
631 }
632
633 #[test]
634 fn apply_env_overrides_applies_every_recognized_var() {
635 let _lock = TODO_TREE_ENV_LOCK.lock().unwrap();
636 clear_todo_tree_env();
637
638 let mut config = Config::new();
639 config.exclude = vec!["existing/**".to_string()];
640
641 unsafe {
643 std::env::set_var("TODO_TREE_TAGS", "CUSTOM, OTHER");
644 std::env::set_var("TODO_TREE_INCLUDE", "*.rs");
645 std::env::set_var("TODO_TREE_EXCLUDE", "extra/**");
646 std::env::set_var("TODO_TREE_JSON", "true");
647 std::env::set_var("TODO_TREE_FLAT", "1");
648 std::env::set_var("TODO_TREE_NO_COLOR", "true");
649 std::env::set_var("TODO_TREE_IGNORE_CASE", "true");
650 std::env::set_var("TODO_TREE_REQUIRE_COLON", "false");
651 }
652 config.apply_env_overrides();
653 clear_todo_tree_env();
654
655 assert_eq!(config.tags, vec!["CUSTOM".to_string(), "OTHER".to_string()]);
656 assert_eq!(config.include, vec!["*.rs".to_string()]);
657 assert_eq!(
658 config.exclude,
659 vec!["existing/**".to_string(), "extra/**".to_string()]
660 );
661 assert!(config.json);
662 assert!(config.flat);
663 assert!(config.no_color);
664 assert!(config.ignore_case);
665 assert!(!config.require_colon);
666 }
667
668 #[test]
669 fn apply_env_overrides_is_a_no_op_when_unset() {
670 let _lock = TODO_TREE_ENV_LOCK.lock().unwrap();
671 clear_todo_tree_env();
672
673 let before = Config::new();
674 let mut config = Config::new();
675 config.apply_env_overrides();
676
677 assert_eq!(config.tags, before.tags);
678 assert_eq!(config.include, before.include);
679 assert_eq!(config.exclude, before.exclude);
680 assert_eq!(config.json, before.json);
681 assert_eq!(config.flat, before.flat);
682 assert_eq!(config.no_color, before.no_color);
683 assert_eq!(config.ignore_case, before.ignore_case);
684 assert_eq!(config.require_colon, before.require_colon);
685 }
686
687 #[test]
688 fn merge_with_cli_ignores_empty_tag_and_include_overrides() {
689 let mut config = Config::new();
690 let original_tags = config.tags.clone();
691
692 config.merge_with_cli(CliOptions {
693 tags: Some(vec![]),
694 include: Some(vec![]),
695 exclude: Some(vec![]),
696 ..Default::default()
697 });
698
699 assert_eq!(config.tags, original_tags);
700 assert!(config.include.is_empty());
701 assert!(config.exclude.is_empty());
702 }
703}