dot_agent_core/
config.rs

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
11/// Default config template with rich comments
12const 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/// Global configuration
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct Config {
30    #[serde(default)]
31    pub profile: ProfileConfig,
32}
33
34/// Profile-related configuration
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ProfileConfig {
37    /// Directories to exclude
38    #[serde(default = "default_exclude")]
39    pub exclude: Vec<String>,
40
41    /// Directories to include (overrides exclude)
42    #[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    /// Load config from base directory
64    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    /// Save config to base directory
80    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    /// Get config file path
94    pub fn path(base_dir: &Path) -> PathBuf {
95        base_dir.join(CONFIG_FILE)
96    }
97
98    /// Initialize config with default template (rich comments)
99    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    /// Get a config value by dot-notation key
111    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    /// Set a config value by dot-notation key
120    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    /// List all config keys with their current values
137    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    /// Convert to IgnoreConfig for use in install/upgrade
151    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
159/// Parse a comma-separated or JSON-like list string
160fn parse_string_list(value: &str) -> Result<Vec<String>> {
161    let trimmed = value.trim();
162
163    // Try JSON array format first: ["a", "b"]
164    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    // Comma-separated format: a,b,c or "a","b"
179    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}