Skip to main content

todo_tree/
config.rs

1//! `.todorc` configuration file discovery, parsing, and merging with CLI
2//! overrides.
3
4use crate::core::tags::default_tag_names;
5use color_eyre::eyre::{Result, WrapErr};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Resolves the global config directory, honoring `XDG_CONFIG_HOME` on all
10/// platforms (not just Linux, where `dirs::config_dir` already does this)
11/// before falling back to the platform default.
12fn 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/// CLI-provided overrides to merge into a loaded [`Config`].
20#[derive(Debug, Clone, Default)]
21pub struct CliOptions {
22    /// Tags to search for, overriding the config file's list if present.
23    pub tags: Option<Vec<String>>,
24    /// Include patterns, overriding the config file's list if present.
25    pub include: Option<Vec<String>>,
26    /// Exclude patterns, appended to the config file's list if present.
27    pub exclude: Option<Vec<String>>,
28    /// Forces JSON output on.
29    pub json: bool,
30    /// Forces flat output on.
31    pub flat: bool,
32    /// Forces colored output off.
33    pub no_color: bool,
34    /// Forces case-insensitive tag matching on.
35    pub ignore_case: bool,
36    /// Forces the trailing-colon requirement off.
37    pub no_require_colon: bool,
38}
39
40/// A loaded (or default) `.todorc` configuration.
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42#[serde(default)]
43pub struct Config {
44    /// Tags to search for.
45    pub tags: Vec<String>,
46    /// Glob patterns to include.
47    pub include: Vec<String>,
48    /// Glob patterns to exclude.
49    pub exclude: Vec<String>,
50    /// Whether to default to JSON output.
51    pub json: bool,
52    /// Whether to default to flat output.
53    pub flat: bool,
54    /// Whether to default to uncolored output.
55    pub no_color: bool,
56    /// An optional custom tag-matching regex, in place of
57    /// [`crate::parser::DEFAULT_REGEX`].
58    pub custom_pattern: Option<String>,
59    /// Whether tag matching is case-insensitive.
60    pub ignore_case: bool,
61    /// Whether a trailing colon is required after the tag.
62    pub require_colon: bool,
63}
64
65impl Config {
66    /// Builds a config with the default tag set and strict matching
67    /// (case-sensitive, colon required).
68    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    /// Load configuration from a .todorc file
83    ///
84    /// Searches for configuration files in the following order:
85    /// 1. .todorc in the current directory
86    /// 2. .todorc.json in the current directory
87    /// 3. .todorc.toml in the current directory
88    /// 4. Parent directories (recursive)
89    /// 5. `$XDG_CONFIG_HOME/todo-tree/config.json` or `config.toml`, falling
90    ///    back to the platform config directory if `XDG_CONFIG_HOME` isn't
91    ///    set (global config)
92    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    /// Loads and parses a specific config file, auto-detecting JSON vs.
129    /// TOML from its extension (falling back to JSON-then-TOML for
130    /// extensionless files like `.todorc`).
131    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    /// Merges CLI-provided overrides into this config in place.
148    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    /// Loads a config, honoring an explicit `config_path` override before
189    /// falling back to [`Config::load`]'s discovery starting at `path`, and
190    /// finally to [`Config::new`] if nothing is found anywhere.
191    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    /// Saves this config to whichever `.todorc`/`.todorc.json`/`.todorc.toml`
203    /// already exists in the current directory, or `.todorc.json` if none
204    /// do.
205    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    /// Writes this config to `path`, choosing TOML or JSON based on its
224    /// extension (JSON if unrecognized).
225    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    // `XDG_CONFIG_HOME` is process-global; serialize every test that touches
247    // it so they don't race each other under parallel test execution.
248    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        // SAFETY: mutating process env is inherently racy under parallel
276        // test execution; the window is kept as narrow as possible and the
277        // var is always removed before returning.
278        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        // Force `config_home()` to resolve to a guaranteed-empty directory so
383        // this test doesn't depend on whether the machine running it happens
384        // to have a real global todo-tree config.
385        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}