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}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ContextConfig {
33    #[serde(default = "default_true")]
34    pub auto_load_global: bool,
35    #[serde(default = "default_true")]
36    pub auto_load_project: bool,
37}
38impl Default for ContextConfig {
39    fn default() -> Self {
40        Self {
41            auto_load_global: true,
42            auto_load_project: true,
43        }
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ThemeConfig {
49    pub name: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct McpServerConfig {
54    #[serde(default)]
55    pub command: Vec<String>,
56    pub url: Option<String>,
57    #[serde(default = "default_true")]
58    pub enabled: bool,
59    #[serde(default)]
60    pub env: HashMap<String, String>,
61    #[serde(default = "default_timeout")]
62    pub timeout: u64,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct AgentConfig {
67    pub description: String,
68    pub model: Option<String>,
69    pub system_prompt: Option<String>,
70    #[serde(default)]
71    pub tools: HashMap<String, bool>,
72    #[serde(default = "default_true")]
73    pub enabled: bool,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct TuiConfig {
78    #[serde(default = "default_true")]
79    pub vim_mode: bool,
80    #[serde(default)]
81    pub favorite_models: Vec<String>,
82}
83
84impl Default for TuiConfig {
85    fn default() -> Self {
86        Self {
87            vim_mode: true,
88            favorite_models: Vec::new(),
89        }
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ProviderDefinition {
95    pub api: String,
96    pub base_url: Option<String>,
97    #[serde(default)]
98    pub api_key_env: Option<String>,
99    #[serde(default)]
100    pub models: Vec<String>,
101    pub default_model: Option<String>,
102    #[serde(default = "default_true")]
103    pub enabled: bool,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct CustomToolConfig {
108    pub description: String,
109    pub command: String,
110    #[serde(default = "default_schema")]
111    pub schema: serde_json::Value,
112    #[serde(default = "default_timeout")]
113    pub timeout: u64,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CommandConfig {
118    pub description: String,
119    pub command: String,
120    #[serde(default = "default_timeout")]
121    pub timeout: u64,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct HookConfig {
126    pub command: String,
127    #[serde(default = "default_timeout")]
128    pub timeout: u64,
129}
130
131fn default_true() -> bool {
132    true
133}
134
135fn default_timeout() -> u64 {
136    30
137}
138
139fn default_schema() -> serde_json::Value {
140    serde_json::json!({
141        "type": "object",
142        "properties": {},
143        "required": []
144    })
145}
146
147impl Default for Config {
148    fn default() -> Self {
149        Self {
150            default_provider: "anthropic".to_string(),
151            default_model: "claude-sonnet-4-20250514".to_string(),
152            theme: ThemeConfig {
153                name: "terminal".to_string(),
154            },
155            context: ContextConfig::default(),
156            mcp: HashMap::new(),
157            agents: HashMap::new(),
158            tui: TuiConfig::default(),
159            permissions: HashMap::new(),
160            providers: HashMap::new(),
161            custom_tools: HashMap::new(),
162            commands: HashMap::new(),
163            hooks: HashMap::new(),
164        }
165    }
166}
167
168impl Config {
169    pub fn config_dir() -> PathBuf {
170        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
171            && !xdg.is_empty()
172        {
173            return PathBuf::from(xdg).join("dot");
174        }
175        #[cfg(unix)]
176        return dirs::home_dir()
177            .unwrap_or_else(|| PathBuf::from("."))
178            .join(".config")
179            .join("dot");
180        #[cfg(not(unix))]
181        dirs::config_dir()
182            .unwrap_or_else(|| PathBuf::from("."))
183            .join("dot")
184    }
185
186    pub fn config_path() -> PathBuf {
187        Self::config_dir().join("config.toml")
188    }
189
190    pub fn data_dir() -> PathBuf {
191        if let Ok(xdg) = std::env::var("XDG_DATA_HOME")
192            && !xdg.is_empty()
193        {
194            return PathBuf::from(xdg).join("dot");
195        }
196        #[cfg(unix)]
197        return dirs::home_dir()
198            .unwrap_or_else(|| PathBuf::from("."))
199            .join(".local")
200            .join("share")
201            .join("dot");
202        #[cfg(not(unix))]
203        dirs::data_local_dir()
204            .unwrap_or_else(|| PathBuf::from("."))
205            .join("dot")
206    }
207
208    pub fn db_path() -> PathBuf {
209        Self::data_dir().join("dot.db")
210    }
211
212    pub fn load() -> Result<Self> {
213        let path = Self::config_path();
214        if path.exists() {
215            let content = std::fs::read_to_string(&path)
216                .with_context(|| format!("reading config from {}", path.display()))?;
217            toml::from_str(&content).context("parsing config.toml")
218        } else {
219            let config = Self::default();
220            config.save()?;
221            Ok(config)
222        }
223    }
224
225    pub fn save(&self) -> Result<()> {
226        let dir = Self::config_dir();
227        std::fs::create_dir_all(&dir)
228            .with_context(|| format!("creating config dir {}", dir.display()))?;
229        let content = toml::to_string_pretty(self).context("serializing config")?;
230        std::fs::write(Self::config_path(), content).context("writing config.toml")
231    }
232
233    pub fn ensure_dirs() -> Result<()> {
234        std::fs::create_dir_all(Self::config_dir()).context("creating config directory")?;
235        std::fs::create_dir_all(Self::data_dir()).context("creating data directory")?;
236        Ok(())
237    }
238
239    pub fn enabled_mcp_servers(&self) -> Vec<(&str, &McpServerConfig)> {
240        self.mcp
241            .iter()
242            .filter(|(_, cfg)| cfg.enabled && !cfg.command.is_empty())
243            .map(|(name, cfg)| (name.as_str(), cfg))
244            .collect()
245    }
246
247    pub fn enabled_agents(&self) -> Vec<(&str, &AgentConfig)> {
248        self.agents
249            .iter()
250            .filter(|(_, cfg)| cfg.enabled)
251            .map(|(name, cfg)| (name.as_str(), cfg))
252            .collect()
253    }
254
255    /// Parse a `provider/model` spec. Returns `(provider, model)` if `/` present,
256    /// otherwise `(None, spec)`.
257    pub fn parse_model_spec(spec: &str) -> (Option<&str>, &str) {
258        if let Some((provider, model)) = spec.split_once('/') {
259            (Some(provider), model)
260        } else {
261            (None, spec)
262        }
263    }
264}