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    /// Telemetry and crash reporting settings
51    #[serde(default)]
52    pub telemetry: TelemetryConfig,
53}
54
55#[derive(Clone, Serialize, Deserialize, Default)]
56pub struct ProviderConfig {
57    /// API key (can also be set via env var)
58    pub api_key: Option<String>,
59
60    /// Base URL override
61    pub base_url: Option<String>,
62
63    /// Custom headers
64    #[serde(default)]
65    pub headers: HashMap<String, String>,
66
67    /// Organization ID (for OpenAI)
68    pub organization: Option<String>,
69}
70
71impl std::fmt::Debug for ProviderConfig {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("ProviderConfig")
74            .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
75            .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
76            .field("base_url", &self.base_url)
77            .field("organization", &self.organization)
78            .field("headers_count", &self.headers.len())
79            .finish()
80    }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct AgentConfig {
85    /// Agent name
86    pub name: String,
87
88    /// Description
89    #[serde(default)]
90    pub description: Option<String>,
91
92    /// Model override for this agent
93    #[serde(default)]
94    pub model: Option<String>,
95
96    /// System prompt override
97    #[serde(default)]
98    pub prompt: Option<String>,
99
100    /// Temperature setting
101    #[serde(default)]
102    pub temperature: Option<f32>,
103
104    /// Top-p setting
105    #[serde(default)]
106    pub top_p: Option<f32>,
107
108    /// Custom permissions for this agent
109    #[serde(default)]
110    pub permissions: HashMap<String, PermissionAction>,
111
112    /// Whether this agent is disabled
113    #[serde(default)]
114    pub disabled: bool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct PermissionConfig {
119    /// Default permission rules
120    #[serde(default)]
121    pub rules: HashMap<String, PermissionAction>,
122
123    /// Tool-specific permissions
124    #[serde(default)]
125    pub tools: HashMap<String, PermissionAction>,
126
127    /// Path-specific permissions
128    #[serde(default)]
129    pub paths: HashMap<String, PermissionAction>,
130}
131
132#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "lowercase")]
134pub enum PermissionAction {
135    Allow,
136    Deny,
137    Ask,
138}
139
140impl Default for PermissionAction {
141    fn default() -> Self {
142        Self::Ask
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct A2aConfig {
148    /// Default A2A server URL
149    pub server_url: Option<String>,
150
151    /// Worker name
152    pub worker_name: Option<String>,
153
154    /// Auto-approve policy
155    #[serde(default)]
156    pub auto_approve: AutoApprovePolicy,
157
158    /// Codebases to register
159    #[serde(default)]
160    pub codebases: Vec<PathBuf>,
161}
162
163#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
164#[serde(rename_all = "lowercase")]
165pub enum AutoApprovePolicy {
166    All,
167    #[default]
168    Safe,
169    None,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct UiConfig {
174    /// Theme name ("default", "marketing", "dark", "light", "solarized-dark", "solarized-light", or custom)
175    #[serde(default = "default_theme")]
176    pub theme: String,
177
178    /// Show line numbers in code
179    #[serde(default = "default_true")]
180    pub line_numbers: bool,
181
182    /// Enable mouse support
183    #[serde(default = "default_true")]
184    pub mouse: bool,
185
186    /// Custom theme configuration (overrides preset themes)
187    #[serde(default)]
188    pub custom_theme: Option<crate::tui::theme::Theme>,
189
190    /// Enable theme hot-reloading (apply changes without restart)
191    #[serde(default = "default_false")]
192    pub hot_reload: bool,
193}
194
195impl Default for UiConfig {
196    fn default() -> Self {
197        Self {
198            theme: default_theme(),
199            line_numbers: true,
200            mouse: true,
201            custom_theme: None,
202            hot_reload: false,
203        }
204    }
205}
206
207fn default_theme() -> String {
208    "marketing".to_string()
209}
210
211fn default_true() -> bool {
212    true
213}
214
215fn default_false() -> bool {
216    false
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct SessionConfig {
221    /// Auto-compact sessions when they get too long
222    #[serde(default = "default_true")]
223    pub auto_compact: bool,
224
225    /// Maximum context tokens before compaction
226    #[serde(default = "default_max_tokens")]
227    pub max_tokens: usize,
228
229    /// Enable session persistence
230    #[serde(default = "default_true")]
231    pub persist: bool,
232}
233
234impl Default for SessionConfig {
235    fn default() -> Self {
236        Self {
237            auto_compact: true,
238            max_tokens: default_max_tokens(),
239            persist: true,
240        }
241    }
242}
243
244fn default_max_tokens() -> usize {
245    100_000
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct TelemetryConfig {
250    /// Opt-in crash reporting. Disabled by default.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub crash_reporting: Option<bool>,
253
254    /// Whether we have already prompted the user about crash reporting consent.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub crash_reporting_prompted: Option<bool>,
257
258    /// Endpoint for crash report ingestion.
259    /// Defaults to the CodeTether telemetry endpoint when unset.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub crash_report_endpoint: Option<String>,
262}
263
264impl TelemetryConfig {
265    pub fn crash_reporting_enabled(&self) -> bool {
266        self.crash_reporting.unwrap_or(false)
267    }
268
269    pub fn crash_reporting_prompted(&self) -> bool {
270        self.crash_reporting_prompted.unwrap_or(false)
271    }
272
273    pub fn crash_report_endpoint(&self) -> String {
274        self.crash_report_endpoint
275            .clone()
276            .unwrap_or_else(default_crash_report_endpoint)
277    }
278}
279
280fn default_crash_report_endpoint() -> String {
281    "https://telemetry.codetether.ai/v1/crash-reports".to_string()
282}
283
284impl Config {
285    /// Load configuration from all sources (global, project, env)
286    pub async fn load() -> Result<Self> {
287        let mut config = Self::default();
288
289        // Load global config
290        if let Some(global_path) = Self::global_config_path() {
291            if global_path.exists() {
292                let content = fs::read_to_string(&global_path).await?;
293                let global: Config = toml::from_str(&content)?;
294                config = config.merge(global);
295            }
296        }
297
298        // Load project config
299        for name in ["codetether.toml", ".codetether/config.toml"] {
300            let path = PathBuf::from(name);
301            if path.exists() {
302                let content = fs::read_to_string(&path).await?;
303                let project: Config = toml::from_str(&content)?;
304                config = config.merge(project);
305            }
306        }
307
308        // Apply environment overrides
309        config.apply_env();
310
311        Ok(config)
312    }
313
314    /// Get the global config directory path
315    pub fn global_config_path() -> Option<PathBuf> {
316        ProjectDirs::from("ai", "codetether", "codetether-agent")
317            .map(|dirs| dirs.config_dir().join("config.toml"))
318    }
319
320    /// Get the data directory path
321    pub fn data_dir() -> Option<PathBuf> {
322        ProjectDirs::from("ai", "codetether", "codetether-agent")
323            .map(|dirs| dirs.data_dir().to_path_buf())
324    }
325
326    /// Initialize default configuration file
327    pub async fn init_default() -> Result<()> {
328        if let Some(path) = Self::global_config_path() {
329            if let Some(parent) = path.parent() {
330                fs::create_dir_all(parent).await?;
331            }
332            let default = Self::default();
333            let content = toml::to_string_pretty(&default)?;
334            fs::write(&path, content).await?;
335            tracing::info!("Created config at {:?}", path);
336        }
337        Ok(())
338    }
339
340    /// Set a configuration value
341    pub async fn set(key: &str, value: &str) -> Result<()> {
342        let mut config = Self::load().await?;
343
344        // Parse key path and set value
345        match key {
346            "default_provider" => config.default_provider = Some(value.to_string()),
347            "default_model" => config.default_model = Some(value.to_string()),
348            "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
349            "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
350            "ui.theme" => config.ui.theme = value.to_string(),
351            "telemetry.crash_reporting" => {
352                config.telemetry.crash_reporting = Some(parse_bool(value)?)
353            }
354            "telemetry.crash_reporting_prompted" => {
355                config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
356            }
357            "telemetry.crash_report_endpoint" => {
358                config.telemetry.crash_report_endpoint = Some(value.to_string())
359            }
360            _ => anyhow::bail!("Unknown config key: {}", key),
361        }
362
363        // Save to global config
364        if let Some(path) = Self::global_config_path() {
365            let content = toml::to_string_pretty(&config)?;
366            fs::write(&path, content).await?;
367        }
368
369        Ok(())
370    }
371
372    /// Merge two configs (other takes precedence)
373    fn merge(mut self, other: Self) -> Self {
374        if other.default_provider.is_some() {
375            self.default_provider = other.default_provider;
376        }
377        if other.default_model.is_some() {
378            self.default_model = other.default_model;
379        }
380        self.providers.extend(other.providers);
381        self.agents.extend(other.agents);
382        self.permissions.rules.extend(other.permissions.rules);
383        self.permissions.tools.extend(other.permissions.tools);
384        self.permissions.paths.extend(other.permissions.paths);
385        if other.a2a.server_url.is_some() {
386            self.a2a = other.a2a;
387        }
388        if other.telemetry.crash_reporting.is_some() {
389            self.telemetry.crash_reporting = other.telemetry.crash_reporting;
390        }
391        if other.telemetry.crash_reporting_prompted.is_some() {
392            self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
393        }
394        if other.telemetry.crash_report_endpoint.is_some() {
395            self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
396        }
397        self
398    }
399
400    /// Load theme based on configuration
401    pub fn load_theme(&self) -> crate::tui::theme::Theme {
402        // Use custom theme if provided
403        if let Some(custom) = &self.ui.custom_theme {
404            return custom.clone();
405        }
406
407        // Use preset theme
408        match self.ui.theme.as_str() {
409            "marketing" | "default" => crate::tui::theme::Theme::marketing(),
410            "dark" => crate::tui::theme::Theme::dark(),
411            "light" => crate::tui::theme::Theme::light(),
412            "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
413            "solarized-light" => crate::tui::theme::Theme::solarized_light(),
414            _ => {
415                // Log warning and fallback to marketing theme
416                tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
417                crate::tui::theme::Theme::marketing()
418            }
419        }
420    }
421
422    /// Apply environment variable overrides
423    fn apply_env(&mut self) {
424        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
425            self.default_model = Some(val);
426        }
427        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
428            self.default_provider = Some(val);
429        }
430        if let Ok(val) = std::env::var("OPENAI_API_KEY") {
431            self.providers
432                .entry("openai".to_string())
433                .or_default()
434                .api_key = Some(val);
435        }
436        if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
437            self.providers
438                .entry("anthropic".to_string())
439                .or_default()
440                .api_key = Some(val);
441        }
442        if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
443            self.providers
444                .entry("google".to_string())
445                .or_default()
446                .api_key = Some(val);
447        }
448        if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
449            self.a2a.server_url = Some(val);
450        }
451        if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
452            match parse_bool(&val) {
453                Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
454                Err(_) => tracing::warn!(
455                    value = %val,
456                    "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
457                ),
458            }
459        }
460        if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
461            self.telemetry.crash_report_endpoint = Some(val);
462        }
463    }
464}
465
466fn parse_bool(value: &str) -> Result<bool> {
467    let normalized = value.trim().to_ascii_lowercase();
468    match normalized.as_str() {
469        "1" | "true" | "yes" | "on" => Ok(true),
470        "0" | "false" | "no" | "off" => Ok(false),
471        _ => anyhow::bail!("Invalid boolean value: {}", value),
472    }
473}