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
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct Config {
30 #[serde(default)]
31 pub profile: ProfileConfig,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ProfileConfig {
37 #[serde(default = "default_exclude")]
39 pub exclude: Vec<String>,
40
41 #[serde(default)]
43 pub include: Vec<String>,
44}
45
46fn default_exclude() -> Vec<String> {
47 DEFAULT_EXCLUDED_DIRS
48 .iter()
49 .map(|s| s.to_string())
50 .collect()
51}
52
53impl Default for ProfileConfig {
54 fn default() -> Self {
55 Self {
56 exclude: default_exclude(),
57 include: Vec::new(),
58 }
59 }
60}
61
62impl Config {
63 pub fn load(base_dir: &Path) -> Result<Self> {
65 let path = base_dir.join(CONFIG_FILE);
66 if !path.exists() {
67 return Ok(Self::default());
68 }
69
70 let content = fs::read_to_string(&path)?;
71 let config: Config = toml::from_str(&content).map_err(|e| DotAgentError::ConfigParse {
72 path: path.clone(),
73 message: e.to_string(),
74 })?;
75
76 Ok(config)
77 }
78
79 pub fn save(&self, base_dir: &Path) -> Result<()> {
81 let path = base_dir.join(CONFIG_FILE);
82 fs::create_dir_all(base_dir)?;
83
84 let content = toml::to_string_pretty(self).map_err(|e| DotAgentError::ConfigParse {
85 path: path.clone(),
86 message: e.to_string(),
87 })?;
88
89 fs::write(&path, content)?;
90 Ok(())
91 }
92
93 pub fn path(base_dir: &Path) -> PathBuf {
95 base_dir.join(CONFIG_FILE)
96 }
97
98 pub fn init(base_dir: &Path) -> Result<PathBuf> {
100 let path = base_dir.join(CONFIG_FILE);
101 fs::create_dir_all(base_dir)?;
102
103 if !path.exists() {
104 fs::write(&path, DEFAULT_CONFIG_TEMPLATE)?;
105 }
106
107 Ok(path)
108 }
109
110 pub fn get(&self, key: &str) -> Option<String> {
112 match key {
113 "profile.exclude" => Some(format!("{:?}", self.profile.exclude)),
114 "profile.include" => Some(format!("{:?}", self.profile.include)),
115 _ => None,
116 }
117 }
118
119 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
121 match key {
122 "profile.exclude" => {
123 self.profile.exclude = parse_string_list(value)?;
124 Ok(())
125 }
126 "profile.include" => {
127 self.profile.include = parse_string_list(value)?;
128 Ok(())
129 }
130 _ => Err(DotAgentError::ConfigKeyNotFound {
131 key: key.to_string(),
132 }),
133 }
134 }
135
136 pub fn list(&self) -> Vec<(String, String)> {
138 vec![
139 (
140 "profile.exclude".to_string(),
141 format!("{:?}", self.profile.exclude),
142 ),
143 (
144 "profile.include".to_string(),
145 format!("{:?}", self.profile.include),
146 ),
147 ]
148 }
149
150 pub fn to_ignore_config(&self) -> IgnoreConfig {
152 IgnoreConfig {
153 excluded_dirs: self.profile.exclude.clone(),
154 included_dirs: self.profile.include.clone(),
155 }
156 }
157}
158
159fn parse_string_list(value: &str) -> Result<Vec<String>> {
161 let trimmed = value.trim();
162
163 if trimmed.starts_with('[') && trimmed.ends_with(']') {
165 let inner = &trimmed[1..trimmed.len() - 1];
166 if inner.trim().is_empty() {
167 return Ok(Vec::new());
168 }
169
170 let items: Vec<String> = inner
171 .split(',')
172 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
173 .filter(|s| !s.is_empty())
174 .collect();
175 return Ok(items);
176 }
177
178 let items: Vec<String> = trimmed
180 .split(',')
181 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
182 .filter(|s| !s.is_empty())
183 .collect();
184
185 Ok(items)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_parse_string_list_comma() {
194 let result = parse_string_list(".git,node_modules").unwrap();
195 assert_eq!(result, vec![".git", "node_modules"]);
196 }
197
198 #[test]
199 fn test_parse_string_list_json() {
200 let result = parse_string_list(r#"[".git", "node_modules"]"#).unwrap();
201 assert_eq!(result, vec![".git", "node_modules"]);
202 }
203
204 #[test]
205 fn test_parse_string_list_empty() {
206 let result = parse_string_list("[]").unwrap();
207 assert!(result.is_empty());
208 }
209
210 #[test]
211 fn test_config_get_set() {
212 let mut config = Config::default();
213
214 config.set("profile.exclude", ".git,node_modules").unwrap();
215 assert_eq!(config.profile.exclude, vec![".git", "node_modules"]);
216
217 let value = config.get("profile.exclude").unwrap();
218 assert!(value.contains(".git"));
219 }
220
221 #[test]
222 fn test_to_ignore_config() {
223 let mut config = Config::default();
224 config.profile.exclude = vec![".git".to_string(), "node_modules".to_string()];
225 config.profile.include = vec![".gitkeep".to_string()];
226
227 let ignore = config.to_ignore_config();
228 assert_eq!(ignore.excluded_dirs, vec![".git", "node_modules"]);
229 assert_eq!(ignore.included_dirs, vec![".gitkeep"]);
230 }
231}