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