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::llm::LlmConfig;
8use crate::profile::{IgnoreConfig, DEFAULT_EXCLUDED_DIRS};
9
10const CONFIG_FILE: &str = "config.toml";
11
12/// Default config template with rich comments
13const 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/// Global configuration
38#[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/// Profile-related configuration
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ProfileConfig {
50    /// Directories to exclude
51    #[serde(default = "default_exclude")]
52    pub exclude: Vec<String>,
53
54    /// Directories to include (overrides exclude)
55    #[serde(default)]
56    pub include: Vec<String>,
57
58    /// Default profile name
59    #[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    /// Load config from base directory
82    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    /// Save config to base directory
98    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    /// Get config file path
112    pub fn path(base_dir: &Path) -> PathBuf {
113        base_dir.join(CONFIG_FILE)
114    }
115
116    /// Initialize config with default template (rich comments)
117    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    /// Get a config value by dot-notation key
129    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    /// Set a config value by dot-notation key
140    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    /// Clear the default profile
171    pub fn clear_default(&mut self) {
172        self.profile.default = None;
173    }
174
175    /// List all config keys with their current values
176    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    /// Convert to IgnoreConfig for use in install/upgrade
198    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
206/// Parse a comma-separated or JSON-like list string
207fn parse_string_list(value: &str) -> Result<Vec<String>> {
208    let trimmed = value.trim();
209
210    // Try JSON array format first: ["a", "b"]
211    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    // Comma-separated format: a,b,c or "a","b"
226    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}