1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{DotAgentError, Result};
7use crate::llm::LlmConfig;
8use crate::profile::{IgnoreConfig, DEFAULT_EXCLUDED_DIRS};
9
10const CONFIG_FILE: &str = "config.toml";
11
12const DEFAULT_CONFIG_TEMPLATE: &str = r#"# dot-agent configuration file
14# Location: ~/.dot-agent/config.toml
15
16[profile]
17# Directories to exclude when installing profiles
18# Default: [".git"]
19# Example: exclude = [".git", "node_modules", ".venv"]
20exclude = [".git"]
21
22# Directories to always include (overrides exclude)
23# Default: []
24# Example: include = [".git"] # to include .git in installations
25include = []
26
27# Default profile to use when none specified
28# default = "my-profile"
29
30[llm]
31# Enable LLM-powered features (category classification, etc.)
32# When enabled, uses Claude API for semantic classification
33# Default: false
34enabled = false
35"#;
36
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct Config {
40 #[serde(default)]
41 pub profile: ProfileConfig,
42
43 #[serde(default)]
44 pub llm: LlmConfig,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ProfileConfig {
50 #[serde(default = "default_exclude")]
52 pub exclude: Vec<String>,
53
54 #[serde(default)]
56 pub include: Vec<String>,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub default: Option<String>,
61}
62
63fn default_exclude() -> Vec<String> {
64 DEFAULT_EXCLUDED_DIRS
65 .iter()
66 .map(|s| s.to_string())
67 .collect()
68}
69
70impl Default for ProfileConfig {
71 fn default() -> Self {
72 Self {
73 exclude: default_exclude(),
74 include: Vec::new(),
75 default: None,
76 }
77 }
78}
79
80impl Config {
81 pub fn load(base_dir: &Path) -> Result<Self> {
83 let path = base_dir.join(CONFIG_FILE);
84 if !path.exists() {
85 return Ok(Self::default());
86 }
87
88 let content = fs::read_to_string(&path)?;
89 let config: Config = toml::from_str(&content).map_err(|e| DotAgentError::ConfigParse {
90 path: path.clone(),
91 message: e.to_string(),
92 })?;
93
94 Ok(config)
95 }
96
97 pub fn save(&self, base_dir: &Path) -> Result<()> {
99 let path = base_dir.join(CONFIG_FILE);
100 fs::create_dir_all(base_dir)?;
101
102 let content = toml::to_string_pretty(self).map_err(|e| DotAgentError::ConfigParse {
103 path: path.clone(),
104 message: e.to_string(),
105 })?;
106
107 fs::write(&path, content)?;
108 Ok(())
109 }
110
111 pub fn path(base_dir: &Path) -> PathBuf {
113 base_dir.join(CONFIG_FILE)
114 }
115
116 pub fn init(base_dir: &Path) -> Result<PathBuf> {
118 let path = base_dir.join(CONFIG_FILE);
119 fs::create_dir_all(base_dir)?;
120
121 if !path.exists() {
122 fs::write(&path, DEFAULT_CONFIG_TEMPLATE)?;
123 }
124
125 Ok(path)
126 }
127
128 pub fn get(&self, key: &str) -> Option<String> {
130 match key {
131 "profile.exclude" => Some(format!("{:?}", self.profile.exclude)),
132 "profile.include" => Some(format!("{:?}", self.profile.include)),
133 "profile.default" => self.profile.default.clone(),
134 "llm.enabled" => Some(self.llm.enabled.to_string()),
135 _ => None,
136 }
137 }
138
139 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
141 match key {
142 "profile.exclude" => {
143 self.profile.exclude = parse_string_list(value)?;
144 Ok(())
145 }
146 "profile.include" => {
147 self.profile.include = parse_string_list(value)?;
148 Ok(())
149 }
150 "profile.default" => {
151 let trimmed = value.trim();
152 self.profile.default = if trimmed.is_empty() {
153 None
154 } else {
155 Some(trimmed.to_string())
156 };
157 Ok(())
158 }
159 "llm.enabled" => {
160 let trimmed = value.trim().to_lowercase();
161 self.llm.enabled = matches!(trimmed.as_str(), "true" | "1" | "yes");
162 Ok(())
163 }
164 _ => Err(DotAgentError::ConfigKeyNotFound {
165 key: key.to_string(),
166 }),
167 }
168 }
169
170 pub fn clear_default(&mut self) {
172 self.profile.default = None;
173 }
174
175 pub fn list(&self) -> Vec<(String, String)> {
177 vec![
178 (
179 "profile.exclude".to_string(),
180 format!("{:?}", self.profile.exclude),
181 ),
182 (
183 "profile.include".to_string(),
184 format!("{:?}", self.profile.include),
185 ),
186 (
187 "profile.default".to_string(),
188 self.profile
189 .default
190 .clone()
191 .unwrap_or_else(|| "(not set)".to_string()),
192 ),
193 ("llm.enabled".to_string(), self.llm.enabled.to_string()),
194 ]
195 }
196
197 pub fn to_ignore_config(&self) -> IgnoreConfig {
199 IgnoreConfig {
200 excluded_dirs: self.profile.exclude.clone(),
201 included_dirs: self.profile.include.clone(),
202 }
203 }
204}
205
206fn parse_string_list(value: &str) -> Result<Vec<String>> {
208 let trimmed = value.trim();
209
210 if trimmed.starts_with('[') && trimmed.ends_with(']') {
212 let inner = &trimmed[1..trimmed.len() - 1];
213 if inner.trim().is_empty() {
214 return Ok(Vec::new());
215 }
216
217 let items: Vec<String> = inner
218 .split(',')
219 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
220 .filter(|s| !s.is_empty())
221 .collect();
222 return Ok(items);
223 }
224
225 let items: Vec<String> = trimmed
227 .split(',')
228 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
229 .filter(|s| !s.is_empty())
230 .collect();
231
232 Ok(items)
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_parse_string_list_comma() {
241 let result = parse_string_list(".git,node_modules").unwrap();
242 assert_eq!(result, vec![".git", "node_modules"]);
243 }
244
245 #[test]
246 fn test_parse_string_list_json() {
247 let result = parse_string_list(r#"[".git", "node_modules"]"#).unwrap();
248 assert_eq!(result, vec![".git", "node_modules"]);
249 }
250
251 #[test]
252 fn test_parse_string_list_empty() {
253 let result = parse_string_list("[]").unwrap();
254 assert!(result.is_empty());
255 }
256
257 #[test]
258 fn test_config_get_set() {
259 let mut config = Config::default();
260
261 config.set("profile.exclude", ".git,node_modules").unwrap();
262 assert_eq!(config.profile.exclude, vec![".git", "node_modules"]);
263
264 let value = config.get("profile.exclude").unwrap();
265 assert!(value.contains(".git"));
266 }
267
268 #[test]
269 fn test_to_ignore_config() {
270 let mut config = Config::default();
271 config.profile.exclude = vec![".git".to_string(), "node_modules".to_string()];
272 config.profile.include = vec![".gitkeep".to_string()];
273
274 let ignore = config.to_ignore_config();
275 assert_eq!(ignore.excluded_dirs, vec![".git", "node_modules"]);
276 assert_eq!(ignore.included_dirs, vec![".gitkeep"]);
277 }
278}