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