Skip to main content

dot/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Config {
8    pub default_provider: String,
9    pub default_model: String,
10    pub theme: ThemeConfig,
11    #[serde(default)]
12    pub context: ContextConfig,
13    #[serde(default)]
14    pub mcp: HashMap<String, McpServerConfig>,
15    #[serde(default)]
16    pub agents: HashMap<String, AgentConfig>,
17    #[serde(default)]
18    pub tui: TuiConfig,
19    #[serde(default)]
20    pub permissions: HashMap<String, String>,
21    #[serde(default)]
22    pub providers: HashMap<String, ProviderDefinition>,
23    #[serde(default)]
24    pub custom_tools: HashMap<String, CustomToolConfig>,
25    #[serde(default)]
26    pub commands: HashMap<String, CommandConfig>,
27    #[serde(default)]
28    pub hooks: HashMap<String, HookConfig>,
29    #[serde(default)]
30    pub subagents: SubagentSettings,
31    #[serde(default)]
32    pub memory: MemoryConfig,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ContextConfig {
37    #[serde(default = "default_true")]
38    pub auto_load_global: bool,
39    #[serde(default = "default_true")]
40    pub auto_load_project: bool,
41}
42impl Default for ContextConfig {
43    fn default() -> Self {
44        Self {
45            auto_load_global: true,
46            auto_load_project: true,
47        }
48    }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ThemeConfig {
53    pub name: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct McpServerConfig {
58    #[serde(default)]
59    pub command: Vec<String>,
60    pub url: Option<String>,
61    #[serde(default = "default_true")]
62    pub enabled: bool,
63    #[serde(default)]
64    pub env: HashMap<String, String>,
65    #[serde(default = "default_timeout")]
66    pub timeout: u64,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct AgentConfig {
71    pub description: String,
72    pub model: Option<String>,
73    pub system_prompt: Option<String>,
74    #[serde(default)]
75    pub tools: HashMap<String, bool>,
76    #[serde(default = "default_true")]
77    pub enabled: bool,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TuiConfig {
82    #[serde(default = "default_true")]
83    pub vim_mode: bool,
84    #[serde(default)]
85    pub favorite_models: Vec<String>,
86}
87
88impl Default for TuiConfig {
89    fn default() -> Self {
90        Self {
91            vim_mode: true,
92            favorite_models: Vec::new(),
93        }
94    }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ProviderDefinition {
99    pub api: String,
100    pub base_url: Option<String>,
101    #[serde(default)]
102    pub api_key_env: Option<String>,
103    #[serde(default)]
104    pub models: Vec<String>,
105    pub default_model: Option<String>,
106    #[serde(default = "default_true")]
107    pub enabled: bool,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct CustomToolConfig {
112    pub description: String,
113    pub command: String,
114    #[serde(default = "default_schema")]
115    pub schema: serde_json::Value,
116    #[serde(default = "default_timeout")]
117    pub timeout: u64,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct CommandConfig {
122    pub description: String,
123    pub command: String,
124    #[serde(default = "default_timeout")]
125    pub timeout: u64,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct HookConfig {
130    pub command: String,
131    #[serde(default = "default_timeout")]
132    pub timeout: u64,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SubagentSettings {
137    #[serde(default = "default_true")]
138    pub enabled: bool,
139    #[serde(default = "default_max_subagent_turns")]
140    pub max_turns: usize,
141}
142
143impl Default for SubagentSettings {
144    fn default() -> Self {
145        Self {
146            enabled: true,
147            max_turns: 20,
148        }
149    }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct MemoryConfig {
154    #[serde(default = "default_true")]
155    pub enabled: bool,
156    #[serde(default = "default_true")]
157    pub auto_extract: bool,
158    #[serde(default = "default_inject_count")]
159    pub inject_count: usize,
160    #[serde(default = "default_max_memories")]
161    pub max_memories: usize,
162}
163
164impl Default for MemoryConfig {
165    fn default() -> Self {
166        Self {
167            enabled: true,
168            auto_extract: true,
169            inject_count: 15,
170            max_memories: 2000,
171        }
172    }
173}
174
175fn default_inject_count() -> usize {
176    15
177}
178
179fn default_max_memories() -> usize {
180    2000
181}
182
183fn default_max_subagent_turns() -> usize {
184    20
185}
186
187fn default_true() -> bool {
188    true
189}
190
191fn default_timeout() -> u64 {
192    30
193}
194
195fn default_schema() -> serde_json::Value {
196    serde_json::json!({
197        "type": "object",
198        "properties": {},
199        "required": []
200    })
201}
202
203impl Default for Config {
204    fn default() -> Self {
205        Self {
206            default_provider: "anthropic".to_string(),
207            default_model: "claude-sonnet-4-20250514".to_string(),
208            theme: ThemeConfig {
209                name: "terminal".to_string(),
210            },
211            context: ContextConfig::default(),
212            mcp: HashMap::new(),
213            agents: HashMap::new(),
214            tui: TuiConfig::default(),
215            permissions: HashMap::new(),
216            providers: HashMap::new(),
217            custom_tools: HashMap::new(),
218            commands: HashMap::new(),
219            hooks: HashMap::new(),
220            subagents: SubagentSettings::default(),
221            memory: MemoryConfig::default(),
222        }
223    }
224}
225
226impl Config {
227    pub fn config_dir() -> PathBuf {
228        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
229            && !xdg.is_empty()
230        {
231            return PathBuf::from(xdg).join("dot");
232        }
233        #[cfg(unix)]
234        return dirs::home_dir()
235            .unwrap_or_else(|| PathBuf::from("."))
236            .join(".config")
237            .join("dot");
238        #[cfg(not(unix))]
239        dirs::config_dir()
240            .unwrap_or_else(|| PathBuf::from("."))
241            .join("dot")
242    }
243
244    pub fn config_path() -> PathBuf {
245        Self::config_dir().join("config.toml")
246    }
247
248    pub fn data_dir() -> PathBuf {
249        if let Ok(xdg) = std::env::var("XDG_DATA_HOME")
250            && !xdg.is_empty()
251        {
252            return PathBuf::from(xdg).join("dot");
253        }
254        #[cfg(unix)]
255        return dirs::home_dir()
256            .unwrap_or_else(|| PathBuf::from("."))
257            .join(".local")
258            .join("share")
259            .join("dot");
260        #[cfg(not(unix))]
261        dirs::data_local_dir()
262            .unwrap_or_else(|| PathBuf::from("."))
263            .join("dot")
264    }
265
266    pub fn db_path() -> PathBuf {
267        Self::data_dir().join("dot.db")
268    }
269
270    pub fn load() -> Result<Self> {
271        let path = Self::config_path();
272        if path.exists() {
273            let content = std::fs::read_to_string(&path)
274                .with_context(|| format!("reading config from {}", path.display()))?;
275            toml::from_str(&content).context("parsing config.toml")
276        } else {
277            let config = Self::default();
278            config.save()?;
279            Ok(config)
280        }
281    }
282
283    pub fn save(&self) -> Result<()> {
284        let dir = Self::config_dir();
285        std::fs::create_dir_all(&dir)
286            .with_context(|| format!("creating config dir {}", dir.display()))?;
287        let content = toml::to_string_pretty(self).context("serializing config")?;
288        std::fs::write(Self::config_path(), content).context("writing config.toml")
289    }
290
291    pub fn ensure_dirs() -> Result<()> {
292        std::fs::create_dir_all(Self::config_dir()).context("creating config directory")?;
293        std::fs::create_dir_all(Self::data_dir()).context("creating data directory")?;
294        Ok(())
295    }
296
297    pub fn enabled_mcp_servers(&self) -> Vec<(&str, &McpServerConfig)> {
298        self.mcp
299            .iter()
300            .filter(|(_, cfg)| cfg.enabled && !cfg.command.is_empty())
301            .map(|(name, cfg)| (name.as_str(), cfg))
302            .collect()
303    }
304
305    pub fn enabled_agents(&self) -> Vec<(&str, &AgentConfig)> {
306        self.agents
307            .iter()
308            .filter(|(_, cfg)| cfg.enabled)
309            .map(|(name, cfg)| (name.as_str(), cfg))
310            .collect()
311    }
312
313    /// Parse a `provider/model` spec. Returns `(provider, model)` if `/` present,
314    /// otherwise `(None, spec)`.
315    pub fn parse_model_spec(spec: &str) -> (Option<&str>, &str) {
316        if let Some((provider, model)) = spec.split_once('/') {
317            (Some(provider), model)
318        } else {
319            (None, spec)
320        }
321    }
322}