Skip to main content

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# Default profile to use when none specified
27# default = "my-profile"
28"#;
29
30/// Global configuration
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct Config {
33    #[serde(default)]
34    pub profile: ProfileConfig,
35}
36
37/// Profile-related configuration
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ProfileConfig {
40    /// Directories to exclude
41    #[serde(default = "default_exclude")]
42    pub exclude: Vec<String>,
43
44    /// Directories to include (overrides exclude)
45    #[serde(default)]
46    pub include: Vec<String>,
47
48    /// Default profile name
49    #[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    /// Load config from base directory
72    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    /// Save config to base directory
88    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    /// Get config file path
102    pub fn path(base_dir: &Path) -> PathBuf {
103        base_dir.join(CONFIG_FILE)
104    }
105
106    /// Initialize config with default template (rich comments)
107    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    /// Get a config value by dot-notation key
119    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    /// Set a config value by dot-notation key
129    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    /// Clear the default profile
155    pub fn clear_default(&mut self) {
156        self.profile.default = None;
157    }
158
159    /// List all config keys with their current values
160    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    /// Convert to IgnoreConfig for use in install/upgrade
181    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
189/// Parse a comma-separated or JSON-like list string
190fn parse_string_list(value: &str) -> Result<Vec<String>> {
191    let trimmed = value.trim();
192
193    // Try JSON array format first: ["a", "b"]
194    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    // Comma-separated format: a,b,c or "a","b"
209    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}