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