Skip to main content

todo_tree/
config.rs

1use anyhow::{Context, Result};
2use directories_next::BaseDirs;
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use todo_tree_core::tags::default_tag_names;
6
7#[derive(Debug, Clone, Default)]
8pub struct CliOptions {
9    pub tags: Option<Vec<String>>,
10    pub include: Option<Vec<String>>,
11    pub exclude: Option<Vec<String>>,
12    pub json: bool,
13    pub flat: bool,
14    pub no_color: bool,
15    pub ignore_case: bool,
16    pub no_require_colon: bool,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20#[serde(default)]
21pub struct Config {
22    pub tags: Vec<String>,
23    pub include: Vec<String>,
24    pub exclude: Vec<String>,
25    pub json: bool,
26    pub flat: bool,
27    pub no_color: bool,
28    pub custom_pattern: Option<String>,
29    pub ignore_case: bool,
30    pub require_colon: bool,
31}
32
33impl Config {
34    pub fn new() -> Self {
35        Self {
36            tags: default_tag_names(),
37            include: Vec::new(),
38            exclude: Vec::new(),
39            json: false,
40            flat: false,
41            no_color: false,
42            custom_pattern: None,
43            ignore_case: false,
44            require_colon: true,
45        }
46    }
47
48    /// Load configuration from a .todorc file
49    ///
50    /// Searches for configuration files in the following order:
51    /// 1. .todorc in the current directory
52    /// 2. .todorc.json in the current directory
53    /// 3. .todorc.yaml or .todorc.yml in the current directory
54    /// 4. ~/.config/todo-tree/config.json (global config)
55    pub fn load(start_path: &Path) -> Result<Option<Self>> {
56        let local_configs = [
57            start_path.join(".todorc"),
58            start_path.join(".todorc.json"),
59            start_path.join(".todorc.yaml"),
60            start_path.join(".todorc.yml"),
61        ];
62
63        for config_path in &local_configs {
64            if config_path.exists() {
65                return Self::load_from_file(config_path).map(Some);
66            }
67        }
68
69        if let Some(parent) = start_path.parent()
70            && parent != start_path
71            && let Ok(Some(config)) = Self::load(parent)
72        {
73            return Ok(Some(config));
74        }
75
76        if let Some(base_dirs) = BaseDirs::new() {
77            let config_dir = base_dirs.config_dir();
78            let global_configs = [
79                config_dir.join("todo-tree").join("config.json"),
80                config_dir.join("todo-tree").join("config.yaml"),
81                config_dir.join("todo-tree").join("config.yml"),
82            ];
83
84            for config_path in &global_configs {
85                if config_path.exists() {
86                    return Self::load_from_file(config_path).map(Some);
87                }
88            }
89        }
90
91        Ok(None)
92    }
93
94    pub fn load_from_file(path: &Path) -> Result<Self> {
95        let content = std::fs::read_to_string(path)
96            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
97
98        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
99        let parse_result = if extension == "yaml" || extension == "yml" {
100            yaml_serde::from_str(&content)
101        } else {
102            serde_json::from_str(&content).or_else(|_| yaml_serde::from_str(&content))
103        };
104
105        parse_result.with_context(|| format!("Failed to parse config: {}", path.display()))
106    }
107
108    pub fn merge_with_cli(&mut self, cli: CliOptions) {
109        if let Some(tags) = cli.tags
110            && !tags.is_empty()
111        {
112            self.tags = tags;
113        }
114
115        if let Some(include) = cli.include
116            && !include.is_empty()
117        {
118            self.include = include;
119        }
120
121        if let Some(exclude) = cli.exclude
122            && !exclude.is_empty()
123        {
124            self.exclude.extend(exclude);
125        }
126
127        if cli.json {
128            self.json = true;
129        }
130        if cli.flat {
131            self.flat = true;
132        }
133        if cli.no_color {
134            self.no_color = true;
135        }
136
137        if cli.ignore_case {
138            self.ignore_case = true;
139        }
140
141        if cli.no_require_colon {
142            self.require_colon = false;
143        }
144    }
145
146    pub fn save(&self, path: &Path) -> Result<()> {
147        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
148        let content = if extension == "yaml" || extension == "yml" {
149            yaml_serde::to_string(self)?
150        } else {
151            serde_json::to_string_pretty(self)?
152        };
153
154        std::fs::write(path, content)
155            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
156
157        Ok(())
158    }
159}