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