Skip to main content

codetether_agent/config/
mod.rs

1//! Configuration system
2//!
3//! Handles loading configuration from multiple sources:
4//! - Global config (~/.config/codetether/config.toml)
5//! - Project config (./codetether.toml or .codetether/config.toml)
6//! - Environment variables (CODETETHER_*)
7
8use anyhow::Result;
9use directories::ProjectDirs;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13use tokio::fs;
14
15/// Main configuration structure
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct Config {
18    /// Default provider to use
19    #[serde(default)]
20    pub default_provider: Option<String>,
21
22    /// Default model to use (provider/model format)
23    #[serde(default)]
24    pub default_model: Option<String>,
25
26    /// Provider-specific configurations
27    #[serde(default)]
28    pub providers: HashMap<String, ProviderConfig>,
29
30    /// Agent configurations
31    #[serde(default)]
32    pub agents: HashMap<String, AgentConfig>,
33
34    /// Permission rules
35    #[serde(default)]
36    pub permissions: PermissionConfig,
37
38    /// A2A worker settings
39    #[serde(default)]
40    pub a2a: A2aConfig,
41
42    /// UI/TUI settings
43    #[serde(default)]
44    pub ui: UiConfig,
45
46    /// Session settings
47    #[serde(default)]
48    pub session: SessionConfig,
49}
50
51#[derive(Clone, Serialize, Deserialize, Default)]
52pub struct ProviderConfig {
53    /// API key (can also be set via env var)
54    pub api_key: Option<String>,
55
56    /// Base URL override
57    pub base_url: Option<String>,
58
59    /// Custom headers
60    #[serde(default)]
61    pub headers: HashMap<String, String>,
62
63    /// Organization ID (for OpenAI)
64    pub organization: Option<String>,
65}
66
67impl std::fmt::Debug for ProviderConfig {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("ProviderConfig")
70            .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
71            .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
72            .field("base_url", &self.base_url)
73            .field("organization", &self.organization)
74            .field("headers_count", &self.headers.len())
75            .finish()
76    }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct AgentConfig {
81    /// Agent name
82    pub name: String,
83
84    /// Description
85    #[serde(default)]
86    pub description: Option<String>,
87
88    /// Model override for this agent
89    #[serde(default)]
90    pub model: Option<String>,
91
92    /// System prompt override
93    #[serde(default)]
94    pub prompt: Option<String>,
95
96    /// Temperature setting
97    #[serde(default)]
98    pub temperature: Option<f32>,
99
100    /// Top-p setting
101    #[serde(default)]
102    pub top_p: Option<f32>,
103
104    /// Custom permissions for this agent
105    #[serde(default)]
106    pub permissions: HashMap<String, PermissionAction>,
107
108    /// Whether this agent is disabled
109    #[serde(default)]
110    pub disabled: bool,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
114pub struct PermissionConfig {
115    /// Default permission rules
116    #[serde(default)]
117    pub rules: HashMap<String, PermissionAction>,
118
119    /// Tool-specific permissions
120    #[serde(default)]
121    pub tools: HashMap<String, PermissionAction>,
122
123    /// Path-specific permissions
124    #[serde(default)]
125    pub paths: HashMap<String, PermissionAction>,
126}
127
128#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(rename_all = "lowercase")]
130pub enum PermissionAction {
131    Allow,
132    Deny,
133    Ask,
134}
135
136impl Default for PermissionAction {
137    fn default() -> Self {
138        Self::Ask
139    }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct A2aConfig {
144    /// Default A2A server URL
145    pub server_url: Option<String>,
146
147    /// Worker name
148    pub worker_name: Option<String>,
149
150    /// Auto-approve policy
151    #[serde(default)]
152    pub auto_approve: AutoApprovePolicy,
153
154    /// Codebases to register
155    #[serde(default)]
156    pub codebases: Vec<PathBuf>,
157}
158
159#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
160#[serde(rename_all = "lowercase")]
161pub enum AutoApprovePolicy {
162    All,
163    #[default]
164    Safe,
165    None,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct UiConfig {
170    /// Theme name ("default", "dark", "light", "solarized-dark", "solarized-light", or custom)
171    #[serde(default = "default_theme")]
172    pub theme: String,
173
174    /// Show line numbers in code
175    #[serde(default = "default_true")]
176    pub line_numbers: bool,
177
178    /// Enable mouse support
179    #[serde(default = "default_true")]
180    pub mouse: bool,
181
182    /// Custom theme configuration (overrides preset themes)
183    #[serde(default)]
184    pub custom_theme: Option<crate::tui::theme::Theme>,
185
186    /// Enable theme hot-reloading (apply changes without restart)
187    #[serde(default = "default_false")]
188    pub hot_reload: bool,
189}
190
191impl Default for UiConfig {
192    fn default() -> Self {
193        Self {
194            theme: default_theme(),
195            line_numbers: true,
196            mouse: true,
197            custom_theme: None,
198            hot_reload: false,
199        }
200    }
201}
202
203fn default_theme() -> String {
204    "default".to_string()
205}
206
207fn default_true() -> bool {
208    true
209}
210
211fn default_false() -> bool {
212    false
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct SessionConfig {
217    /// Auto-compact sessions when they get too long
218    #[serde(default = "default_true")]
219    pub auto_compact: bool,
220
221    /// Maximum context tokens before compaction
222    #[serde(default = "default_max_tokens")]
223    pub max_tokens: usize,
224
225    /// Enable session persistence
226    #[serde(default = "default_true")]
227    pub persist: bool,
228}
229
230impl Default for SessionConfig {
231    fn default() -> Self {
232        Self {
233            auto_compact: true,
234            max_tokens: default_max_tokens(),
235            persist: true,
236        }
237    }
238}
239
240fn default_max_tokens() -> usize {
241    100_000
242}
243
244impl Config {
245    /// Load configuration from all sources (global, project, env)
246    pub async fn load() -> Result<Self> {
247        let mut config = Self::default();
248
249        // Load global config
250        if let Some(global_path) = Self::global_config_path() {
251            if global_path.exists() {
252                let content = fs::read_to_string(&global_path).await?;
253                let global: Config = toml::from_str(&content)?;
254                config = config.merge(global);
255            }
256        }
257
258        // Load project config
259        for name in ["codetether.toml", ".codetether/config.toml"] {
260            let path = PathBuf::from(name);
261            if path.exists() {
262                let content = fs::read_to_string(&path).await?;
263                let project: Config = toml::from_str(&content)?;
264                config = config.merge(project);
265            }
266        }
267
268        // Apply environment overrides
269        config.apply_env();
270
271        Ok(config)
272    }
273
274    /// Get the global config directory path
275    pub fn global_config_path() -> Option<PathBuf> {
276        ProjectDirs::from("ai", "codetether", "codetether-agent")
277            .map(|dirs| dirs.config_dir().join("config.toml"))
278    }
279
280    /// Get the data directory path
281    pub fn data_dir() -> Option<PathBuf> {
282        ProjectDirs::from("ai", "codetether", "codetether-agent")
283            .map(|dirs| dirs.data_dir().to_path_buf())
284    }
285
286    /// Initialize default configuration file
287    pub async fn init_default() -> Result<()> {
288        if let Some(path) = Self::global_config_path() {
289            if let Some(parent) = path.parent() {
290                fs::create_dir_all(parent).await?;
291            }
292            let default = Self::default();
293            let content = toml::to_string_pretty(&default)?;
294            fs::write(&path, content).await?;
295            tracing::info!("Created config at {:?}", path);
296        }
297        Ok(())
298    }
299
300    /// Set a configuration value
301    pub async fn set(key: &str, value: &str) -> Result<()> {
302        let mut config = Self::load().await?;
303
304        // Parse key path and set value
305        match key {
306            "default_provider" => config.default_provider = Some(value.to_string()),
307            "default_model" => config.default_model = Some(value.to_string()),
308            "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
309            "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
310            "ui.theme" => config.ui.theme = value.to_string(),
311            _ => anyhow::bail!("Unknown config key: {}", key),
312        }
313
314        // Save to global config
315        if let Some(path) = Self::global_config_path() {
316            let content = toml::to_string_pretty(&config)?;
317            fs::write(&path, content).await?;
318        }
319
320        Ok(())
321    }
322
323    /// Merge two configs (other takes precedence)
324    fn merge(mut self, other: Self) -> Self {
325        if other.default_provider.is_some() {
326            self.default_provider = other.default_provider;
327        }
328        if other.default_model.is_some() {
329            self.default_model = other.default_model;
330        }
331        self.providers.extend(other.providers);
332        self.agents.extend(other.agents);
333        self.permissions.rules.extend(other.permissions.rules);
334        self.permissions.tools.extend(other.permissions.tools);
335        self.permissions.paths.extend(other.permissions.paths);
336        if other.a2a.server_url.is_some() {
337            self.a2a = other.a2a;
338        }
339        self
340    }
341
342    /// Load theme based on configuration
343    pub fn load_theme(&self) -> crate::tui::theme::Theme {
344        // Use custom theme if provided
345        if let Some(custom) = &self.ui.custom_theme {
346            return custom.clone();
347        }
348
349        // Use preset theme
350        match self.ui.theme.as_str() {
351            "dark" | "default" => crate::tui::theme::Theme::dark(),
352            "light" => crate::tui::theme::Theme::light(),
353            "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
354            "solarized-light" => crate::tui::theme::Theme::solarized_light(),
355            _ => {
356                // Log warning and fallback to dark theme
357                tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to dark");
358                crate::tui::theme::Theme::dark()
359            }
360        }
361    }
362
363    /// Apply environment variable overrides
364    fn apply_env(&mut self) {
365        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
366            self.default_model = Some(val);
367        }
368        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
369            self.default_provider = Some(val);
370        }
371        if let Ok(val) = std::env::var("OPENAI_API_KEY") {
372            self.providers
373                .entry("openai".to_string())
374                .or_default()
375                .api_key = Some(val);
376        }
377        if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
378            self.providers
379                .entry("anthropic".to_string())
380                .or_default()
381                .api_key = Some(val);
382        }
383        if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
384            self.providers
385                .entry("google".to_string())
386                .or_default()
387                .api_key = Some(val);
388        }
389        if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
390            self.a2a.server_url = Some(val);
391        }
392    }
393}