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::{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    /// LSP / linter server settings
55    #[serde(default)]
56    pub lsp: LspSettings,
57
58    /// RLM (Recursive Language Model) settings.
59    ///
60    /// Controls when and how large-output tool results and long
61    /// conversation prefixes are handed off to the RLM router for
62    /// compression/analysis. See [`crate::rlm::RlmConfig`] for fields.
63    #[serde(default)]
64    pub rlm: crate::rlm::RlmConfig,
65}
66
67impl Default for Config {
68    fn default() -> Self {
69        Self {
70            // Default to GLM-5 via Z.AI everywhere unless overridden.
71            // Use provider/model format so provider selection is unambiguous.
72            default_provider: Some("zai".to_string()),
73            default_model: Some("zai/glm-5".to_string()),
74            providers: HashMap::new(),
75            agents: HashMap::new(),
76            permissions: PermissionConfig::default(),
77            a2a: A2aConfig::default(),
78            ui: UiConfig::default(),
79            session: SessionConfig::default(),
80            telemetry: TelemetryConfig::default(),
81            lsp: LspSettings::default(),
82            rlm: crate::rlm::RlmConfig::default(),
83        }
84    }
85}
86
87#[derive(Clone, Serialize, Deserialize, Default)]
88pub struct ProviderConfig {
89    /// API key (can also be set via env var)
90    pub api_key: Option<String>,
91
92    /// Base URL override
93    pub base_url: Option<String>,
94
95    /// Custom headers
96    #[serde(default)]
97    pub headers: HashMap<String, String>,
98
99    /// Organization ID (for OpenAI)
100    pub organization: Option<String>,
101}
102
103impl std::fmt::Debug for ProviderConfig {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("ProviderConfig")
106            .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
107            .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
108            .field("base_url", &self.base_url)
109            .field("organization", &self.organization)
110            .field("headers_count", &self.headers.len())
111            .finish()
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AgentConfig {
117    /// Agent name
118    pub name: String,
119
120    /// Description
121    #[serde(default)]
122    pub description: Option<String>,
123
124    /// Model override for this agent
125    #[serde(default)]
126    pub model: Option<String>,
127
128    /// System prompt override
129    #[serde(default)]
130    pub prompt: Option<String>,
131
132    /// Temperature setting
133    #[serde(default)]
134    pub temperature: Option<f32>,
135
136    /// Top-p setting
137    #[serde(default)]
138    pub top_p: Option<f32>,
139
140    /// Custom permissions for this agent
141    #[serde(default)]
142    pub permissions: HashMap<String, PermissionAction>,
143
144    /// Whether this agent is disabled
145    #[serde(default)]
146    pub disabled: bool,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default)]
150pub struct PermissionConfig {
151    /// Default permission rules
152    #[serde(default)]
153    pub rules: HashMap<String, PermissionAction>,
154
155    /// Tool-specific permissions
156    #[serde(default)]
157    pub tools: HashMap<String, PermissionAction>,
158
159    /// Path-specific permissions
160    #[serde(default)]
161    pub paths: HashMap<String, PermissionAction>,
162}
163
164#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
165#[serde(rename_all = "lowercase")]
166#[derive(Default)]
167pub enum PermissionAction {
168    Allow,
169    Deny,
170    #[default]
171    Ask,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175pub struct A2aConfig {
176    /// Default A2A server URL
177    pub server_url: Option<String>,
178
179    /// Worker name
180    pub worker_name: Option<String>,
181
182    /// Auto-approve policy
183    #[serde(default)]
184    pub auto_approve: AutoApprovePolicy,
185
186    /// Workspaces to register
187    #[serde(default)]
188    pub workspaces: Vec<PathBuf>,
189}
190
191#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
192#[serde(rename_all = "lowercase")]
193pub enum AutoApprovePolicy {
194    All,
195    #[default]
196    Safe,
197    None,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct UiConfig {
202    /// Theme name ("default", "marketing", "dark", "light", "solarized-dark", "solarized-light", or custom)
203    #[serde(default = "default_theme")]
204    pub theme: String,
205
206    /// Show line numbers in code
207    #[serde(default = "default_true")]
208    pub line_numbers: bool,
209
210    /// Enable mouse support
211    #[serde(default = "default_true")]
212    pub mouse: bool,
213
214    /// Custom theme configuration (overrides preset themes)
215    #[serde(default)]
216    pub custom_theme: Option<crate::tui::theme::Theme>,
217
218    /// Enable theme hot-reloading (apply changes without restart)
219    #[serde(default = "default_false")]
220    pub hot_reload: bool,
221}
222
223impl Default for UiConfig {
224    fn default() -> Self {
225        Self {
226            theme: default_theme(),
227            line_numbers: true,
228            mouse: true,
229            custom_theme: None,
230            hot_reload: false,
231        }
232    }
233}
234
235fn default_theme() -> String {
236    "marketing".to_string()
237}
238
239fn default_true() -> bool {
240    true
241}
242
243fn default_false() -> bool {
244    false
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct SessionConfig {
249    /// Auto-compact sessions when they get too long
250    #[serde(default = "default_true")]
251    pub auto_compact: bool,
252
253    /// Maximum context tokens before compaction
254    #[serde(default = "default_max_tokens")]
255    pub max_tokens: usize,
256
257    /// Enable session persistence
258    #[serde(default = "default_true")]
259    pub persist: bool,
260}
261
262impl Default for SessionConfig {
263    fn default() -> Self {
264        Self {
265            auto_compact: true,
266            max_tokens: default_max_tokens(),
267            persist: true,
268        }
269    }
270}
271
272fn default_max_tokens() -> usize {
273    100_000
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, Default)]
277pub struct TelemetryConfig {
278    /// Opt-in crash reporting. Disabled by default.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub crash_reporting: Option<bool>,
281
282    /// Whether we have already prompted the user about crash reporting consent.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub crash_reporting_prompted: Option<bool>,
285
286    /// Endpoint for crash report ingestion.
287    /// Defaults to the CodeTether telemetry endpoint when unset.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub crash_report_endpoint: Option<String>,
290}
291
292impl TelemetryConfig {
293    pub fn crash_reporting_enabled(&self) -> bool {
294        self.crash_reporting.unwrap_or(false)
295    }
296
297    pub fn crash_reporting_prompted(&self) -> bool {
298        self.crash_reporting_prompted.unwrap_or(false)
299    }
300
301    pub fn crash_report_endpoint(&self) -> String {
302        self.crash_report_endpoint
303            .clone()
304            .unwrap_or_else(default_crash_report_endpoint)
305    }
306}
307
308fn default_crash_report_endpoint() -> String {
309    "https://api.codetether.run/v1/crash-reports".to_string()
310}
311
312/// Configuration for LSP language servers and linter servers.
313#[derive(Debug, Clone, Serialize, Deserialize, Default)]
314pub struct LspSettings {
315    /// Additional language servers beyond the built-in defaults.
316    /// Keyed by a user-chosen name (e.g. "my-ruby-lsp").
317    #[serde(default)]
318    pub servers: HashMap<String, LspServerEntry>,
319
320    /// Linter servers that run alongside the primary language server.
321    /// These only contribute diagnostics; they are not used for
322    /// go-to-definition, completion, etc.
323    /// Use the built-in name ("eslint", "ruff", "biome") or provide
324    /// a custom entry with `command`.
325    #[serde(default)]
326    pub linters: HashMap<String, LspLinterEntry>,
327
328    /// Disable all built-in linter integrations (default: false).
329    #[serde(default)]
330    pub disable_builtin_linters: bool,
331}
332
333/// A user-defined language server entry in config.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct LspServerEntry {
336    /// Binary / command to run.
337    pub command: String,
338    /// Arguments passed to the command.
339    #[serde(default)]
340    pub args: Vec<String>,
341    /// File extensions this server handles (e.g. ["rb", "erb"]).
342    #[serde(default)]
343    pub file_extensions: Vec<String>,
344    /// Optional initialization options (JSON value).
345    #[serde(default)]
346    pub initialization_options: Option<serde_json::Value>,
347    /// Request timeout in milliseconds (default 30 000).
348    #[serde(default = "default_lsp_timeout")]
349    pub timeout_ms: u64,
350}
351
352/// A linter server entry in config.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct LspLinterEntry {
355    /// Binary / command to run.  For built-in linters (eslint, ruff, biome)
356    /// this can be omitted — the default binary name will be used.
357    pub command: Option<String>,
358    /// Arguments passed to the command.
359    #[serde(default)]
360    pub args: Vec<String>,
361    /// File extensions this linter handles.  If empty, the built-in
362    /// defaults for the linter are used.
363    #[serde(default)]
364    pub file_extensions: Vec<String>,
365    /// Optional initialization options.
366    #[serde(default)]
367    pub initialization_options: Option<serde_json::Value>,
368    /// Whether this linter is enabled (default: true).
369    #[serde(default = "default_true")]
370    pub enabled: bool,
371}
372
373impl Default for LspLinterEntry {
374    fn default() -> Self {
375        Self {
376            command: None,
377            args: Vec::new(),
378            file_extensions: Vec::new(),
379            initialization_options: None,
380            enabled: true,
381        }
382    }
383}
384
385fn default_lsp_timeout() -> u64 {
386    30_000
387}
388
389impl Config {
390    /// Load configuration from all sources (global, project, env)
391    pub async fn load() -> Result<Self> {
392        let mut config = Self::default();
393
394        // Load global config
395        if let Some(global_path) = Self::global_config_path()
396            && global_path.exists()
397        {
398            let content = fs::read_to_string(&global_path).await?;
399            let global: Config = toml::from_str(&content)?;
400            config = config.merge(global);
401        }
402
403        // Load project config
404        for name in ["codetether.toml", ".codetether/config.toml"] {
405            let path = PathBuf::from(name);
406            if path.exists() {
407                let content = fs::read_to_string(&path).await?;
408                let project: Config = toml::from_str(&content)?;
409                config = config.merge(project);
410            }
411        }
412
413        // Apply environment overrides
414        config.apply_env();
415        config.normalize_legacy_defaults();
416
417        Ok(config)
418    }
419
420    /// Get the global config directory path
421    pub fn global_config_path() -> Option<PathBuf> {
422        ProjectDirs::from("ai", "codetether", "codetether-agent")
423            .map(|dirs| dirs.config_dir().join("config.toml"))
424    }
425
426    /// Get the data directory path
427    pub fn data_dir() -> Option<PathBuf> {
428        if let Ok(explicit) = std::env::var("CODETETHER_DATA_DIR") {
429            let explicit = explicit.trim();
430            if !explicit.is_empty() {
431                return Some(PathBuf::from(explicit));
432            }
433        }
434
435        workspace_data_dir().or_else(|| {
436            ProjectDirs::from("ai", "codetether", "codetether-agent")
437                .map(|dirs| dirs.data_dir().to_path_buf())
438        })
439    }
440
441    /// Initialize default configuration file
442    pub async fn init_default() -> Result<()> {
443        if let Some(path) = Self::global_config_path() {
444            if let Some(parent) = path.parent() {
445                fs::create_dir_all(parent).await?;
446            }
447            let default = Self::default();
448            let content = toml::to_string_pretty(&default)?;
449            fs::write(&path, content).await?;
450            tracing::info!("Created config at {:?}", path);
451        }
452        Ok(())
453    }
454
455    /// Set a configuration value
456    pub async fn set(key: &str, value: &str) -> Result<()> {
457        let mut config = Self::load().await?;
458
459        // Parse key path and set value
460        match key {
461            "default_provider" => config.default_provider = Some(value.to_string()),
462            "default_model" => config.default_model = Some(value.to_string()),
463            "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
464            "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
465            "ui.theme" => config.ui.theme = value.to_string(),
466            "telemetry.crash_reporting" => {
467                config.telemetry.crash_reporting = Some(parse_bool(value)?)
468            }
469            "telemetry.crash_reporting_prompted" => {
470                config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
471            }
472            "telemetry.crash_report_endpoint" => {
473                config.telemetry.crash_report_endpoint = Some(value.to_string())
474            }
475            _ => anyhow::bail!("Unknown config key: {}", key),
476        }
477
478        // Save to global config
479        if let Some(path) = Self::global_config_path() {
480            let content = toml::to_string_pretty(&config)?;
481            fs::write(&path, content).await?;
482        }
483
484        Ok(())
485    }
486
487    /// Merge two configs (other takes precedence)
488    fn merge(mut self, other: Self) -> Self {
489        if other.default_provider.is_some() {
490            self.default_provider = other.default_provider;
491        }
492        if other.default_model.is_some() {
493            self.default_model = other.default_model;
494        }
495        self.providers.extend(other.providers);
496        self.agents.extend(other.agents);
497        self.permissions.rules.extend(other.permissions.rules);
498        self.permissions.tools.extend(other.permissions.tools);
499        self.permissions.paths.extend(other.permissions.paths);
500        if other.a2a.server_url.is_some() {
501            self.a2a = other.a2a;
502        }
503        if other.telemetry.crash_reporting.is_some() {
504            self.telemetry.crash_reporting = other.telemetry.crash_reporting;
505        }
506        if other.telemetry.crash_reporting_prompted.is_some() {
507            self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
508        }
509        if other.telemetry.crash_report_endpoint.is_some() {
510            self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
511        }
512        self.lsp.servers.extend(other.lsp.servers);
513        self.lsp.linters.extend(other.lsp.linters);
514        if other.lsp.disable_builtin_linters {
515            self.lsp.disable_builtin_linters = true;
516        }
517        self
518    }
519
520    /// Load theme based on configuration
521    pub fn load_theme(&self) -> crate::tui::theme::Theme {
522        // Use custom theme if provided
523        if let Some(custom) = &self.ui.custom_theme {
524            return custom.clone();
525        }
526
527        // Use preset theme
528        match self.ui.theme.as_str() {
529            "marketing" | "default" => crate::tui::theme::Theme::marketing(),
530            "dark" => crate::tui::theme::Theme::dark(),
531            "light" => crate::tui::theme::Theme::light(),
532            "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
533            "solarized-light" => crate::tui::theme::Theme::solarized_light(),
534            _ => {
535                // Log warning and fallback to marketing theme
536                tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
537                crate::tui::theme::Theme::marketing()
538            }
539        }
540    }
541
542    /// Apply environment variable overrides
543    fn apply_env(&mut self) {
544        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
545            self.default_model = Some(val);
546        }
547        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
548            self.default_provider = Some(val);
549        }
550        if let Ok(val) = std::env::var("OPENAI_API_KEY") {
551            self.providers
552                .entry("openai".to_string())
553                .or_default()
554                .api_key = Some(val);
555        }
556        if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
557            self.providers
558                .entry("anthropic".to_string())
559                .or_default()
560                .api_key = Some(val);
561        }
562        if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
563            self.providers
564                .entry("google".to_string())
565                .or_default()
566                .api_key = Some(val);
567        }
568        if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
569            self.a2a.server_url = Some(val);
570        }
571        if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
572            match parse_bool(&val) {
573                Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
574                Err(_) => tracing::warn!(
575                    value = %val,
576                    "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
577                ),
578            }
579        }
580        if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
581            self.telemetry.crash_report_endpoint = Some(val);
582        }
583    }
584
585    /// Normalize legacy provider/model defaults from older releases.
586    ///
587    /// Historical versions defaulted to Moonshot Kimi K2.5. The current
588    /// product default is Z.AI GLM-5, so we migrate only known legacy default
589    /// values while preserving all other explicit user choices.
590    fn normalize_legacy_defaults(&mut self) {
591        if let Some(provider) = self.default_provider.as_deref()
592            && provider.trim().eq_ignore_ascii_case("zhipuai")
593        {
594            self.default_provider = Some("zai".to_string());
595        }
596
597        if let Some(model) = self.default_model.as_deref() {
598            let model_trimmed = model.trim();
599
600            if model_trimmed.eq_ignore_ascii_case("zhipuai/glm-5") {
601                self.default_model = Some("zai/glm-5".to_string());
602                return;
603            }
604
605            let is_legacy_kimi_default = model_trimmed.eq_ignore_ascii_case("moonshotai/kimi-k2.5")
606                || model_trimmed.eq_ignore_ascii_case("kimi-k2.5");
607
608            if is_legacy_kimi_default {
609                tracing::info!(
610                    from = %model_trimmed,
611                    to = "zai/glm-5",
612                    "Migrating legacy default model to current Z.AI GLM-5 default"
613                );
614                self.default_model = Some("zai/glm-5".to_string());
615
616                let should_update_provider = self.default_provider.as_deref().is_none_or(|p| {
617                    let p = p.trim();
618                    p.eq_ignore_ascii_case("moonshotai") || p.eq_ignore_ascii_case("zhipuai")
619                });
620                if should_update_provider {
621                    self.default_provider = Some("zai".to_string());
622                }
623            }
624        }
625    }
626}
627
628fn parse_bool(value: &str) -> Result<bool> {
629    let normalized = value.trim().to_ascii_lowercase();
630    match normalized.as_str() {
631        "1" | "true" | "yes" | "on" => Ok(true),
632        "0" | "false" | "no" | "off" => Ok(false),
633        _ => anyhow::bail!("Invalid boolean value: {}", value),
634    }
635}
636
637fn workspace_data_dir() -> Option<PathBuf> {
638    let cwd = std::env::current_dir().ok()?;
639    Some(workspace_data_dir_from(&cwd))
640}
641
642fn workspace_data_dir_from(start: &Path) -> PathBuf {
643    detect_workspace_root(start)
644        .unwrap_or_else(|| start.to_path_buf())
645        .join(".codetether-agent")
646}
647
648fn detect_workspace_root(start: &Path) -> Option<PathBuf> {
649    start
650        .ancestors()
651        .find(|path| path.join(".git").exists())
652        .map(Path::to_path_buf)
653}
654
655#[cfg(test)]
656mod tests {
657    use super::Config;
658    use super::{detect_workspace_root, workspace_data_dir_from};
659    use tempfile::tempdir;
660
661    #[test]
662    fn migrates_legacy_kimi_default_to_zai_glm5() {
663        let mut cfg = Config {
664            default_provider: Some("moonshotai".to_string()),
665            default_model: Some("moonshotai/kimi-k2.5".to_string()),
666            ..Default::default()
667        };
668
669        cfg.normalize_legacy_defaults();
670
671        assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
672        assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
673    }
674
675    #[test]
676    fn preserves_explicit_non_legacy_default_model() {
677        let mut cfg = Config {
678            default_provider: Some("openai".to_string()),
679            default_model: Some("openai/gpt-4o".to_string()),
680            ..Default::default()
681        };
682
683        cfg.normalize_legacy_defaults();
684
685        assert_eq!(cfg.default_provider.as_deref(), Some("openai"));
686        assert_eq!(cfg.default_model.as_deref(), Some("openai/gpt-4o"));
687    }
688
689    #[test]
690    fn normalizes_zhipuai_aliases_to_zai() {
691        let mut cfg = Config {
692            default_provider: Some("zhipuai".to_string()),
693            default_model: Some("zhipuai/glm-5".to_string()),
694            ..Default::default()
695        };
696
697        cfg.normalize_legacy_defaults();
698
699        assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
700        assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
701    }
702
703    #[test]
704    fn detects_workspace_root_using_git_marker() {
705        let temp = tempdir().expect("tempdir");
706        let repo_root = temp.path().join("repo");
707        std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
708        let nested = repo_root.join("src").join("nested");
709        std::fs::create_dir_all(&nested).expect("create nested");
710
711        let detected = detect_workspace_root(&nested);
712        assert_eq!(detected.as_deref(), Some(repo_root.as_path()));
713    }
714
715    #[test]
716    fn workspace_data_dir_defaults_to_workspace_root() {
717        let temp = tempdir().expect("tempdir");
718        let repo_root = temp.path().join("repo");
719        std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
720        let nested = repo_root.join("api").join("src");
721        std::fs::create_dir_all(&nested).expect("create nested");
722
723        let data_dir = workspace_data_dir_from(&nested);
724        assert_eq!(data_dir, repo_root.join(".codetether-agent"));
725    }
726
727    #[test]
728    fn workspace_data_dir_falls_back_to_start_when_not_git_repo() {
729        let temp = tempdir().expect("tempdir");
730        let workspace = temp.path().join("workspace");
731        std::fs::create_dir_all(&workspace).expect("create workspace");
732
733        let data_dir = workspace_data_dir_from(&workspace);
734        assert_eq!(data_dir, workspace.join(".codetether-agent"));
735    }
736}