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