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}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ContextConfig {
23    #[serde(default = "default_true")]
24    pub auto_load_global: bool,
25    #[serde(default = "default_true")]
26    pub auto_load_project: bool,
27}
28impl Default for ContextConfig {
29    fn default() -> Self {
30        Self {
31            auto_load_global: true,
32            auto_load_project: true,
33        }
34    }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ThemeConfig {
39    pub name: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct McpServerConfig {
44    #[serde(default)]
45    pub command: Vec<String>,
46    pub url: Option<String>,
47    #[serde(default = "default_true")]
48    pub enabled: bool,
49    #[serde(default)]
50    pub env: HashMap<String, String>,
51    #[serde(default = "default_timeout")]
52    pub timeout: u64,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct AgentConfig {
57    pub description: String,
58    pub model: Option<String>,
59    pub system_prompt: Option<String>,
60    #[serde(default)]
61    pub tools: HashMap<String, bool>,
62    #[serde(default = "default_true")]
63    pub enabled: bool,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct TuiConfig {
68    #[serde(default = "default_true")]
69    pub vim_mode: bool,
70}
71
72impl Default for TuiConfig {
73    fn default() -> Self {
74        Self { vim_mode: true }
75    }
76}
77
78fn default_true() -> bool {
79    true
80}
81
82fn default_timeout() -> u64 {
83    30
84}
85
86impl Default for Config {
87    fn default() -> Self {
88        Self {
89            default_provider: "anthropic".to_string(),
90            default_model: "claude-sonnet-4-20250514".to_string(),
91            theme: ThemeConfig {
92                name: "dark".to_string(),
93            },
94            context: ContextConfig::default(),
95
96            mcp: HashMap::new(),
97            agents: HashMap::new(),
98            tui: TuiConfig::default(),
99        }
100    }
101}
102
103impl Config {
104    pub fn config_dir() -> PathBuf {
105        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
106            if !xdg.is_empty() {
107                return PathBuf::from(xdg).join("dot");
108            }
109        }
110        #[cfg(unix)]
111        return dirs::home_dir()
112            .unwrap_or_else(|| PathBuf::from("."))
113            .join(".config")
114            .join("dot");
115        #[cfg(not(unix))]
116        dirs::config_dir()
117            .unwrap_or_else(|| PathBuf::from("."))
118            .join("dot")
119    }
120
121    pub fn config_path() -> PathBuf {
122        Self::config_dir().join("config.toml")
123    }
124
125    pub fn data_dir() -> PathBuf {
126        if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
127            if !xdg.is_empty() {
128                return PathBuf::from(xdg).join("dot");
129            }
130        }
131        #[cfg(unix)]
132        return dirs::home_dir()
133            .unwrap_or_else(|| PathBuf::from("."))
134            .join(".local")
135            .join("share")
136            .join("dot");
137        #[cfg(not(unix))]
138        dirs::data_local_dir()
139            .unwrap_or_else(|| PathBuf::from("."))
140            .join("dot")
141    }
142
143    pub fn db_path() -> PathBuf {
144        Self::data_dir().join("dot.db")
145    }
146
147    pub fn load() -> Result<Self> {
148        let path = Self::config_path();
149        if path.exists() {
150            let content = std::fs::read_to_string(&path)
151                .with_context(|| format!("reading config from {}", path.display()))?;
152            toml::from_str(&content).context("parsing config.toml")
153        } else {
154            let config = Self::default();
155            config.save()?;
156            Ok(config)
157        }
158    }
159
160    pub fn save(&self) -> Result<()> {
161        let dir = Self::config_dir();
162        std::fs::create_dir_all(&dir)
163            .with_context(|| format!("creating config dir {}", dir.display()))?;
164        let content = toml::to_string_pretty(self).context("serializing config")?;
165        std::fs::write(Self::config_path(), content).context("writing config.toml")
166    }
167
168    pub fn ensure_dirs() -> Result<()> {
169        std::fs::create_dir_all(Self::config_dir()).context("creating config directory")?;
170        std::fs::create_dir_all(Self::data_dir()).context("creating data directory")?;
171        Ok(())
172    }
173
174    pub fn enabled_mcp_servers(&self) -> Vec<(&str, &McpServerConfig)> {
175        self.mcp
176            .iter()
177            .filter(|(_, cfg)| cfg.enabled && !cfg.command.is_empty())
178            .map(|(name, cfg)| (name.as_str(), cfg))
179            .collect()
180    }
181
182    pub fn enabled_agents(&self) -> Vec<(&str, &AgentConfig)> {
183        self.agents
184            .iter()
185            .filter(|(_, cfg)| cfg.enabled)
186            .map(|(name, cfg)| (name.as_str(), cfg))
187            .collect()
188    }
189
190    /// Parse a `provider/model` spec. Returns `(provider, model)` if `/` present,
191    /// otherwise `(None, spec)`.
192    pub fn parse_model_spec(spec: &str) -> (Option<&str>, &str) {
193        if let Some((provider, model)) = spec.split_once('/') {
194            (Some(provider), model)
195        } else {
196            (None, spec)
197        }
198    }
199}