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    ///
392    /// Performance: issues every candidate file read concurrently so that
393    /// slow-disk or network-mounted home directories don't sum their
394    /// latency. Merge order is preserved: global < project-root <
395    /// project-dot-dir < env.
396    pub async fn load() -> Result<Self> {
397        // Resolve all candidate paths up front.
398        let global_path = Self::global_config_path();
399        let project_paths = [
400            PathBuf::from("codetether.toml"),
401            PathBuf::from(".codetether/config.toml"),
402        ];
403
404        // Fire all reads in parallel. Each future returns (path, Option<content>).
405        // Missing / unreadable files yield `None` and are simply skipped.
406        async fn read_opt(p: PathBuf) -> (PathBuf, Option<String>) {
407            match fs::read_to_string(&p).await {
408                Ok(s) => (p, Some(s)),
409                Err(_) => (p, None),
410            }
411        }
412
413        let global_future = async {
414            match global_path {
415                Some(p) => Some(read_opt(p).await),
416                None => None,
417            }
418        };
419        let project_futures = futures::future::join_all(project_paths.into_iter().map(read_opt));
420
421        let (global_result, project_results) = tokio::join!(global_future, project_futures);
422
423        let mut config = Self::default();
424        if let Some((path, Some(content))) = global_result {
425            match toml::from_str::<Config>(&content) {
426                Ok(global) => config = config.merge(global),
427                Err(err) => {
428                    return Err(err).map_err(|e| {
429                        anyhow::anyhow!("failed to parse {}: {}", path.display(), e)
430                    });
431                }
432            }
433        }
434        for (path, maybe) in project_results {
435            let Some(content) = maybe else { continue };
436            match toml::from_str::<Config>(&content) {
437                Ok(project) => config = config.merge(project),
438                Err(err) => {
439                    return Err(err).map_err(|e| {
440                        anyhow::anyhow!("failed to parse {}: {}", path.display(), e)
441                    });
442                }
443            }
444        }
445
446        // Apply environment overrides
447        config.apply_env();
448        config.normalize_legacy_defaults();
449
450        Ok(config)
451    }
452
453    /// Get the global config directory path
454    pub fn global_config_path() -> Option<PathBuf> {
455        ProjectDirs::from("ai", "codetether", "codetether-agent")
456            .map(|dirs| dirs.config_dir().join("config.toml"))
457    }
458
459    /// Get the data directory path
460    pub fn data_dir() -> Option<PathBuf> {
461        if let Ok(explicit) = std::env::var("CODETETHER_DATA_DIR") {
462            let explicit = explicit.trim();
463            if !explicit.is_empty() {
464                return Some(PathBuf::from(explicit));
465            }
466        }
467
468        workspace_data_dir().or_else(|| {
469            ProjectDirs::from("ai", "codetether", "codetether-agent")
470                .map(|dirs| dirs.data_dir().to_path_buf())
471        })
472    }
473
474    /// Initialize default configuration file
475    pub async fn init_default() -> Result<()> {
476        if let Some(path) = Self::global_config_path() {
477            if let Some(parent) = path.parent() {
478                fs::create_dir_all(parent).await?;
479            }
480            let default = Self::default();
481            let content = toml::to_string_pretty(&default)?;
482            fs::write(&path, content).await?;
483            tracing::info!("Created config at {:?}", path);
484        }
485        Ok(())
486    }
487
488    /// Set a configuration value
489    pub async fn set(key: &str, value: &str) -> Result<()> {
490        let mut config = Self::load().await?;
491
492        // Parse key path and set value
493        match key {
494            "default_provider" => config.default_provider = Some(value.to_string()),
495            "default_model" => config.default_model = Some(value.to_string()),
496            "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
497            "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
498            "ui.theme" => config.ui.theme = value.to_string(),
499            "telemetry.crash_reporting" => {
500                config.telemetry.crash_reporting = Some(parse_bool(value)?)
501            }
502            "telemetry.crash_reporting_prompted" => {
503                config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
504            }
505            "telemetry.crash_report_endpoint" => {
506                config.telemetry.crash_report_endpoint = Some(value.to_string())
507            }
508            _ => anyhow::bail!("Unknown config key: {}", key),
509        }
510
511        // Save to global config
512        if let Some(path) = Self::global_config_path() {
513            let content = toml::to_string_pretty(&config)?;
514            fs::write(&path, content).await?;
515        }
516
517        Ok(())
518    }
519
520    /// Merge two configs (other takes precedence)
521    fn merge(mut self, other: Self) -> Self {
522        if other.default_provider.is_some() {
523            self.default_provider = other.default_provider;
524        }
525        if other.default_model.is_some() {
526            self.default_model = other.default_model;
527        }
528        self.providers.extend(other.providers);
529        self.agents.extend(other.agents);
530        self.permissions.rules.extend(other.permissions.rules);
531        self.permissions.tools.extend(other.permissions.tools);
532        self.permissions.paths.extend(other.permissions.paths);
533        if other.a2a.server_url.is_some() {
534            self.a2a = other.a2a;
535        }
536        if other.telemetry.crash_reporting.is_some() {
537            self.telemetry.crash_reporting = other.telemetry.crash_reporting;
538        }
539        if other.telemetry.crash_reporting_prompted.is_some() {
540            self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
541        }
542        if other.telemetry.crash_report_endpoint.is_some() {
543            self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
544        }
545        self.lsp.servers.extend(other.lsp.servers);
546        self.lsp.linters.extend(other.lsp.linters);
547        if other.lsp.disable_builtin_linters {
548            self.lsp.disable_builtin_linters = true;
549        }
550        self
551    }
552
553    /// Load theme based on configuration
554    pub fn load_theme(&self) -> crate::tui::theme::Theme {
555        // Use custom theme if provided
556        if let Some(custom) = &self.ui.custom_theme {
557            return custom.clone();
558        }
559
560        // Use preset theme
561        match self.ui.theme.as_str() {
562            "marketing" | "default" => crate::tui::theme::Theme::marketing(),
563            "dark" => crate::tui::theme::Theme::dark(),
564            "light" => crate::tui::theme::Theme::light(),
565            "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
566            "solarized-light" => crate::tui::theme::Theme::solarized_light(),
567            _ => {
568                // Log warning and fallback to marketing theme
569                tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
570                crate::tui::theme::Theme::marketing()
571            }
572        }
573    }
574
575    /// Apply environment variable overrides
576    fn apply_env(&mut self) {
577        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
578            self.default_model = Some(val);
579        }
580        if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
581            self.default_provider = Some(val);
582        }
583        if let Ok(val) = std::env::var("OPENAI_API_KEY") {
584            self.providers
585                .entry("openai".to_string())
586                .or_default()
587                .api_key = Some(val);
588        }
589        if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
590            self.providers
591                .entry("anthropic".to_string())
592                .or_default()
593                .api_key = Some(val);
594        }
595        if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
596            self.providers
597                .entry("google".to_string())
598                .or_default()
599                .api_key = Some(val);
600        }
601        if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
602            self.a2a.server_url = Some(val);
603        }
604        if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
605            match parse_bool(&val) {
606                Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
607                Err(_) => tracing::warn!(
608                    value = %val,
609                    "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
610                ),
611            }
612        }
613        if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
614            self.telemetry.crash_report_endpoint = Some(val);
615        }
616    }
617
618    /// Normalize legacy provider/model defaults from older releases.
619    ///
620    /// Historical versions defaulted to Moonshot Kimi K2.5. The current
621    /// product default is Z.AI GLM-5, so we migrate only known legacy default
622    /// values while preserving all other explicit user choices.
623    fn normalize_legacy_defaults(&mut self) {
624        if let Some(provider) = self.default_provider.as_deref()
625            && provider.trim().eq_ignore_ascii_case("zhipuai")
626        {
627            self.default_provider = Some("zai".to_string());
628        }
629
630        if let Some(model) = self.default_model.as_deref() {
631            let model_trimmed = model.trim();
632
633            if model_trimmed.eq_ignore_ascii_case("zhipuai/glm-5") {
634                self.default_model = Some("zai/glm-5".to_string());
635                return;
636            }
637
638            let is_legacy_kimi_default = model_trimmed.eq_ignore_ascii_case("moonshotai/kimi-k2.5")
639                || model_trimmed.eq_ignore_ascii_case("kimi-k2.5");
640
641            if is_legacy_kimi_default {
642                tracing::info!(
643                    from = %model_trimmed,
644                    to = "zai/glm-5",
645                    "Migrating legacy default model to current Z.AI GLM-5 default"
646                );
647                self.default_model = Some("zai/glm-5".to_string());
648
649                let should_update_provider = self.default_provider.as_deref().is_none_or(|p| {
650                    let p = p.trim();
651                    p.eq_ignore_ascii_case("moonshotai") || p.eq_ignore_ascii_case("zhipuai")
652                });
653                if should_update_provider {
654                    self.default_provider = Some("zai".to_string());
655                }
656            }
657        }
658    }
659}
660
661fn parse_bool(value: &str) -> Result<bool> {
662    let normalized = value.trim().to_ascii_lowercase();
663    match normalized.as_str() {
664        "1" | "true" | "yes" | "on" => Ok(true),
665        "0" | "false" | "no" | "off" => Ok(false),
666        _ => anyhow::bail!("Invalid boolean value: {}", value),
667    }
668}
669
670fn workspace_data_dir() -> Option<PathBuf> {
671    let cwd = std::env::current_dir().ok()?;
672    Some(workspace_data_dir_from(&cwd))
673}
674
675fn workspace_data_dir_from(start: &Path) -> PathBuf {
676    detect_workspace_root(start)
677        .unwrap_or_else(|| start.to_path_buf())
678        .join(".codetether-agent")
679}
680
681fn detect_workspace_root(start: &Path) -> Option<PathBuf> {
682    start
683        .ancestors()
684        .find(|path| path.join(".git").exists())
685        .map(Path::to_path_buf)
686}
687
688#[cfg(test)]
689mod tests {
690    use super::Config;
691    use super::{detect_workspace_root, workspace_data_dir_from};
692    use tempfile::tempdir;
693
694    #[test]
695    fn migrates_legacy_kimi_default_to_zai_glm5() {
696        let mut cfg = Config {
697            default_provider: Some("moonshotai".to_string()),
698            default_model: Some("moonshotai/kimi-k2.5".to_string()),
699            ..Default::default()
700        };
701
702        cfg.normalize_legacy_defaults();
703
704        assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
705        assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
706    }
707
708    #[test]
709    fn preserves_explicit_non_legacy_default_model() {
710        let mut cfg = Config {
711            default_provider: Some("openai".to_string()),
712            default_model: Some("openai/gpt-4o".to_string()),
713            ..Default::default()
714        };
715
716        cfg.normalize_legacy_defaults();
717
718        assert_eq!(cfg.default_provider.as_deref(), Some("openai"));
719        assert_eq!(cfg.default_model.as_deref(), Some("openai/gpt-4o"));
720    }
721
722    #[test]
723    fn normalizes_zhipuai_aliases_to_zai() {
724        let mut cfg = Config {
725            default_provider: Some("zhipuai".to_string()),
726            default_model: Some("zhipuai/glm-5".to_string()),
727            ..Default::default()
728        };
729
730        cfg.normalize_legacy_defaults();
731
732        assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
733        assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
734    }
735
736    #[test]
737    fn detects_workspace_root_using_git_marker() {
738        let temp = tempdir().expect("tempdir");
739        let repo_root = temp.path().join("repo");
740        std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
741        let nested = repo_root.join("src").join("nested");
742        std::fs::create_dir_all(&nested).expect("create nested");
743
744        let detected = detect_workspace_root(&nested);
745        assert_eq!(detected.as_deref(), Some(repo_root.as_path()));
746    }
747
748    #[test]
749    fn workspace_data_dir_defaults_to_workspace_root() {
750        let temp = tempdir().expect("tempdir");
751        let repo_root = temp.path().join("repo");
752        std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
753        let nested = repo_root.join("api").join("src");
754        std::fs::create_dir_all(&nested).expect("create nested");
755
756        let data_dir = workspace_data_dir_from(&nested);
757        assert_eq!(data_dir, repo_root.join(".codetether-agent"));
758    }
759
760    #[test]
761    fn workspace_data_dir_falls_back_to_start_when_not_git_repo() {
762        let temp = tempdir().expect("tempdir");
763        let workspace = temp.path().join("workspace");
764        std::fs::create_dir_all(&workspace).expect("create workspace");
765
766        let data_dir = workspace_data_dir_from(&workspace);
767        assert_eq!(data_dir, workspace.join(".codetether-agent"));
768    }
769}