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 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}