Skip to main content

devboy_core/
config.rs

1//! Configuration management for devboy-tools.
2//!
3//! Handles loading and saving configuration from TOML files.
4//! Config files are stored in platform-specific locations:
5//!
6//! - **macOS/Linux**: `~/.config/devboy-tools/config.toml`
7//! - **Windows**: `%APPDATA%\devboy-tools\config.toml`
8//!
9//! # Example
10//!
11//! ```ignore
12//! use devboy_core::config::{Config, GitHubConfig};
13//!
14//! // Load config
15//! let config = Config::load()?;
16//!
17//! // Modify config
18//! let mut config = config;
19//! config.github = Some(GitHubConfig {
20//!     owner: "meteora-pro".to_string(),
21//!     repo: "devboy-tools".to_string(),
22//! });
23//!
24//! // Save config
25//! config.save()?;
26//! ```
27
28use crate::{Error, Result};
29use serde::{Deserialize, Serialize};
30use std::collections::{BTreeMap, HashMap};
31use std::path::PathBuf;
32use tracing::{debug, info};
33
34const CONFIG_FILE_NAME: &str = "config.toml";
35
36/// Config directory name.
37const CONFIG_DIR_NAME: &str = "devboy-tools";
38
39// =============================================================================
40// Configuration structures
41// =============================================================================
42
43/// Main configuration structure.
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct Config {
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub github: Option<GitHubConfig>,
48
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub gitlab: Option<GitLabConfig>,
51
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub clickup: Option<ClickUpConfig>,
54
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub jira: Option<JiraConfig>,
57
58    /// Fireflies.ai configuration (meeting notes)
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub fireflies: Option<FirefliesConfig>,
61
62    /// Confluence self-hosted configuration (knowledge base)
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub confluence: Option<ConfluenceConfig>,
65
66    /// Slack configuration (messenger)
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub slack: Option<SlackConfig>,
69
70    /// Named contexts (profiles) configuration.
71    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72    pub contexts: BTreeMap<String, ContextConfig>,
73
74    /// Currently active context name.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub active_context: Option<String>,
77
78    /// Upstream MCP servers to proxy.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub proxy_mcp_servers: Vec<ProxyMcpServerConfig>,
81
82    /// Built-in tools filtering configuration.
83    #[serde(default, skip_serializing_if = "BuiltinToolsConfig::is_empty")]
84    pub builtin_tools: BuiltinToolsConfig,
85
86    /// Format pipeline configuration (TOON encoding, budget trimming, strategies).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub format_pipeline: Option<FormatPipelineConfig>,
89
90    /// Transparent proxy configuration: routing strategy, secrets cache, telemetry.
91    /// Applies across all upstream MCP servers unless overridden per-server.
92    #[serde(default, skip_serializing_if = "ProxyConfig::is_default")]
93    pub proxy: ProxyConfig,
94
95    /// Sentry error reporting configuration (optional, disabled by default).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub sentry: Option<SentryConfig>,
98
99    /// Remote configuration endpoint (optional).
100    /// Fetches TOML config from a URL on startup and merges with local config.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub remote_config: Option<RemoteConfigSettings>,
103}
104
105/// Configuration for an upstream MCP server to proxy.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ProxyMcpServerConfig {
108    /// Server name (used as tool prefix if tool_prefix not set)
109    pub name: String,
110    /// Server URL (SSE or Streamable HTTP endpoint)
111    pub url: String,
112    /// Auth type: "bearer", "api_key", "none"
113    #[serde(default = "default_auth_none")]
114    pub auth_type: String,
115    /// Keychain key for auth token
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub token_key: Option<String>,
118    /// Tool name prefix override (default: name)
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub tool_prefix: Option<String>,
121    /// Transport type: "sse" (default) or "streamable-http"
122    #[serde(default = "default_transport_sse")]
123    pub transport: String,
124    /// Per-server routing override. Only the fields explicitly set here win over the
125    /// global `[proxy.routing]`; omitted fields inherit from the global config (so a
126    /// per-server block that just sets `strategy` does **not** silently reset
127    /// `fallback_on_error` to its default).
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub routing: Option<ProxyRoutingOverride>,
130}
131
132fn default_transport_sse() -> String {
133    "sse".to_string()
134}
135
136fn default_auth_none() -> String {
137    "none".to_string()
138}
139
140/// Per-context provider configuration.
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142pub struct ContextConfig {
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub github: Option<GitHubConfig>,
145
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub gitlab: Option<GitLabConfig>,
148
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub clickup: Option<ClickUpConfig>,
151
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub jira: Option<JiraConfig>,
154
155    /// Fireflies.ai configuration (meeting notes)
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub fireflies: Option<FirefliesConfig>,
158
159    /// Confluence self-hosted configuration (knowledge base)
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub confluence: Option<ConfluenceConfig>,
162
163    /// Slack configuration (messenger)
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub slack: Option<SlackConfig>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct GitHubConfig {
170    /// Repository owner (user or organization)
171    pub owner: String,
172    pub repo: String,
173    /// GitHub API base URL (for GitHub Enterprise)
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub base_url: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct GitLabConfig {
180    /// GitLab instance URL
181    #[serde(default = "default_gitlab_url")]
182    pub url: String,
183    /// Project ID (numeric or path)
184    pub project_id: String,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ClickUpConfig {
189    pub list_id: String,
190    /// ClickUp team (workspace) ID — required for custom task ID resolution
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub team_id: Option<String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct JiraConfig {
197    /// Jira instance URL
198    pub url: String,
199    /// Project key (e.g., "PROJ")
200    pub project_key: String,
201    /// User email (required for Jira auth)
202    pub email: String,
203}
204
205/// Fireflies.ai provider configuration (meeting notes).
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct FirefliesConfig {
208    // API key is stored in OS keychain (key: "fireflies.token")
209    // No fields needed — config just enables the provider
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct ConfluenceConfig {
214    /// Confluence base URL, e.g. `https://wiki.example.com`.
215    pub base_url: String,
216    /// Preferred REST API generation when the instance supports multiple.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub api_version: Option<String>,
219    /// Username/email for basic auth when that auth mode is used.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub username: Option<String>,
222    /// Optional default space hint.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub space_key: Option<String>,
225}
226
227/// Slack provider configuration (messenger).
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct SlackConfig {
230    /// Optional Slack workspace/team ID.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub team_id: Option<String>,
233    /// Optional human-readable workspace name.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub workspace: Option<String>,
236    /// Slack API base URL override.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub base_url: Option<String>,
239    /// OAuth app client ID.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub client_id: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub redirect_uri: Option<String>,
244    /// Required bot scopes expected by devboy Slack integration.
245    #[serde(
246        default = "default_slack_required_scopes",
247        skip_serializing_if = "is_default_slack_required_scopes"
248    )]
249    pub required_scopes: Vec<String>,
250}
251
252impl Default for SlackConfig {
253    fn default() -> Self {
254        Self {
255            team_id: None,
256            workspace: None,
257            base_url: None,
258            client_id: None,
259            redirect_uri: None,
260            required_scopes: default_slack_required_scopes(),
261        }
262    }
263}
264
265pub fn default_slack_required_scopes() -> Vec<String> {
266    vec![
267        "channels:read".to_string(),
268        "channels:history".to_string(),
269        "groups:read".to_string(),
270        "groups:history".to_string(),
271        "im:read".to_string(),
272        "im:history".to_string(),
273        "mpim:read".to_string(),
274        "mpim:history".to_string(),
275        "chat:write".to_string(),
276        "users:read".to_string(),
277    ]
278}
279
280fn is_default_slack_required_scopes(scopes: &[String]) -> bool {
281    scopes == default_slack_required_scopes().as_slice()
282}
283
284/// Configuration for controlling which built-in tools are available.
285///
286/// Supports two mutually exclusive modes:
287/// - `disabled`: blacklist specific tools (all others remain enabled)
288/// - `enabled`: whitelist specific tools (all others are disabled)
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290pub struct BuiltinToolsConfig {
291    /// List of tool names to disable (blacklist mode).
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub disabled: Vec<String>,
294
295    /// List of tool names to enable (whitelist mode). All others are disabled.
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub enabled: Vec<String>,
298}
299
300impl BuiltinToolsConfig {
301    /// Check whether the config is empty (no filtering).
302    pub fn is_empty(&self) -> bool {
303        self.disabled.is_empty() && self.enabled.is_empty()
304    }
305
306    /// Validate the config: `disabled` and `enabled` must not both be set.
307    pub fn validate(&self) -> Result<()> {
308        if !self.disabled.is_empty() && !self.enabled.is_empty() {
309            return Err(Error::Config(
310                "builtin_tools: 'disabled' and 'enabled' are mutually exclusive, use only one"
311                    .to_string(),
312            ));
313        }
314        Ok(())
315    }
316
317    /// Check whether a tool with the given name should be available.
318    pub fn is_tool_allowed(&self, name: &str) -> bool {
319        if !self.enabled.is_empty() {
320            return self.enabled.iter().any(|n| n == name);
321        }
322        if !self.disabled.is_empty() {
323            return !self.disabled.iter().any(|n| n == name);
324        }
325        true
326    }
327
328    /// Log warnings for tool names that are not in the known set.
329    pub fn warn_unknown_tools(&self, known: &[&str]) {
330        for name in self.disabled.iter().chain(self.enabled.iter()) {
331            if !known.iter().any(|k| k == name) {
332                tracing::warn!(
333                    "builtin_tools: unknown tool name '{}', it will have no effect",
334                    name
335                );
336            }
337        }
338    }
339}
340
341// ============================================================================
342// Format Pipeline Config
343// ============================================================================
344
345/// Configuration for the format pipeline (TOON encoding, budget trimming, strategies).
346///
347/// All fields have sensible defaults — the pipeline works out of the box without config.
348///
349/// # Example TOML
350///
351/// ```toml
352/// [format_pipeline]
353/// budget_tokens = 8000
354/// margin = 0.20
355/// max_iterations = 3
356/// default_format = "toon"
357///
358/// [format_pipeline.strategies]
359/// get_issues = "element_count"
360/// "cloud__get_tasks" = "element_count"
361///
362/// [format_pipeline.proxy_matching]
363/// enabled = true
364/// ```
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct FormatPipelineConfig {
367    /// Maximum token budget per tool response (default: 8000).
368    /// ~6% of a 128K context window.
369    #[serde(default = "default_budget_tokens")]
370    pub budget_tokens: usize,
371
372    /// Safety margin for token estimation inaccuracy (default: 0.20).
373    /// Covers up to 25% deviation in compression ratio after trimming.
374    #[serde(default = "default_margin")]
375    pub margin: f64,
376
377    /// Maximum trim-encode-verify iterations (default: 3).
378    /// 2 is sufficient in 99% of cases; 3 is a safety net.
379    #[serde(default = "default_max_iterations")]
380    pub max_iterations: usize,
381
382    /// Default output format: "toon" or "json" (default: "toon").
383    #[serde(default = "default_format_toon")]
384    pub default_format: String,
385
386    /// Strategy overrides by tool name.
387    /// Keys are tool names (including proxy-prefixed), values are strategy names.
388    /// Available strategies: element_count, cascading, size_proportional,
389    /// thread_level, head_tail, default.
390    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
391    pub strategies: HashMap<String, String>,
392
393    #[serde(default)]
394    pub proxy_matching: ProxyMatchingConfig,
395}
396
397impl Default for FormatPipelineConfig {
398    fn default() -> Self {
399        Self {
400            budget_tokens: default_budget_tokens(),
401            margin: default_margin(),
402            max_iterations: default_max_iterations(),
403            default_format: default_format_toon(),
404            strategies: HashMap::new(),
405            proxy_matching: ProxyMatchingConfig::default(),
406        }
407    }
408}
409
410fn default_budget_tokens() -> usize {
411    8000
412}
413
414fn default_margin() -> f64 {
415    0.20
416}
417
418fn default_max_iterations() -> usize {
419    3
420}
421
422fn default_format_toon() -> String {
423    "toon".to_string()
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct ProxyMatchingConfig {
428    /// When true, strip proxy prefix (e.g. `cloud__get_issues` → `get_issues`)
429    /// and look up hardcoded defaults (default: true).
430    #[serde(default = "default_true")]
431    pub enabled: bool,
432}
433
434impl Default for ProxyMatchingConfig {
435    fn default() -> Self {
436        Self {
437            enabled: default_true(),
438        }
439    }
440}
441
442fn default_true() -> bool {
443    true
444}
445
446/// Sentry error reporting configuration.
447///
448/// By default Sentry is disabled. Setting `dsn` (or the `DEVBOY_SENTRY_DSN` env var)
449/// is sufficient to enable error reporting.
450///
451/// # Example
452///
453/// ```toml
454/// [sentry]
455/// dsn = "https://examplePublicKey@o0.ingest.sentry.io/0"
456/// environment = "production"
457/// sample_rate = 1.0
458/// traces_sample_rate = 0.0
459/// ```
460///
461/// `Debug` is implemented manually so the `dsn` (which contains an auth token
462/// in its userinfo segment) does not leak through `tracing::debug!` /
463/// `dbg!()` /  panic backtraces. Serialization preserves the value because
464/// the DSN must round-trip back to the on-disk TOML config.
465#[derive(Clone, Default, Serialize, Deserialize)]
466pub struct SentryConfig {
467    /// Sentry DSN endpoint. When empty, Sentry is disabled (no-op).
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub dsn: Option<String>,
470
471    /// Environment tag (e.g., "production", "staging", "development").
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub environment: Option<String>,
474
475    /// Error sample rate (0.0 - 1.0). Default: 1.0 (send all errors).
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub sample_rate: Option<f32>,
478
479    /// Performance tracing sample rate (0.0 - 1.0). Default: 0.0 (disabled).
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub traces_sample_rate: Option<f32>,
482}
483
484impl std::fmt::Debug for SentryConfig {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        f.debug_struct("SentryConfig")
487            .field("dsn", &self.dsn.as_ref().map(|_| "<redacted>"))
488            .field("environment", &self.environment)
489            .field("sample_rate", &self.sample_rate)
490            .field("traces_sample_rate", &self.traces_sample_rate)
491            .finish()
492    }
493}
494
495/// Remote configuration endpoint settings.
496///
497/// Fetches TOML configuration from a remote URL on startup and merges it
498/// with the local config. Remote values override local values.
499///
500/// # Example
501///
502/// ```toml
503/// [remote_config]
504/// url = "https://example.com/api/devboy-config"
505/// token_key = "remote_config.token"
506/// ```
507///
508/// Or via environment variables:
509/// - `DEVBOY_REMOTE_CONFIG_URL` — Remote config URL
510/// - `DEVBOY_REMOTE_CONFIG_TOKEN` — Bearer token for authentication
511#[derive(Debug, Clone, Default, Serialize, Deserialize)]
512pub struct RemoteConfigSettings {
513    /// URL to fetch remote TOML config from.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub url: Option<String>,
516
517    /// Keychain key for the Bearer token (e.g., "remote_config.token").
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub token_key: Option<String>,
520}
521
522fn default_gitlab_url() -> String {
523    "https://gitlab.com".to_string()
524}
525
526// =============================================================================
527// Transparent Proxy Config (routing, secrets, telemetry)
528// =============================================================================
529
530/// Routing strategy — how a tool invocation is dispatched when both the local executor
531/// and a connected upstream MCP server can handle the same tool.
532///
533/// Cloud has priority by design: the default strategy is `Remote`, so behavior is unchanged
534/// for existing deployments unless the user explicitly opts in to local routing.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
536#[serde(rename_all = "kebab-case")]
537pub enum RoutingStrategy {
538    /// Route every matched call to the upstream server. Local executor stays idle for
539    /// matched tools (still used for local-only tools that have no upstream counterpart).
540    #[default]
541    Remote,
542    /// Route matched calls to the local executor. If a tool has no local implementation,
543    /// fall through to upstream.
544    Local,
545    /// Try the local executor first; on error, fall back to upstream (requires
546    /// `fallback_on_error`).
547    #[serde(rename = "local-first")]
548    LocalFirst,
549    /// Try upstream first; on error, fall back to the local executor (requires
550    /// `fallback_on_error`).
551    #[serde(rename = "remote-first")]
552    RemoteFirst,
553}
554
555impl RoutingStrategy {
556    /// Parse a string token, tolerating both kebab-case and snake_case.
557    pub fn parse(s: &str) -> Option<Self> {
558        match s.trim().to_ascii_lowercase().as_str() {
559            "remote" => Some(Self::Remote),
560            "local" => Some(Self::Local),
561            "local-first" | "local_first" | "localfirst" => Some(Self::LocalFirst),
562            "remote-first" | "remote_first" | "remotefirst" => Some(Self::RemoteFirst),
563            _ => None,
564        }
565    }
566}
567
568/// Per-tool override: maps a tool-name glob pattern to a specific routing strategy.
569/// Patterns are matched against the tool name *without* the upstream prefix
570/// (e.g., `get_issues`, not `cloud__get_issues`).
571#[derive(Debug, Clone, Serialize, Deserialize)]
572#[serde(deny_unknown_fields)]
573pub struct ProxyToolRule {
574    /// Glob-like pattern: `*` matches any sequence (including empty).
575    /// Examples: `get_*`, `*_issue`, `gitlab.*`, `create_*`.
576    pub pattern: String,
577    /// Strategy to apply for tools whose name matches this pattern.
578    pub strategy: RoutingStrategy,
579}
580
581/// Routing policy: global default strategy plus per-tool overrides.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(deny_unknown_fields)]
584pub struct ProxyRoutingConfig {
585    /// Default strategy applied to tools without a matching override.
586    #[serde(default)]
587    pub strategy: RoutingStrategy,
588    /// For `LocalFirst` / `RemoteFirst`: when the primary executor errors, retry with
589    /// the other executor. No-op for `Remote` / `Local` strategies.
590    #[serde(default = "default_true")]
591    pub fallback_on_error: bool,
592    /// First-match-wins list of per-tool overrides.
593    #[serde(default, skip_serializing_if = "Vec::is_empty")]
594    pub tool_overrides: Vec<ProxyToolRule>,
595}
596
597impl Default for ProxyRoutingConfig {
598    fn default() -> Self {
599        Self {
600            strategy: RoutingStrategy::default(),
601            fallback_on_error: true,
602            tool_overrides: Vec::new(),
603        }
604    }
605}
606
607impl ProxyRoutingConfig {
608    /// Resolve the effective strategy for a tool name (without upstream prefix).
609    /// First-match wins across `tool_overrides`; falls back to the global `strategy`.
610    pub fn strategy_for(&self, tool_name: &str) -> RoutingStrategy {
611        for rule in &self.tool_overrides {
612            if matches_glob(&rule.pattern, tool_name) {
613                return rule.strategy;
614            }
615        }
616        self.strategy
617    }
618
619    /// Merge a per-server override on top of this global config.
620    ///
621    /// Only `Some` fields of the override win over the global config — omitted fields
622    /// are inherited. `tool_overrides` from the override are prepended so they match
623    /// before global rules; `None` there means "use the global list as-is".
624    pub fn merged_with(&self, override_cfg: Option<&ProxyRoutingOverride>) -> ProxyRoutingConfig {
625        let Some(o) = override_cfg else {
626            return self.clone();
627        };
628        let mut merged = self.clone();
629        if let Some(strategy) = o.strategy {
630            merged.strategy = strategy;
631        }
632        if let Some(fallback_on_error) = o.fallback_on_error {
633            merged.fallback_on_error = fallback_on_error;
634        }
635        if let Some(extra) = &o.tool_overrides
636            && !extra.is_empty()
637        {
638            let mut combined = extra.clone();
639            combined.extend(self.tool_overrides.iter().cloned());
640            merged.tool_overrides = combined;
641        }
642        merged
643    }
644
645    /// True iff this config equals the default — used for `skip_serializing_if`.
646    pub fn is_default(&self) -> bool {
647        self.strategy == RoutingStrategy::default()
648            && self.fallback_on_error
649            && self.tool_overrides.is_empty()
650    }
651}
652
653/// Per-server partial override for [`ProxyRoutingConfig`].
654///
655/// Every field is `Option` so that an override block touches only what it explicitly
656/// sets — omitted fields inherit from the global `[proxy.routing]`. This matches the
657/// "override what you want, keep what you don't" intuition a reviewer would expect
658/// from the merge semantics described in the docs.
659#[derive(Debug, Clone, Default, Serialize, Deserialize)]
660#[serde(deny_unknown_fields)]
661pub struct ProxyRoutingOverride {
662    #[serde(default, skip_serializing_if = "Option::is_none")]
663    pub strategy: Option<RoutingStrategy>,
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub fallback_on_error: Option<bool>,
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub tool_overrides: Option<Vec<ProxyToolRule>>,
668}
669
670/// Secure-store configuration for proxy authentication tokens.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct ProxySecretsConfig {
674    /// TTL (seconds) for the in-memory cache on top of the OS keychain.
675    /// `0` disables caching and forces a keychain lookup on every call
676    /// (safer, but slower and may trigger repeated UI prompts on macOS).
677    /// Default: 300 (5 minutes).
678    #[serde(default = "default_secrets_cache_ttl")]
679    pub cache_ttl_secs: u64,
680}
681
682impl Default for ProxySecretsConfig {
683    fn default() -> Self {
684        Self {
685            cache_ttl_secs: default_secrets_cache_ttl(),
686        }
687    }
688}
689
690impl ProxySecretsConfig {
691    pub fn is_default(&self) -> bool {
692        self.cache_ttl_secs == default_secrets_cache_ttl()
693    }
694}
695
696fn default_secrets_cache_ttl() -> u64 {
697    300
698}
699
700/// Telemetry pipeline configuration — reports routing decisions to a configurable
701/// HTTP endpoint even when the call is executed locally.
702#[derive(Debug, Clone, Serialize, Deserialize)]
703#[serde(deny_unknown_fields)]
704pub struct ProxyTelemetryConfig {
705    /// When false, no telemetry events are collected or uploaded.
706    #[serde(default = "default_true")]
707    pub enabled: bool,
708    /// Flush when this many events accumulate in the buffer.
709    #[serde(default = "default_batch_size")]
710    pub batch_size: usize,
711    /// Flush at least once per interval even if the buffer is smaller than `batch_size`.
712    #[serde(default = "default_batch_interval_secs")]
713    pub batch_interval_secs: u64,
714    /// Upload endpoint URL. If unset, events are collected but never uploaded (dry-run).
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub endpoint: Option<String>,
717    /// Keychain key for the telemetry auth token. Falls back to the first upstream
718    /// server's `token_key` when unset.
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub token_key: Option<String>,
721    /// Maximum events held in the offline queue (when upload is unavailable). Oldest
722    /// events are dropped when the queue is full.
723    #[serde(default = "default_offline_queue_max")]
724    pub offline_queue_max: usize,
725}
726
727impl Default for ProxyTelemetryConfig {
728    fn default() -> Self {
729        Self {
730            enabled: true,
731            batch_size: default_batch_size(),
732            batch_interval_secs: default_batch_interval_secs(),
733            endpoint: None,
734            token_key: None,
735            offline_queue_max: default_offline_queue_max(),
736        }
737    }
738}
739
740impl ProxyTelemetryConfig {
741    pub fn is_default(&self) -> bool {
742        self.enabled
743            && self.batch_size == default_batch_size()
744            && self.batch_interval_secs == default_batch_interval_secs()
745            && self.endpoint.is_none()
746            && self.token_key.is_none()
747            && self.offline_queue_max == default_offline_queue_max()
748    }
749}
750
751fn default_batch_size() -> usize {
752    100
753}
754
755fn default_batch_interval_secs() -> u64 {
756    30
757}
758
759fn default_offline_queue_max() -> usize {
760    10_000
761}
762
763/// Container for global proxy configuration — wired under `[proxy]` in TOML.
764#[derive(Debug, Clone, Default, Serialize, Deserialize)]
765#[serde(deny_unknown_fields)]
766pub struct ProxyConfig {
767    #[serde(default, skip_serializing_if = "ProxyRoutingConfig::is_default")]
768    pub routing: ProxyRoutingConfig,
769
770    #[serde(default, skip_serializing_if = "ProxySecretsConfig::is_default")]
771    pub secrets: ProxySecretsConfig,
772
773    #[serde(default, skip_serializing_if = "ProxyTelemetryConfig::is_default")]
774    pub telemetry: ProxyTelemetryConfig,
775}
776
777impl ProxyConfig {
778    pub fn is_default(&self) -> bool {
779        self.routing.is_default() && self.secrets.is_default() && self.telemetry.is_default()
780    }
781}
782
783/// Match `name` against a glob-like `pattern` where `*` is a wildcard matching any
784/// run of characters (including empty). No character classes, escapes, or `?`.
785///
786/// Examples:
787/// - `get_*` matches `get_issues`, `get_merge_requests`
788/// - `*_issue` matches `create_issue`, `update_issue`
789/// - `*` matches everything
790/// - `exact` matches only `exact`
791pub fn matches_glob(pattern: &str, name: &str) -> bool {
792    // Trivial cases
793    if pattern == "*" {
794        return true;
795    }
796    if !pattern.contains('*') {
797        return pattern == name;
798    }
799
800    let segments: Vec<&str> = pattern.split('*').collect();
801    let mut cursor = 0usize;
802    let last_idx = segments.len() - 1;
803
804    // First segment must be a prefix unless empty (leading *).
805    if !segments[0].is_empty() {
806        if !name.starts_with(segments[0]) {
807            return false;
808        }
809        cursor = segments[0].len();
810    }
811
812    // Middle segments must appear in order, each consuming a position in `name`.
813    for seg in &segments[1..last_idx] {
814        if seg.is_empty() {
815            continue; // "**" collapses
816        }
817        match name[cursor..].find(seg) {
818            Some(pos) => cursor += pos + seg.len(),
819            None => return false,
820        }
821    }
822
823    // Last segment must be a suffix unless empty (trailing *).
824    let last = segments[last_idx];
825    if last.is_empty() {
826        return true;
827    }
828    if cursor > name.len() {
829        return false;
830    }
831    name[cursor..].ends_with(last)
832}
833
834// =============================================================================
835// Config implementation
836// =============================================================================
837
838impl Config {
839    /// Name of the implicit context for legacy top-level provider configuration.
840    pub const DEFAULT_CONTEXT_NAME: &'static str = "default";
841
842    /// Get the configuration directory path.
843    pub fn config_dir() -> Result<PathBuf> {
844        dirs::config_dir()
845            .map(|p| p.join(CONFIG_DIR_NAME))
846            .ok_or_else(|| Error::Config("Could not determine config directory".to_string()))
847    }
848
849    /// Get the configuration file path.
850    pub fn config_path() -> Result<PathBuf> {
851        Ok(Self::config_dir()?.join(CONFIG_FILE_NAME))
852    }
853
854    /// Load configuration from the default location.
855    ///
856    /// Returns a default (empty) config if the file doesn't exist.
857    pub fn load() -> Result<Self> {
858        let path = Self::config_path()?;
859        Self::load_from(&path)
860    }
861
862    /// Load configuration from a specific path.
863    ///
864    /// Returns a default (empty) config if the file doesn't exist.
865    pub fn load_from(path: &PathBuf) -> Result<Self> {
866        if !path.exists() {
867            debug!(path = ?path, "Config file does not exist, using defaults");
868            return Ok(Self::default());
869        }
870
871        debug!(path = ?path, "Loading config");
872
873        let contents = std::fs::read_to_string(path)
874            .map_err(|e| Error::Config(format!("Failed to read config file: {}", e)))?;
875
876        let mut config: Config = toml::from_str(&contents)
877            .map_err(|e| Error::Config(format!("Failed to parse config file: {}", e)))?;
878
879        // First collapse cosmetic empties (e.g. `endpoint = ""`) so they behave like the
880        // CLI's "empty value clears the field" semantics, then validate semantics that
881        // serde cannot enforce on its own (URL shape, etc.). `Config::set` already
882        // applies these at write time — we re-run them on load so hand-edited TOML
883        // cannot sneak invalid values past the API surface.
884        config.sanitize();
885        config.validate()?;
886
887        info!(path = ?path, "Config loaded successfully");
888        Ok(config)
889    }
890
891    /// Normalize cosmetic "null-equivalents" that TOML/serde can't express on their
892    /// own — currently just: `proxy.telemetry.endpoint = ""` collapses to `None`, so
893    /// hand-edited TOML matches the CLI semantics (where an empty value clears the
894    /// field rather than leaving an invalid URL in place). Called by [`Self::load_from`]
895    /// immediately before [`Self::validate`].
896    pub fn sanitize(&mut self) {
897        if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref()
898            && endpoint.is_empty()
899        {
900            self.proxy.telemetry.endpoint = None;
901        }
902    }
903
904    /// Run post-deserialization validation on the config.
905    ///
906    /// Covers invariants that TOML/serde deserializers can't express by themselves:
907    /// URL shape for telemetry endpoint, bool coercions, etc. Safe to call at any time.
908    /// Note: an empty-string endpoint is rejected here — callers that want "empty
909    /// means clear" semantics should run [`Self::sanitize`] first (which `load_from`
910    /// does automatically).
911    pub fn validate(&self) -> Result<()> {
912        if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref() {
913            validate_http_url(endpoint, "proxy.telemetry.endpoint")?;
914        }
915        Ok(())
916    }
917
918    /// Save configuration to the default location.
919    pub fn save(&self) -> Result<()> {
920        let path = Self::config_path()?;
921        self.save_to(&path)
922    }
923
924    /// Save configuration to a specific path.
925    pub fn save_to(&self, path: &PathBuf) -> Result<()> {
926        // Ensure directory exists
927        if let Some(parent) = path.parent() {
928            std::fs::create_dir_all(parent)
929                .map_err(|e| Error::Config(format!("Failed to create config directory: {}", e)))?;
930        }
931
932        debug!(path = ?path, "Saving config");
933
934        let contents = toml::to_string_pretty(self)
935            .map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
936
937        std::fs::write(path, contents)
938            .map_err(|e| Error::Config(format!("Failed to write config file: {}", e)))?;
939
940        info!(path = ?path, "Config saved successfully");
941        Ok(())
942    }
943
944    /// Check if any provider is configured.
945    pub fn has_any_provider(&self) -> bool {
946        self.github.is_some()
947            || self.gitlab.is_some()
948            || self.clickup.is_some()
949            || self.jira.is_some()
950            || self.fireflies.is_some()
951            || self.confluence.is_some()
952            || self.slack.is_some()
953            || self.contexts.values().any(ContextConfig::has_any_provider)
954    }
955
956    /// Get a list of configured provider names.
957    pub fn configured_providers(&self) -> Vec<&'static str> {
958        let mut providers = Vec::new();
959        if self.github.is_some() {
960            providers.push("github");
961        }
962        if self.gitlab.is_some() {
963            providers.push("gitlab");
964        }
965        if self.clickup.is_some() {
966            providers.push("clickup");
967        }
968        if self.jira.is_some() {
969            providers.push("jira");
970        }
971        if self.confluence.is_some() {
972            providers.push("confluence");
973        }
974        if self.slack.is_some() {
975            providers.push("slack");
976        }
977        providers
978    }
979
980    /// Get all context names, including implicit legacy `default` context when applicable.
981    pub fn context_names(&self) -> Vec<String> {
982        let mut names: Vec<String> = self.contexts.keys().cloned().collect();
983        if self.legacy_default_context().is_some()
984            && !names.iter().any(|n| n == Self::DEFAULT_CONTEXT_NAME)
985        {
986            names.push(Self::DEFAULT_CONTEXT_NAME.to_string());
987        }
988        names.sort();
989        names
990    }
991
992    /// Get context config by name, including implicit legacy `default` context.
993    pub fn get_context(&self, name: &str) -> Option<ContextConfig> {
994        if name == Self::DEFAULT_CONTEXT_NAME {
995            return self
996                .contexts
997                .get(name)
998                .cloned()
999                .or_else(|| self.legacy_default_context());
1000        }
1001
1002        self.contexts.get(name).cloned()
1003    }
1004
1005    /// Resolve the currently active context name.
1006    pub fn resolve_active_context_name(&self) -> Option<String> {
1007        if let Some(active) = &self.active_context
1008            && self.get_context(active).is_some()
1009        {
1010            return Some(active.clone());
1011        }
1012
1013        if self.get_context(Self::DEFAULT_CONTEXT_NAME).is_some() {
1014            return Some(Self::DEFAULT_CONTEXT_NAME.to_string());
1015        }
1016
1017        self.context_names().into_iter().next()
1018    }
1019
1020    /// Set active context if it exists.
1021    pub fn set_active_context(&mut self, name: &str) -> Result<()> {
1022        if self.get_context(name).is_none() {
1023            return Err(Error::Config(format!("Unknown context: {}", name)));
1024        }
1025        self.active_context = Some(name.to_string());
1026        Ok(())
1027    }
1028
1029    /// Return the implicit legacy context from top-level provider fields.
1030    pub fn legacy_default_context(&self) -> Option<ContextConfig> {
1031        let ctx = ContextConfig {
1032            github: self.github.clone(),
1033            gitlab: self.gitlab.clone(),
1034            clickup: self.clickup.clone(),
1035            jira: self.jira.clone(),
1036            fireflies: self.fireflies.clone(),
1037            confluence: self.confluence.clone(),
1038            slack: self.slack.clone(),
1039        };
1040
1041        if ctx.has_any_provider() {
1042            Some(ctx)
1043        } else {
1044            None
1045        }
1046    }
1047
1048    /// Set a configuration value by key path.
1049    ///
1050    /// Supported key formats:
1051    /// - `provider.field` — e.g., `github.owner`, `gitlab.url`
1052    /// - `proxy.{routing|secrets|telemetry}.{field}` — e.g., `proxy.routing.strategy`
1053    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
1054        let parts: Vec<&str> = key.split('.').collect();
1055
1056        // Three-part paths are reserved for `proxy.*` sections.
1057        if parts.len() == 3 && parts[0] == "proxy" {
1058            return self.set_proxy_field(parts[1], parts[2], value);
1059        }
1060
1061        if parts.len() != 2 {
1062            return Err(Error::Config(format!(
1063                "Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
1064                key
1065            )));
1066        }
1067
1068        let (provider, field) = (parts[0], parts[1]);
1069
1070        match provider {
1071            "github" => {
1072                let config = self.github.get_or_insert_with(|| GitHubConfig {
1073                    owner: String::new(),
1074                    repo: String::new(),
1075                    base_url: None,
1076                });
1077                match field {
1078                    "owner" => config.owner = value.to_string(),
1079                    "repo" => config.repo = value.to_string(),
1080                    "base_url" | "url" => config.base_url = Some(value.to_string()),
1081                    _ => {
1082                        return Err(Error::Config(format!(
1083                            "Unknown GitHub config field: {}",
1084                            field
1085                        )));
1086                    }
1087                }
1088            }
1089            "gitlab" => {
1090                let config = self.gitlab.get_or_insert_with(|| GitLabConfig {
1091                    url: default_gitlab_url(),
1092                    project_id: String::new(),
1093                });
1094                match field {
1095                    "url" => config.url = value.to_string(),
1096                    "project_id" | "project" => config.project_id = value.to_string(),
1097                    _ => {
1098                        return Err(Error::Config(format!(
1099                            "Unknown GitLab config field: {}",
1100                            field
1101                        )));
1102                    }
1103                }
1104            }
1105            "clickup" => {
1106                let config = self.clickup.get_or_insert_with(|| ClickUpConfig {
1107                    list_id: String::new(),
1108                    team_id: None,
1109                });
1110                match field {
1111                    "list_id" | "list" => config.list_id = value.to_string(),
1112                    "team_id" | "team" => config.team_id = Some(value.to_string()),
1113                    _ => {
1114                        return Err(Error::Config(format!(
1115                            "Unknown ClickUp config field: {}",
1116                            field
1117                        )));
1118                    }
1119                }
1120            }
1121            "jira" => {
1122                let config = self.jira.get_or_insert_with(|| JiraConfig {
1123                    url: String::new(),
1124                    project_key: String::new(),
1125                    email: String::new(),
1126                });
1127                match field {
1128                    "url" => config.url = value.to_string(),
1129                    "project_key" | "project" => config.project_key = value.to_string(),
1130                    "email" => config.email = value.to_string(),
1131                    _ => {
1132                        return Err(Error::Config(format!(
1133                            "Unknown Jira config field: {}",
1134                            field
1135                        )));
1136                    }
1137                }
1138            }
1139            "confluence" => {
1140                let config = self.confluence.get_or_insert_with(|| ConfluenceConfig {
1141                    base_url: String::new(),
1142                    api_version: None,
1143                    username: None,
1144                    space_key: None,
1145                });
1146                match field {
1147                    "base_url" | "url" => config.base_url = value.to_string(),
1148                    "api_version" | "api" | "version" => {
1149                        config.api_version = Some(value.to_string())
1150                    }
1151                    "username" | "email" | "user" => config.username = Some(value.to_string()),
1152                    "space_key" | "space" => config.space_key = Some(value.to_string()),
1153                    _ => {
1154                        return Err(Error::Config(format!(
1155                            "Unknown Confluence config field: {}",
1156                            field
1157                        )));
1158                    }
1159                }
1160            }
1161            "slack" => {
1162                let config = self.slack.get_or_insert_with(SlackConfig::default);
1163                match field {
1164                    "team_id" | "team" => config.team_id = Some(value.to_string()),
1165                    "workspace" => config.workspace = Some(value.to_string()),
1166                    "base_url" | "url" => config.base_url = Some(value.to_string()),
1167                    "client_id" => config.client_id = Some(value.to_string()),
1168                    "redirect_uri" => config.redirect_uri = Some(value.to_string()),
1169                    _ => {
1170                        return Err(Error::Config(format!(
1171                            "Unknown Slack config field: {}",
1172                            field
1173                        )));
1174                    }
1175                }
1176            }
1177            _ => {
1178                return Err(Error::Config(format!("Unknown provider: {}", provider)));
1179            }
1180        }
1181
1182        Ok(())
1183    }
1184
1185    /// Get a configuration value by key path.
1186    ///
1187    /// Supported key formats:
1188    /// - `provider.field` — e.g., `github.owner`, `gitlab.url`
1189    /// - `proxy.{routing|secrets|telemetry}.{field}` — e.g., `proxy.routing.strategy`
1190    pub fn get(&self, key: &str) -> Result<Option<String>> {
1191        let parts: Vec<&str> = key.split('.').collect();
1192
1193        if parts.len() == 3 && parts[0] == "proxy" {
1194            return self.get_proxy_field(parts[1], parts[2]);
1195        }
1196
1197        if parts.len() != 2 {
1198            return Err(Error::Config(format!(
1199                "Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
1200                key
1201            )));
1202        }
1203
1204        let (provider, field) = (parts[0], parts[1]);
1205
1206        match provider {
1207            "github" => {
1208                let Some(config) = &self.github else {
1209                    return Ok(None);
1210                };
1211                match field {
1212                    "owner" => Ok(Some(config.owner.clone())),
1213                    "repo" => Ok(Some(config.repo.clone())),
1214                    "base_url" | "url" => Ok(config.base_url.clone()),
1215                    _ => Err(Error::Config(format!(
1216                        "Unknown GitHub config field: {}",
1217                        field
1218                    ))),
1219                }
1220            }
1221            "gitlab" => {
1222                let Some(config) = &self.gitlab else {
1223                    return Ok(None);
1224                };
1225                match field {
1226                    "url" => Ok(Some(config.url.clone())),
1227                    "project_id" | "project" => Ok(Some(config.project_id.clone())),
1228                    _ => Err(Error::Config(format!(
1229                        "Unknown GitLab config field: {}",
1230                        field
1231                    ))),
1232                }
1233            }
1234            "clickup" => {
1235                let Some(config) = &self.clickup else {
1236                    return Ok(None);
1237                };
1238                match field {
1239                    "list_id" | "list" => Ok(Some(config.list_id.clone())),
1240                    "team_id" | "team" => Ok(config.team_id.clone()),
1241                    _ => Err(Error::Config(format!(
1242                        "Unknown ClickUp config field: {}",
1243                        field
1244                    ))),
1245                }
1246            }
1247            "jira" => {
1248                let Some(config) = &self.jira else {
1249                    return Ok(None);
1250                };
1251                match field {
1252                    "url" => Ok(Some(config.url.clone())),
1253                    "project_key" | "project" => Ok(Some(config.project_key.clone())),
1254                    "email" => Ok(Some(config.email.clone())),
1255                    _ => Err(Error::Config(format!(
1256                        "Unknown Jira config field: {}",
1257                        field
1258                    ))),
1259                }
1260            }
1261            "confluence" => {
1262                let Some(config) = &self.confluence else {
1263                    return Ok(None);
1264                };
1265                match field {
1266                    "base_url" | "url" => Ok(Some(config.base_url.clone())),
1267                    "api_version" | "api" | "version" => Ok(config.api_version.clone()),
1268                    "username" | "email" | "user" => Ok(config.username.clone()),
1269                    "space_key" | "space" => Ok(config.space_key.clone()),
1270                    _ => Err(Error::Config(format!(
1271                        "Unknown Confluence config field: {}",
1272                        field
1273                    ))),
1274                }
1275            }
1276            "slack" => {
1277                let Some(config) = &self.slack else {
1278                    return Ok(None);
1279                };
1280                match field {
1281                    "team_id" | "team" => Ok(config.team_id.clone()),
1282                    "workspace" => Ok(config.workspace.clone()),
1283                    "base_url" | "url" => Ok(config.base_url.clone()),
1284                    "client_id" => Ok(config.client_id.clone()),
1285                    "redirect_uri" => Ok(config.redirect_uri.clone()),
1286                    _ => Err(Error::Config(format!(
1287                        "Unknown Slack config field: {}",
1288                        field
1289                    ))),
1290                }
1291            }
1292            _ => Err(Error::Config(format!("Unknown provider: {}", provider))),
1293        }
1294    }
1295
1296    /// Set a `proxy.{section}.{field}` value. Extracted so [`Self::set`] stays small.
1297    fn set_proxy_field(&mut self, section: &str, field: &str, value: &str) -> Result<()> {
1298        match section {
1299            "routing" => match field {
1300                "strategy" => {
1301                    let strat = RoutingStrategy::parse(value).ok_or_else(|| {
1302                        Error::Config(format!(
1303                            "Invalid routing strategy '{}'. Allowed (case-insensitive): \
1304                             remote, local, local-first, remote-first",
1305                            value
1306                        ))
1307                    })?;
1308                    self.proxy.routing.strategy = strat;
1309                    Ok(())
1310                }
1311                "fallback_on_error" => {
1312                    self.proxy.routing.fallback_on_error = parse_bool(value)?;
1313                    Ok(())
1314                }
1315                _ => Err(Error::Config(format!(
1316                    "Unknown proxy.routing field: {}",
1317                    field
1318                ))),
1319            },
1320            "secrets" => match field {
1321                "cache_ttl_secs" => {
1322                    self.proxy.secrets.cache_ttl_secs = parse_u64(value, field)?;
1323                    Ok(())
1324                }
1325                _ => Err(Error::Config(format!(
1326                    "Unknown proxy.secrets field: {}",
1327                    field
1328                ))),
1329            },
1330            "telemetry" => match field {
1331                "enabled" => {
1332                    self.proxy.telemetry.enabled = parse_bool(value)?;
1333                    Ok(())
1334                }
1335                "endpoint" => {
1336                    self.proxy.telemetry.endpoint = if value.is_empty() {
1337                        None
1338                    } else {
1339                        validate_http_url(value, "proxy.telemetry.endpoint")?;
1340                        Some(value.to_string())
1341                    };
1342                    Ok(())
1343                }
1344                "token_key" => {
1345                    self.proxy.telemetry.token_key = if value.is_empty() {
1346                        None
1347                    } else {
1348                        Some(value.to_string())
1349                    };
1350                    Ok(())
1351                }
1352                "batch_size" => {
1353                    self.proxy.telemetry.batch_size = parse_usize(value, field)?;
1354                    Ok(())
1355                }
1356                "batch_interval_secs" => {
1357                    self.proxy.telemetry.batch_interval_secs = parse_u64(value, field)?;
1358                    Ok(())
1359                }
1360                "offline_queue_max" => {
1361                    self.proxy.telemetry.offline_queue_max = parse_usize(value, field)?;
1362                    Ok(())
1363                }
1364                _ => Err(Error::Config(format!(
1365                    "Unknown proxy.telemetry field: {}",
1366                    field
1367                ))),
1368            },
1369            _ => Err(Error::Config(format!(
1370                "Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
1371                section
1372            ))),
1373        }
1374    }
1375
1376    /// Read a `proxy.{section}.{field}` value. Returns `Ok(None)` for fields that are
1377    /// unset (e.g., optional `telemetry.endpoint`).
1378    fn get_proxy_field(&self, section: &str, field: &str) -> Result<Option<String>> {
1379        match section {
1380            "routing" => match field {
1381                "strategy" => Ok(Some(routing_strategy_slug(self.proxy.routing.strategy))),
1382                "fallback_on_error" => Ok(Some(self.proxy.routing.fallback_on_error.to_string())),
1383                _ => Err(Error::Config(format!(
1384                    "Unknown proxy.routing field: {}",
1385                    field
1386                ))),
1387            },
1388            "secrets" => match field {
1389                "cache_ttl_secs" => Ok(Some(self.proxy.secrets.cache_ttl_secs.to_string())),
1390                _ => Err(Error::Config(format!(
1391                    "Unknown proxy.secrets field: {}",
1392                    field
1393                ))),
1394            },
1395            "telemetry" => match field {
1396                "enabled" => Ok(Some(self.proxy.telemetry.enabled.to_string())),
1397                "endpoint" => Ok(self.proxy.telemetry.endpoint.clone()),
1398                "token_key" => Ok(self.proxy.telemetry.token_key.clone()),
1399                "batch_size" => Ok(Some(self.proxy.telemetry.batch_size.to_string())),
1400                "batch_interval_secs" => {
1401                    Ok(Some(self.proxy.telemetry.batch_interval_secs.to_string()))
1402                }
1403                "offline_queue_max" => Ok(Some(self.proxy.telemetry.offline_queue_max.to_string())),
1404                _ => Err(Error::Config(format!(
1405                    "Unknown proxy.telemetry field: {}",
1406                    field
1407                ))),
1408            },
1409            _ => Err(Error::Config(format!(
1410                "Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
1411                section
1412            ))),
1413        }
1414    }
1415}
1416
1417fn parse_bool(value: &str) -> Result<bool> {
1418    match value.trim().to_ascii_lowercase().as_str() {
1419        "true" | "1" | "yes" | "on" => Ok(true),
1420        "false" | "0" | "no" | "off" => Ok(false),
1421        _ => Err(Error::Config(format!(
1422            "Invalid boolean '{}'. Allowed: true/false, 1/0, yes/no, on/off",
1423            value
1424        ))),
1425    }
1426}
1427
1428fn parse_u64(value: &str, field: &str) -> Result<u64> {
1429    value.trim().parse::<u64>().map_err(|_| {
1430        Error::Config(format!(
1431            "Invalid value for {}: '{}'. Expected non-negative integer",
1432            field, value
1433        ))
1434    })
1435}
1436
1437fn parse_usize(value: &str, field: &str) -> Result<usize> {
1438    value.trim().parse::<usize>().map_err(|_| {
1439        Error::Config(format!(
1440            "Invalid value for {}: '{}'. Expected non-negative integer",
1441            field, value
1442        ))
1443    })
1444}
1445
1446/// Lightweight sanity check that the value looks like a valid HTTP(S) URL.
1447///
1448/// A full RFC 3986 parser would pull in the `url` crate for a single field, which no
1449/// other part of `devboy-core` needs. To reject the obvious garbage (`not-a-url`,
1450/// `ftp://…`, lone slashes) it is enough to verify that the string:
1451/// - starts with `http://` or `https://`
1452/// - has at least one non-empty character after the scheme, before any `/`, `?`, `#`
1453/// - contains no whitespace anywhere (host, path, query)
1454///
1455/// Stricter validation (DNS labels, port, escaping) is left to `reqwest` at upload
1456/// time; this helper exists purely to catch user typos at configuration time.
1457fn validate_http_url(value: &str, field: &str) -> Result<()> {
1458    // A correct URL has no whitespace anywhere (host, path, or query). Reject the
1459    // whole string up-front instead of letting e.g. `https://example.com/a b` slip
1460    // through just because the host part was clean.
1461    if value.contains(|c: char| c.is_whitespace()) {
1462        return Err(Error::Config(format!(
1463            "Invalid URL for {}: '{}'. Must not contain whitespace",
1464            field, value
1465        )));
1466    }
1467
1468    let rest = if let Some(r) = value.strip_prefix("https://") {
1469        r
1470    } else if let Some(r) = value.strip_prefix("http://") {
1471        r
1472    } else {
1473        return Err(Error::Config(format!(
1474            "Invalid URL for {}: '{}'. Must start with http:// or https://",
1475            field, value
1476        )));
1477    };
1478
1479    // Minimal host extraction — everything up to the first `/`, `?` or `#`.
1480    let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1481    let host = &rest[..host_end];
1482    if host.is_empty() {
1483        return Err(Error::Config(format!(
1484            "Invalid URL for {}: '{}'. Missing host",
1485            field, value
1486        )));
1487    }
1488
1489    Ok(())
1490}
1491
1492/// Stable kebab-case slug for a [`RoutingStrategy`]. Symmetric with serde and TOML
1493/// serialisation. Exported so CLI / observability code renders strategy values the
1494/// same way in every surface (JSON, plain text, `config list`).
1495pub fn routing_strategy_slug(s: RoutingStrategy) -> String {
1496    match s {
1497        RoutingStrategy::Remote => "remote",
1498        RoutingStrategy::Local => "local",
1499        RoutingStrategy::LocalFirst => "local-first",
1500        RoutingStrategy::RemoteFirst => "remote-first",
1501    }
1502    .to_string()
1503}
1504
1505impl ContextConfig {
1506    /// Check whether this context config defines at least one provider.
1507    pub fn has_any_provider(&self) -> bool {
1508        self.github.is_some()
1509            || self.gitlab.is_some()
1510            || self.clickup.is_some()
1511            || self.jira.is_some()
1512            || self.fireflies.is_some()
1513            || self.confluence.is_some()
1514            || self.slack.is_some()
1515    }
1516
1517    /// Return configured provider names for this context.
1518    pub fn configured_providers(&self) -> Vec<&'static str> {
1519        let mut providers = Vec::new();
1520        if self.github.is_some() {
1521            providers.push("github");
1522        }
1523        if self.gitlab.is_some() {
1524            providers.push("gitlab");
1525        }
1526        if self.clickup.is_some() {
1527            providers.push("clickup");
1528        }
1529        if self.jira.is_some() {
1530            providers.push("jira");
1531        }
1532        if self.confluence.is_some() {
1533            providers.push("confluence");
1534        }
1535        if self.slack.is_some() {
1536            providers.push("slack");
1537        }
1538        providers
1539    }
1540}
1541
1542// =============================================================================
1543// Tests
1544// =============================================================================
1545
1546#[cfg(test)]
1547mod tests {
1548    use super::*;
1549    use tempfile::NamedTempFile;
1550
1551    #[test]
1552    fn test_default_config() {
1553        let config = Config::default();
1554        assert!(config.github.is_none());
1555        assert!(config.gitlab.is_none());
1556        assert!(config.contexts.is_empty());
1557        assert!(!config.has_any_provider());
1558        assert!(config.configured_providers().is_empty());
1559    }
1560
1561    #[test]
1562    fn test_set_and_get() {
1563        let mut config = Config::default();
1564
1565        // Set GitHub config
1566        config.set("github.owner", "test-owner").unwrap();
1567        config.set("github.repo", "test-repo").unwrap();
1568
1569        assert_eq!(
1570            config.get("github.owner").unwrap(),
1571            Some("test-owner".to_string())
1572        );
1573        assert_eq!(
1574            config.get("github.repo").unwrap(),
1575            Some("test-repo".to_string())
1576        );
1577
1578        // Set GitLab config
1579        config
1580            .set("gitlab.url", "https://gitlab.example.com")
1581            .unwrap();
1582        config.set("gitlab.project_id", "123").unwrap();
1583
1584        assert_eq!(
1585            config.get("gitlab.url").unwrap(),
1586            Some("https://gitlab.example.com".to_string())
1587        );
1588
1589        // Check configured providers
1590        assert!(config.has_any_provider());
1591        let providers = config.configured_providers();
1592        assert!(providers.contains(&"github"));
1593        assert!(providers.contains(&"gitlab"));
1594    }
1595
1596    #[test]
1597    fn test_default_slack_required_scopes_cover_default_conversation_types() {
1598        let scopes = default_slack_required_scopes();
1599
1600        assert!(scopes.contains(&"channels:read".to_string()));
1601        assert!(scopes.contains(&"channels:history".to_string()));
1602        assert!(scopes.contains(&"groups:read".to_string()));
1603        assert!(scopes.contains(&"groups:history".to_string()));
1604        assert!(scopes.contains(&"im:read".to_string()));
1605        assert!(scopes.contains(&"im:history".to_string()));
1606        assert!(scopes.contains(&"mpim:read".to_string()));
1607        assert!(scopes.contains(&"mpim:history".to_string()));
1608    }
1609
1610    #[test]
1611    fn test_invalid_key() {
1612        let mut config = Config::default();
1613
1614        // Invalid key format
1615        assert!(config.set("invalid", "value").is_err());
1616        assert!(config.set("too.many.parts", "value").is_err());
1617
1618        // Unknown provider
1619        assert!(config.set("unknown.field", "value").is_err());
1620
1621        // When provider config doesn't exist, get returns Ok(None)
1622        assert_eq!(config.get("github.owner").unwrap(), None);
1623
1624        // But unknown field on configured provider should error
1625        config.set("github.owner", "test").unwrap();
1626        assert!(config.get("github.unknown_field").is_err());
1627    }
1628
1629    #[test]
1630    fn test_save_and_load() {
1631        let config = Config {
1632            github: Some(GitHubConfig {
1633                owner: "test-owner".to_string(),
1634                repo: "test-repo".to_string(),
1635                base_url: None,
1636            }),
1637            ..Default::default()
1638        };
1639
1640        // Save to temp file
1641        let temp_file = NamedTempFile::new().unwrap();
1642        let path = temp_file.path().to_path_buf();
1643
1644        config.save_to(&path).unwrap();
1645
1646        // Read raw content
1647        let contents = std::fs::read_to_string(&path).unwrap();
1648        assert!(contents.contains("owner = \"test-owner\""));
1649        assert!(contents.contains("repo = \"test-repo\""));
1650
1651        // Load back
1652        let loaded = Config::load_from(&path).unwrap();
1653        assert!(loaded.github.is_some());
1654        let gh = loaded.github.unwrap();
1655        assert_eq!(gh.owner, "test-owner");
1656        assert_eq!(gh.repo, "test-repo");
1657    }
1658
1659    #[test]
1660    fn test_load_nonexistent() {
1661        let path = PathBuf::from("/nonexistent/path/config.toml");
1662        let config = Config::load_from(&path).unwrap();
1663        assert!(config.github.is_none());
1664    }
1665
1666    #[test]
1667    fn test_set_and_get_gitlab() {
1668        let mut config = Config::default();
1669
1670        config
1671            .set("gitlab.url", "https://gitlab.example.com")
1672            .unwrap();
1673        config.set("gitlab.project_id", "456").unwrap();
1674
1675        assert_eq!(
1676            config.get("gitlab.url").unwrap(),
1677            Some("https://gitlab.example.com".to_string())
1678        );
1679        assert_eq!(
1680            config.get("gitlab.project_id").unwrap(),
1681            Some("456".to_string())
1682        );
1683        // Test alias
1684        assert_eq!(
1685            config.get("gitlab.project").unwrap(),
1686            Some("456".to_string())
1687        );
1688    }
1689
1690    #[test]
1691    fn test_set_and_get_gitlab_alias() {
1692        let mut config = Config::default();
1693
1694        config.set("gitlab.project", "789").unwrap();
1695
1696        assert_eq!(
1697            config.get("gitlab.project_id").unwrap(),
1698            Some("789".to_string())
1699        );
1700    }
1701
1702    #[test]
1703    fn test_set_and_get_clickup() {
1704        let mut config = Config::default();
1705
1706        config.set("clickup.list_id", "list123").unwrap();
1707
1708        assert_eq!(
1709            config.get("clickup.list_id").unwrap(),
1710            Some("list123".to_string())
1711        );
1712        // Test alias
1713        assert_eq!(
1714            config.get("clickup.list").unwrap(),
1715            Some("list123".to_string())
1716        );
1717    }
1718
1719    #[test]
1720    fn test_set_and_get_clickup_alias() {
1721        let mut config = Config::default();
1722
1723        config.set("clickup.list", "list456").unwrap();
1724
1725        assert_eq!(
1726            config.get("clickup.list_id").unwrap(),
1727            Some("list456".to_string())
1728        );
1729    }
1730
1731    #[test]
1732    fn test_set_and_get_jira() {
1733        let mut config = Config::default();
1734
1735        config.set("jira.url", "https://jira.example.com").unwrap();
1736        config.set("jira.project_key", "PROJ").unwrap();
1737        config.set("jira.email", "user@example.com").unwrap();
1738
1739        assert_eq!(
1740            config.get("jira.url").unwrap(),
1741            Some("https://jira.example.com".to_string())
1742        );
1743        assert_eq!(
1744            config.get("jira.project_key").unwrap(),
1745            Some("PROJ".to_string())
1746        );
1747        assert_eq!(
1748            config.get("jira.email").unwrap(),
1749            Some("user@example.com".to_string())
1750        );
1751        // Test alias
1752        assert_eq!(
1753            config.get("jira.project").unwrap(),
1754            Some("PROJ".to_string())
1755        );
1756    }
1757
1758    #[test]
1759    fn test_set_and_get_jira_alias() {
1760        let mut config = Config::default();
1761
1762        config.set("jira.project", "KEY").unwrap();
1763
1764        assert_eq!(
1765            config.get("jira.project_key").unwrap(),
1766            Some("KEY".to_string())
1767        );
1768    }
1769
1770    #[test]
1771    fn test_set_and_get_confluence() {
1772        let mut config = Config::default();
1773
1774        config
1775            .set("confluence.base_url", "https://wiki.example.com")
1776            .unwrap();
1777        config.set("confluence.api_version", "v1").unwrap();
1778        config
1779            .set("confluence.username", "dev@example.com")
1780            .unwrap();
1781        config.set("confluence.space_key", "ENG").unwrap();
1782
1783        assert_eq!(
1784            config.get("confluence.base_url").unwrap(),
1785            Some("https://wiki.example.com".to_string())
1786        );
1787        assert_eq!(
1788            config.get("confluence.url").unwrap(),
1789            Some("https://wiki.example.com".to_string())
1790        );
1791        assert_eq!(
1792            config.get("confluence.api").unwrap(),
1793            Some("v1".to_string())
1794        );
1795        assert_eq!(
1796            config.get("confluence.username").unwrap(),
1797            Some("dev@example.com".to_string())
1798        );
1799        assert_eq!(
1800            config.get("confluence.space").unwrap(),
1801            Some("ENG".to_string())
1802        );
1803    }
1804
1805    #[test]
1806    fn test_set_github_base_url() {
1807        let mut config = Config::default();
1808
1809        config
1810            .set("github.base_url", "https://github.example.com/api/v3")
1811            .unwrap();
1812
1813        assert_eq!(
1814            config.get("github.base_url").unwrap(),
1815            Some("https://github.example.com/api/v3".to_string())
1816        );
1817        // url alias should also work for get
1818        assert_eq!(
1819            config.get("github.url").unwrap(),
1820            Some("https://github.example.com/api/v3".to_string())
1821        );
1822    }
1823
1824    #[test]
1825    fn test_set_github_url_alias() {
1826        let mut config = Config::default();
1827
1828        config
1829            .set("github.url", "https://github.example.com/api/v3")
1830            .unwrap();
1831
1832        assert_eq!(
1833            config.get("github.base_url").unwrap(),
1834            Some("https://github.example.com/api/v3".to_string())
1835        );
1836    }
1837
1838    #[test]
1839    fn test_unknown_field_errors() {
1840        let mut config = Config::default();
1841
1842        // GitHub unknown field
1843        assert!(config.set("github.unknown", "value").is_err());
1844        config.set("github.owner", "test").unwrap();
1845        assert!(config.get("github.unknown").is_err());
1846
1847        // GitLab unknown field
1848        assert!(config.set("gitlab.unknown", "value").is_err());
1849        config.set("gitlab.url", "https://gitlab.com").unwrap();
1850        assert!(config.get("gitlab.unknown").is_err());
1851
1852        // ClickUp unknown field
1853        assert!(config.set("clickup.unknown", "value").is_err());
1854        config.set("clickup.list_id", "123").unwrap();
1855        assert!(config.get("clickup.unknown").is_err());
1856
1857        // Jira unknown field
1858        assert!(config.set("jira.unknown", "value").is_err());
1859        config.set("jira.url", "https://jira.com").unwrap();
1860        assert!(config.get("jira.unknown").is_err());
1861    }
1862
1863    #[test]
1864    fn test_get_unconfigured_providers() {
1865        let config = Config::default();
1866
1867        assert_eq!(config.get("github.owner").unwrap(), None);
1868        assert_eq!(config.get("gitlab.url").unwrap(), None);
1869        assert_eq!(config.get("clickup.list_id").unwrap(), None);
1870        assert_eq!(config.get("jira.url").unwrap(), None);
1871        assert_eq!(config.get("confluence.base_url").unwrap(), None);
1872    }
1873
1874    #[test]
1875    fn test_unknown_provider_set() {
1876        let mut config = Config::default();
1877        let result = config.set("unknown.field", "value");
1878        assert!(result.is_err());
1879        let err_msg = result.unwrap_err().to_string();
1880        assert!(err_msg.contains("Unknown provider: unknown"));
1881    }
1882
1883    #[test]
1884    fn test_unknown_provider_get() {
1885        let config = Config::default();
1886        let result = config.get("unknown.field");
1887        assert!(result.is_err());
1888    }
1889
1890    #[test]
1891    fn test_malformed_toml() {
1892        let temp_file = NamedTempFile::new().unwrap();
1893        let path = temp_file.path().to_path_buf();
1894
1895        std::fs::write(&path, "invalid toml content [[[").unwrap();
1896
1897        let result = Config::load_from(&path);
1898        assert!(result.is_err());
1899        let err_msg = result.unwrap_err().to_string();
1900        assert!(err_msg.contains("Failed to parse config file"));
1901    }
1902
1903    #[test]
1904    fn test_configured_providers_all() {
1905        let config = Config {
1906            github: Some(GitHubConfig {
1907                owner: "o".to_string(),
1908                repo: "r".to_string(),
1909                base_url: None,
1910            }),
1911            gitlab: Some(GitLabConfig {
1912                url: "u".to_string(),
1913                project_id: "p".to_string(),
1914            }),
1915            clickup: Some(ClickUpConfig {
1916                list_id: "l".to_string(),
1917                team_id: None,
1918            }),
1919            jira: Some(JiraConfig {
1920                url: "u".to_string(),
1921                project_key: "k".to_string(),
1922                email: "e".to_string(),
1923            }),
1924            fireflies: None,
1925            confluence: None,
1926            slack: None,
1927            contexts: BTreeMap::new(),
1928            active_context: None,
1929            proxy_mcp_servers: Vec::new(),
1930            builtin_tools: BuiltinToolsConfig::default(),
1931            format_pipeline: None,
1932            proxy: ProxyConfig::default(),
1933            sentry: None,
1934            remote_config: None,
1935        };
1936
1937        let providers = config.configured_providers();
1938        assert_eq!(providers.len(), 4);
1939        assert!(providers.contains(&"github"));
1940        assert!(providers.contains(&"gitlab"));
1941        assert!(providers.contains(&"clickup"));
1942        assert!(providers.contains(&"jira"));
1943        assert!(config.has_any_provider());
1944    }
1945
1946    #[test]
1947    fn test_config_dir() {
1948        // config_dir() should return a path ending with CONFIG_DIR_NAME
1949        let dir = Config::config_dir().unwrap();
1950        assert!(dir.ends_with("devboy-tools"));
1951    }
1952
1953    #[test]
1954    fn test_config_path() {
1955        // config_path() should return config_dir/config.toml
1956        let path = Config::config_path().unwrap();
1957        assert!(path.ends_with("config.toml"));
1958        assert!(path.parent().unwrap().ends_with("devboy-tools"));
1959    }
1960
1961    #[test]
1962    fn test_load_default_path() {
1963        // Use a temp path so the test is isolated from the real user/system config
1964        let dir = tempfile::tempdir().unwrap();
1965        let path = dir.path().join("config.toml");
1966        // load_from() should return a default config if the file doesn't exist
1967        let config = Config::load_from(&path).unwrap();
1968        assert!(!config.has_any_provider());
1969    }
1970
1971    #[test]
1972    fn test_save_default_path() {
1973        // Test save() to an actual temp location by using save_to
1974        let dir = tempfile::tempdir().unwrap();
1975        let path = dir.path().join("config.toml");
1976
1977        let config = Config {
1978            github: Some(GitHubConfig {
1979                owner: "test".to_string(),
1980                repo: "repo".to_string(),
1981                base_url: None,
1982            }),
1983            ..Default::default()
1984        };
1985
1986        config.save_to(&path).unwrap();
1987        assert!(path.exists());
1988
1989        // Reload and verify
1990        let loaded = Config::load_from(&path).unwrap();
1991        assert_eq!(loaded.github.unwrap().owner, "test");
1992    }
1993
1994    #[test]
1995    fn test_toml_serialization() {
1996        let config = Config {
1997            github: Some(GitHubConfig {
1998                owner: "owner".to_string(),
1999                repo: "repo".to_string(),
2000                base_url: Some("https://github.example.com".to_string()),
2001            }),
2002            gitlab: Some(GitLabConfig {
2003                url: "https://gitlab.example.com".to_string(),
2004                project_id: "123".to_string(),
2005            }),
2006            clickup: None,
2007            jira: None,
2008            fireflies: None,
2009            confluence: None,
2010            slack: None,
2011            contexts: BTreeMap::new(),
2012            active_context: None,
2013            proxy_mcp_servers: Vec::new(),
2014            builtin_tools: BuiltinToolsConfig::default(),
2015            format_pipeline: None,
2016            proxy: ProxyConfig::default(),
2017            sentry: None,
2018            remote_config: None,
2019        };
2020
2021        let toml_str = toml::to_string_pretty(&config).unwrap();
2022        assert!(toml_str.contains("[github]"));
2023        assert!(toml_str.contains("[gitlab]"));
2024        assert!(!toml_str.contains("[clickup]"));
2025        assert!(!toml_str.contains("[jira]"));
2026
2027        // Parse back
2028        let parsed: Config = toml::from_str(&toml_str).unwrap();
2029        assert!(parsed.github.is_some());
2030        assert!(parsed.gitlab.is_some());
2031    }
2032
2033    #[test]
2034    fn test_contexts_and_active_context() {
2035        let mut config = Config::default();
2036        config.contexts.insert(
2037            "dashboard".to_string(),
2038            ContextConfig {
2039                github: Some(GitHubConfig {
2040                    owner: "meteora-pro".to_string(),
2041                    repo: "my-project".to_string(),
2042                    base_url: None,
2043                }),
2044                clickup: Some(ClickUpConfig {
2045                    list_id: "abc123".to_string(),
2046                    team_id: None,
2047                }),
2048                ..Default::default()
2049            },
2050        );
2051
2052        let names = config.context_names();
2053        assert_eq!(names, vec!["dashboard".to_string()]);
2054
2055        config.set_active_context("dashboard").unwrap();
2056        assert_eq!(
2057            config.resolve_active_context_name(),
2058            Some("dashboard".to_string())
2059        );
2060    }
2061
2062    #[test]
2063    fn test_context_names_include_legacy_default() {
2064        let mut config = Config {
2065            github: Some(GitHubConfig {
2066                owner: "legacy-owner".to_string(),
2067                repo: "legacy-repo".to_string(),
2068                base_url: None,
2069            }),
2070            ..Default::default()
2071        };
2072        config
2073            .contexts
2074            .insert("workspace".to_string(), ContextConfig::default());
2075
2076        assert_eq!(
2077            config.context_names(),
2078            vec!["default".to_string(), "workspace".to_string()]
2079        );
2080    }
2081
2082    #[test]
2083    fn test_get_context_prefers_explicit_default_over_legacy() {
2084        let mut config = Config {
2085            github: Some(GitHubConfig {
2086                owner: "legacy-owner".to_string(),
2087                repo: "legacy-repo".to_string(),
2088                base_url: None,
2089            }),
2090            ..Default::default()
2091        };
2092        config.contexts.insert(
2093            Config::DEFAULT_CONTEXT_NAME.to_string(),
2094            ContextConfig {
2095                github: Some(GitHubConfig {
2096                    owner: "explicit-owner".to_string(),
2097                    repo: "explicit-repo".to_string(),
2098                    base_url: None,
2099                }),
2100                ..Default::default()
2101            },
2102        );
2103
2104        let default_ctx = config.get_context(Config::DEFAULT_CONTEXT_NAME).unwrap();
2105        let gh = default_ctx.github.unwrap();
2106        assert_eq!(gh.owner, "explicit-owner");
2107        assert_eq!(gh.repo, "explicit-repo");
2108    }
2109
2110    #[test]
2111    fn test_resolve_active_context_fallbacks() {
2112        let mut config = Config {
2113            active_context: Some("missing".to_string()),
2114            github: Some(GitHubConfig {
2115                owner: "legacy-owner".to_string(),
2116                repo: "legacy-repo".to_string(),
2117                base_url: None,
2118            }),
2119            ..Default::default()
2120        };
2121        config
2122            .contexts
2123            .insert("beta".to_string(), ContextConfig::default());
2124        config
2125            .contexts
2126            .insert("alpha".to_string(), ContextConfig::default());
2127
2128        assert_eq!(
2129            config.resolve_active_context_name(),
2130            Some("default".to_string())
2131        );
2132
2133        config.github = None;
2134        assert_eq!(
2135            config.resolve_active_context_name(),
2136            Some("alpha".to_string())
2137        );
2138    }
2139
2140    #[test]
2141    fn test_set_active_context_unknown_context_errors() {
2142        let mut config = Config::default();
2143        let result = config.set_active_context("missing");
2144        assert!(result.is_err());
2145        assert!(result.unwrap_err().to_string().contains("Unknown context"));
2146    }
2147
2148    #[test]
2149    fn test_context_config_configured_providers() {
2150        let context = ContextConfig {
2151            github: Some(GitHubConfig {
2152                owner: "owner".to_string(),
2153                repo: "repo".to_string(),
2154                base_url: None,
2155            }),
2156            jira: Some(JiraConfig {
2157                url: "https://jira.example.com".to_string(),
2158                project_key: "DEV".to_string(),
2159                email: "dev@example.com".to_string(),
2160            }),
2161            ..Default::default()
2162        };
2163
2164        let providers = context.configured_providers();
2165        assert_eq!(providers, vec!["github", "jira"]);
2166        assert!(context.has_any_provider());
2167    }
2168
2169    // =========================================================================
2170    // ProxyMcpServerConfig tests
2171    // =========================================================================
2172
2173    #[test]
2174    fn test_proxy_mcp_server_config_defaults() {
2175        let toml_str = r#"
2176            [[proxy_mcp_servers]]
2177            name = "my-server"
2178            url = "https://example.com/mcp"
2179        "#;
2180
2181        let config: Config = toml::from_str(toml_str).unwrap();
2182        assert_eq!(config.proxy_mcp_servers.len(), 1);
2183
2184        let proxy = &config.proxy_mcp_servers[0];
2185        assert_eq!(proxy.name, "my-server");
2186        assert_eq!(proxy.url, "https://example.com/mcp");
2187        assert_eq!(proxy.auth_type, "none");
2188        assert_eq!(proxy.transport, "sse");
2189        assert!(proxy.token_key.is_none());
2190        assert!(proxy.tool_prefix.is_none());
2191    }
2192
2193    #[test]
2194    fn test_proxy_mcp_server_config_full() {
2195        let toml_str = r#"
2196            [[proxy_mcp_servers]]
2197            name = "devboy-cloud"
2198            url = "https://app.devboy.pro/api/mcp"
2199            auth_type = "bearer"
2200            token_key = "devboy-cloud.token"
2201            tool_prefix = "cloud"
2202            transport = "streamable-http"
2203        "#;
2204
2205        let config: Config = toml::from_str(toml_str).unwrap();
2206        let proxy = &config.proxy_mcp_servers[0];
2207
2208        assert_eq!(proxy.name, "devboy-cloud");
2209        assert_eq!(proxy.auth_type, "bearer");
2210        assert_eq!(proxy.token_key.as_deref(), Some("devboy-cloud.token"));
2211        assert_eq!(proxy.tool_prefix.as_deref(), Some("cloud"));
2212        assert_eq!(proxy.transport, "streamable-http");
2213    }
2214
2215    #[test]
2216    fn test_proxy_mcp_server_config_multiple() {
2217        let toml_str = r#"
2218            [[proxy_mcp_servers]]
2219            name = "server1"
2220            url = "https://s1.example.com/mcp"
2221
2222            [[proxy_mcp_servers]]
2223            name = "server2"
2224            url = "https://s2.example.com/mcp"
2225            auth_type = "api_key"
2226            token_key = "s2.token"
2227        "#;
2228
2229        let config: Config = toml::from_str(toml_str).unwrap();
2230        assert_eq!(config.proxy_mcp_servers.len(), 2);
2231        assert_eq!(config.proxy_mcp_servers[0].name, "server1");
2232        assert_eq!(config.proxy_mcp_servers[1].name, "server2");
2233        assert_eq!(config.proxy_mcp_servers[1].auth_type, "api_key");
2234    }
2235
2236    #[test]
2237    fn test_proxy_mcp_server_config_serialization_roundtrip() {
2238        let config = Config {
2239            proxy_mcp_servers: vec![ProxyMcpServerConfig {
2240                name: "test".to_string(),
2241                url: "https://test.com/mcp".to_string(),
2242                auth_type: "bearer".to_string(),
2243                token_key: Some("test.token".to_string()),
2244                tool_prefix: Some("tst".to_string()),
2245                transport: "streamable-http".to_string(),
2246                routing: None,
2247            }],
2248            ..Default::default()
2249        };
2250
2251        let toml_str = toml::to_string_pretty(&config).unwrap();
2252        assert!(toml_str.contains("[[proxy_mcp_servers]]"));
2253        assert!(toml_str.contains("name = \"test\""));
2254
2255        let parsed: Config = toml::from_str(&toml_str).unwrap();
2256        assert_eq!(parsed.proxy_mcp_servers.len(), 1);
2257        assert_eq!(parsed.proxy_mcp_servers[0].name, "test");
2258        assert_eq!(parsed.proxy_mcp_servers[0].transport, "streamable-http");
2259    }
2260
2261    #[test]
2262    fn test_proxy_mcp_server_config_skips_none_fields_in_serialization() {
2263        let config = Config {
2264            proxy_mcp_servers: vec![ProxyMcpServerConfig {
2265                name: "minimal".to_string(),
2266                url: "https://test.com/mcp".to_string(),
2267                auth_type: "none".to_string(),
2268                token_key: None,
2269                tool_prefix: None,
2270                transport: "sse".to_string(),
2271                routing: None,
2272            }],
2273            ..Default::default()
2274        };
2275
2276        let toml_str = toml::to_string_pretty(&config).unwrap();
2277        assert!(!toml_str.contains("token_key"));
2278        assert!(!toml_str.contains("tool_prefix"));
2279    }
2280
2281    #[test]
2282    fn test_empty_proxy_mcp_servers_not_serialized() {
2283        let config = Config::default();
2284        let toml_str = toml::to_string_pretty(&config).unwrap();
2285        assert!(!toml_str.contains("proxy_mcp_servers"));
2286    }
2287
2288    // =========================================================================
2289    // ProxyConfig (routing, secrets, telemetry) tests
2290    // =========================================================================
2291
2292    #[test]
2293    fn test_proxy_config_default_is_default() {
2294        let cfg = ProxyConfig::default();
2295        assert!(cfg.is_default());
2296    }
2297
2298    #[test]
2299    fn test_default_proxy_section_not_serialized() {
2300        let config = Config::default();
2301        let toml_str = toml::to_string_pretty(&config).unwrap();
2302        assert!(!toml_str.contains("[proxy]"));
2303        assert!(!toml_str.contains("[proxy.routing]"));
2304    }
2305
2306    #[test]
2307    fn test_routing_strategy_default_is_remote() {
2308        let strategy = RoutingStrategy::default();
2309        assert_eq!(strategy, RoutingStrategy::Remote);
2310    }
2311
2312    #[test]
2313    fn test_routing_strategy_parse_tolerates_formats() {
2314        assert_eq!(
2315            RoutingStrategy::parse("remote"),
2316            Some(RoutingStrategy::Remote)
2317        );
2318        assert_eq!(
2319            RoutingStrategy::parse(" REMOTE "),
2320            Some(RoutingStrategy::Remote)
2321        );
2322        assert_eq!(
2323            RoutingStrategy::parse("local"),
2324            Some(RoutingStrategy::Local)
2325        );
2326        assert_eq!(
2327            RoutingStrategy::parse("local-first"),
2328            Some(RoutingStrategy::LocalFirst)
2329        );
2330        assert_eq!(
2331            RoutingStrategy::parse("local_first"),
2332            Some(RoutingStrategy::LocalFirst)
2333        );
2334        assert_eq!(
2335            RoutingStrategy::parse("remote-first"),
2336            Some(RoutingStrategy::RemoteFirst)
2337        );
2338        assert_eq!(RoutingStrategy::parse("unknown"), None);
2339    }
2340
2341    #[test]
2342    fn test_routing_strategy_serde_kebab_case() {
2343        let toml_str = r#"
2344            [proxy.routing]
2345            strategy = "local-first"
2346        "#;
2347        let config: Config = toml::from_str(toml_str).unwrap();
2348        assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2349
2350        // Round-trip
2351        let serialized = toml::to_string_pretty(&config).unwrap();
2352        assert!(serialized.contains("strategy = \"local-first\""));
2353    }
2354
2355    #[test]
2356    fn test_proxy_routing_strategy_for_picks_first_matching_override() {
2357        let routing = ProxyRoutingConfig {
2358            strategy: RoutingStrategy::Remote,
2359            fallback_on_error: true,
2360            tool_overrides: vec![
2361                ProxyToolRule {
2362                    pattern: "create_*".to_string(),
2363                    strategy: RoutingStrategy::Remote,
2364                },
2365                ProxyToolRule {
2366                    pattern: "get_*".to_string(),
2367                    strategy: RoutingStrategy::LocalFirst,
2368                },
2369                ProxyToolRule {
2370                    pattern: "*".to_string(),
2371                    strategy: RoutingStrategy::Local,
2372                },
2373            ],
2374        };
2375
2376        assert_eq!(
2377            routing.strategy_for("create_issue"),
2378            RoutingStrategy::Remote
2379        );
2380        assert_eq!(
2381            routing.strategy_for("get_issues"),
2382            RoutingStrategy::LocalFirst
2383        );
2384        assert_eq!(
2385            routing.strategy_for("anything_else"),
2386            RoutingStrategy::Local
2387        );
2388    }
2389
2390    #[test]
2391    fn test_proxy_routing_strategy_for_falls_back_to_global() {
2392        let routing = ProxyRoutingConfig {
2393            strategy: RoutingStrategy::Remote,
2394            fallback_on_error: true,
2395            tool_overrides: vec![ProxyToolRule {
2396                pattern: "get_*".to_string(),
2397                strategy: RoutingStrategy::LocalFirst,
2398            }],
2399        };
2400
2401        assert_eq!(
2402            routing.strategy_for("unrelated_tool"),
2403            RoutingStrategy::Remote
2404        );
2405    }
2406
2407    #[test]
2408    fn test_proxy_routing_merged_with_override_wins() {
2409        let global = ProxyRoutingConfig {
2410            strategy: RoutingStrategy::Remote,
2411            fallback_on_error: true,
2412            tool_overrides: vec![ProxyToolRule {
2413                pattern: "get_*".to_string(),
2414                strategy: RoutingStrategy::LocalFirst,
2415            }],
2416        };
2417        let override_cfg = ProxyRoutingOverride {
2418            strategy: Some(RoutingStrategy::Local),
2419            fallback_on_error: Some(false),
2420            tool_overrides: Some(vec![ProxyToolRule {
2421                pattern: "create_*".to_string(),
2422                strategy: RoutingStrategy::Remote,
2423            }]),
2424        };
2425
2426        let merged = global.merged_with(Some(&override_cfg));
2427        assert_eq!(merged.strategy, RoutingStrategy::Local);
2428        assert!(!merged.fallback_on_error);
2429        // override tool_overrides come first, global rules append
2430        assert_eq!(merged.tool_overrides.len(), 2);
2431        assert_eq!(merged.tool_overrides[0].pattern, "create_*");
2432        assert_eq!(merged.tool_overrides[1].pattern, "get_*");
2433    }
2434
2435    #[test]
2436    fn test_proxy_routing_merged_with_partial_override_preserves_unset_fields() {
2437        // Reviewer concern: "a per-server block that only sets strategy must not reset
2438        // fallback_on_error / tool_overrides to defaults."
2439        let global = ProxyRoutingConfig {
2440            strategy: RoutingStrategy::Remote,
2441            fallback_on_error: false, // deliberately non-default
2442            tool_overrides: vec![ProxyToolRule {
2443                pattern: "get_*".to_string(),
2444                strategy: RoutingStrategy::LocalFirst,
2445            }],
2446        };
2447        // Override only tweaks `strategy`; everything else must inherit from global.
2448        let override_cfg = ProxyRoutingOverride {
2449            strategy: Some(RoutingStrategy::Local),
2450            fallback_on_error: None,
2451            tool_overrides: None,
2452        };
2453
2454        let merged = global.merged_with(Some(&override_cfg));
2455        assert_eq!(merged.strategy, RoutingStrategy::Local);
2456        assert!(
2457            !merged.fallback_on_error,
2458            "fallback_on_error must inherit from global, not snap to default"
2459        );
2460        assert_eq!(
2461            merged.tool_overrides.len(),
2462            1,
2463            "tool_overrides must inherit from global when override omits them"
2464        );
2465        assert_eq!(merged.tool_overrides[0].pattern, "get_*");
2466    }
2467
2468    #[test]
2469    fn test_proxy_routing_merged_with_none_returns_clone() {
2470        let global = ProxyRoutingConfig {
2471            strategy: RoutingStrategy::LocalFirst,
2472            ..Default::default()
2473        };
2474        let merged = global.merged_with(None);
2475        assert_eq!(merged.strategy, RoutingStrategy::LocalFirst);
2476    }
2477
2478    #[test]
2479    fn test_proxy_secrets_default_cache_ttl() {
2480        let s = ProxySecretsConfig::default();
2481        assert_eq!(s.cache_ttl_secs, 300);
2482        assert!(s.is_default());
2483    }
2484
2485    #[test]
2486    fn test_proxy_telemetry_defaults() {
2487        let t = ProxyTelemetryConfig::default();
2488        assert!(t.enabled);
2489        assert_eq!(t.batch_size, 100);
2490        assert_eq!(t.batch_interval_secs, 30);
2491        assert!(t.endpoint.is_none());
2492        assert!(t.is_default());
2493    }
2494
2495    #[test]
2496    fn test_proxy_toml_parse_full() {
2497        let toml_str = r#"
2498            [proxy.routing]
2499            strategy = "local-first"
2500            fallback_on_error = false
2501
2502            [[proxy.routing.tool_overrides]]
2503            pattern = "create_*"
2504            strategy = "remote"
2505
2506            [proxy.secrets]
2507            cache_ttl_secs = 120
2508
2509            [proxy.telemetry]
2510            enabled = true
2511            batch_size = 50
2512            batch_interval_secs = 10
2513            endpoint = "https://telemetry.example.com/api/events"
2514        "#;
2515
2516        let config: Config = toml::from_str(toml_str).unwrap();
2517        assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2518        assert!(!config.proxy.routing.fallback_on_error);
2519        assert_eq!(config.proxy.routing.tool_overrides.len(), 1);
2520        assert_eq!(config.proxy.secrets.cache_ttl_secs, 120);
2521        assert_eq!(config.proxy.telemetry.batch_size, 50);
2522        assert_eq!(
2523            config.proxy.telemetry.endpoint.as_deref(),
2524            Some("https://telemetry.example.com/api/events")
2525        );
2526    }
2527
2528    #[test]
2529    fn test_proxy_mcp_server_per_server_routing_override() {
2530        let toml_str = r#"
2531            [[proxy_mcp_servers]]
2532            name = "cloud"
2533            url = "https://api.example.com/mcp"
2534
2535            [proxy_mcp_servers.routing]
2536            strategy = "local-first"
2537        "#;
2538
2539        let config: Config = toml::from_str(toml_str).unwrap();
2540        let server = &config.proxy_mcp_servers[0];
2541        let override_cfg = server.routing.as_ref().expect("override present");
2542        // Only `strategy` was set — other fields must stay `None` so they inherit.
2543        assert_eq!(override_cfg.strategy, Some(RoutingStrategy::LocalFirst));
2544        assert!(override_cfg.fallback_on_error.is_none());
2545        assert!(override_cfg.tool_overrides.is_none());
2546    }
2547
2548    // =========================================================================
2549    // Config::set / Config::get for `proxy.*` paths
2550    // =========================================================================
2551
2552    #[test]
2553    fn test_set_get_proxy_routing_strategy_roundtrip() {
2554        let mut cfg = Config::default();
2555        cfg.set("proxy.routing.strategy", "local-first").unwrap();
2556        assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2557        assert_eq!(
2558            cfg.get("proxy.routing.strategy").unwrap().as_deref(),
2559            Some("local-first")
2560        );
2561
2562        cfg.set("proxy.routing.strategy", "remote").unwrap();
2563        assert_eq!(
2564            cfg.get("proxy.routing.strategy").unwrap().as_deref(),
2565            Some("remote")
2566        );
2567    }
2568
2569    #[test]
2570    fn test_set_proxy_routing_strategy_rejects_garbage() {
2571        let mut cfg = Config::default();
2572        let err = cfg
2573            .set("proxy.routing.strategy", "teleport")
2574            .unwrap_err()
2575            .to_string();
2576        assert!(err.contains("Invalid routing strategy"));
2577    }
2578
2579    #[test]
2580    fn test_set_proxy_routing_booleans_accept_many_forms() {
2581        let mut cfg = Config::default();
2582        for truthy in ["true", "TRUE", "1", "yes", "on"] {
2583            cfg.set("proxy.routing.fallback_on_error", truthy).unwrap();
2584            assert!(cfg.proxy.routing.fallback_on_error);
2585        }
2586        for falsy in ["false", "0", "no", "off"] {
2587            cfg.set("proxy.routing.fallback_on_error", falsy).unwrap();
2588            assert!(!cfg.proxy.routing.fallback_on_error);
2589        }
2590    }
2591
2592    #[test]
2593    fn test_set_proxy_secrets_cache_ttl() {
2594        let mut cfg = Config::default();
2595        cfg.set("proxy.secrets.cache_ttl_secs", "120").unwrap();
2596        assert_eq!(cfg.proxy.secrets.cache_ttl_secs, 120);
2597        assert_eq!(
2598            cfg.get("proxy.secrets.cache_ttl_secs").unwrap().as_deref(),
2599            Some("120")
2600        );
2601
2602        assert!(cfg.set("proxy.secrets.cache_ttl_secs", "-5").is_err());
2603    }
2604
2605    #[test]
2606    fn test_set_proxy_telemetry_endpoint_and_clear() {
2607        let mut cfg = Config::default();
2608        cfg.set("proxy.telemetry.endpoint", "https://example.com/t")
2609            .unwrap();
2610        assert_eq!(
2611            cfg.proxy.telemetry.endpoint.as_deref(),
2612            Some("https://example.com/t")
2613        );
2614
2615        // Empty string clears the field — symmetric with how serde skips it.
2616        cfg.set("proxy.telemetry.endpoint", "").unwrap();
2617        assert!(cfg.proxy.telemetry.endpoint.is_none());
2618    }
2619
2620    #[test]
2621    fn test_set_proxy_telemetry_endpoint_rejects_garbage() {
2622        let mut cfg = Config::default();
2623        for bad in [
2624            "not-a-url",
2625            "ftp://host.example.com",
2626            "//example.com",
2627            "https://",
2628            "http:// space.example.com",
2629            // whitespace anywhere — path, query, trailing — must be rejected too
2630            "https://example.com/a b",
2631            "https://example.com/path?key=a b",
2632            "https://example.com/\tpath",
2633            "https://example.com/ ",
2634        ] {
2635            match cfg.set("proxy.telemetry.endpoint", bad) {
2636                Ok(()) => panic!("expected reject for {}", bad),
2637                Err(e) => assert!(
2638                    e.to_string().contains("Invalid URL"),
2639                    "bad={}, err={}",
2640                    bad,
2641                    e
2642                ),
2643            }
2644        }
2645    }
2646
2647    #[test]
2648    fn test_set_proxy_telemetry_endpoint_accepts_common_forms() {
2649        let mut cfg = Config::default();
2650        for good in [
2651            "https://app.example.com/api/telemetry/tool-invocations",
2652            "http://localhost:4335/api/telemetry/tool-invocations",
2653            "https://example.com",
2654            "http://10.0.0.1:8080/",
2655        ] {
2656            cfg.set("proxy.telemetry.endpoint", good)
2657                .unwrap_or_else(|e| panic!("expected accept for {}: {}", good, e));
2658        }
2659    }
2660
2661    // =========================================================================
2662    // Config::validate() — run-time checks applied on load_from() too
2663    // =========================================================================
2664
2665    #[test]
2666    fn test_validate_rejects_bad_endpoint_from_toml() {
2667        // A user hand-editing TOML can sneak invalid endpoints past `set()`; ensure
2668        // `Config::load_from` (via `validate()`) still catches them.
2669        let toml_str = r#"
2670            [proxy.telemetry]
2671            endpoint = "not-a-url"
2672        "#;
2673        let config: Config = toml::from_str(toml_str).unwrap();
2674        let err = config
2675            .validate()
2676            .expect_err("expected validation to fail for 'not-a-url'");
2677        assert!(
2678            err.to_string().contains("Invalid URL"),
2679            "unexpected error: {}",
2680            err
2681        );
2682    }
2683
2684    #[test]
2685    fn test_validate_accepts_empty_endpoint_as_absent() {
2686        // Current TOML serde path keeps `endpoint = None` when the field is skipped.
2687        // Validation must not fail in this common case.
2688        let config = Config::default();
2689        config.validate().expect("default config validates");
2690    }
2691
2692    #[test]
2693    fn test_sanitize_normalizes_empty_endpoint_to_none() {
2694        // Hand-edited TOML may set `endpoint = ""`; serde keeps it as Some("").
2695        // `sanitize` must collapse it to None so it stops short-circuiting validation
2696        // and later tricking the telemetry pipeline into using an invalid URL.
2697        let mut config: Config = toml::from_str(
2698            r#"
2699[proxy.telemetry]
2700endpoint = ""
2701"#,
2702        )
2703        .unwrap();
2704        assert_eq!(config.proxy.telemetry.endpoint.as_deref(), Some(""));
2705        config.sanitize();
2706        assert!(config.proxy.telemetry.endpoint.is_none());
2707        config.validate().expect("sanitized config must validate");
2708    }
2709
2710    #[test]
2711    fn test_load_from_sanitizes_empty_endpoint() {
2712        use std::fs::write;
2713        let dir = tempfile::tempdir().unwrap();
2714        let path = dir.path().join("config.toml");
2715        write(
2716            &path,
2717            r#"
2718[proxy.telemetry]
2719endpoint = ""
2720"#,
2721        )
2722        .unwrap();
2723
2724        let cfg = Config::load_from(&path).expect("empty endpoint must be normalised on load");
2725        assert!(
2726            cfg.proxy.telemetry.endpoint.is_none(),
2727            "empty string must load as None, not Some(\"\")"
2728        );
2729    }
2730
2731    #[test]
2732    fn test_validate_rejects_naked_empty_string_endpoint() {
2733        // Skip sanitize: a caller that set the value manually must see the bad-URL
2734        // error rather than silent acceptance.
2735        let mut config = Config::default();
2736        config.proxy.telemetry.endpoint = Some(String::new());
2737        let err = config
2738            .validate()
2739            .expect_err("empty string must be rejected if caller skipped sanitize");
2740        assert!(
2741            err.to_string().contains("Invalid URL"),
2742            "unexpected error: {}",
2743            err
2744        );
2745    }
2746
2747    #[test]
2748    fn test_load_from_runs_validation() {
2749        use std::fs::write;
2750        let dir = tempfile::tempdir().unwrap();
2751        let path = dir.path().join("config.toml");
2752        write(
2753            &path,
2754            r#"
2755[proxy.telemetry]
2756endpoint = "ftp://wrong-scheme.example.com"
2757"#,
2758        )
2759        .unwrap();
2760
2761        let err = Config::load_from(&path).expect_err("must reject bad URL from file");
2762        assert!(
2763            err.to_string().contains("Invalid URL"),
2764            "unexpected error: {}",
2765            err
2766        );
2767    }
2768
2769    // =========================================================================
2770    // deny_unknown_fields — typos surface on load, not silently default away
2771    // =========================================================================
2772
2773    #[test]
2774    fn test_unknown_field_in_proxy_routing_rejected() {
2775        let toml_str = r#"
2776            [proxy.routing]
2777            strategy = "local-first"
2778            startegy = "typo"
2779        "#;
2780        let err = toml::from_str::<Config>(toml_str)
2781            .expect_err("expected parse error for typo 'startegy'");
2782        let msg = err.to_string();
2783        assert!(
2784            msg.contains("startegy") || msg.contains("unknown field"),
2785            "unexpected error: {}",
2786            msg
2787        );
2788    }
2789
2790    #[test]
2791    fn test_unknown_field_in_proxy_secrets_rejected() {
2792        let toml_str = r#"
2793            [proxy.secrets]
2794            cache_ttl_secs = 60
2795            chache_ttl_secs = 120
2796        "#;
2797        let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
2798        assert!(
2799            err.to_string().contains("chache_ttl_secs")
2800                || err.to_string().contains("unknown field")
2801        );
2802    }
2803
2804    #[test]
2805    fn test_unknown_field_in_proxy_telemetry_rejected() {
2806        let toml_str = r#"
2807            [proxy.telemetry]
2808            enabled = true
2809            endpooint = "https://example.com"
2810        "#;
2811        let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
2812        assert!(err.to_string().contains("endpooint") || err.to_string().contains("unknown field"));
2813    }
2814
2815    #[test]
2816    fn test_unknown_field_in_tool_override_rejected() {
2817        let toml_str = r#"
2818            [[proxy.routing.tool_overrides]]
2819            pattern = "get_*"
2820            strategy = "local"
2821            unknown = 1
2822        "#;
2823        let err = toml::from_str::<Config>(toml_str).expect_err("typo in rule must fail");
2824        assert!(err.to_string().contains("unknown"));
2825    }
2826
2827    #[test]
2828    fn test_unknown_top_level_proxy_section_rejected() {
2829        // E.g. user writes [proxy.typo] — we want this to fail, not silently ignore.
2830        let toml_str = r#"
2831            [proxy.typo]
2832            foo = 1
2833        "#;
2834        let err = toml::from_str::<Config>(toml_str).expect_err("unknown section must fail");
2835        let msg = err.to_string();
2836        assert!(msg.contains("typo") || msg.contains("unknown field"));
2837    }
2838
2839    #[test]
2840    fn test_load_from_accepts_valid_proxy_config() {
2841        use std::fs::write;
2842        let dir = tempfile::tempdir().unwrap();
2843        let path = dir.path().join("config.toml");
2844        write(
2845            &path,
2846            r#"
2847[proxy.routing]
2848strategy = "local-first"
2849
2850[proxy.telemetry]
2851endpoint = "https://app.example.com/api/telemetry/tool-invocations"
2852"#,
2853        )
2854        .unwrap();
2855
2856        let cfg = Config::load_from(&path).expect("valid config must load");
2857        assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2858        assert_eq!(
2859            cfg.proxy.telemetry.endpoint.as_deref(),
2860            Some("https://app.example.com/api/telemetry/tool-invocations")
2861        );
2862    }
2863
2864    #[test]
2865    fn test_set_proxy_telemetry_batch_fields() {
2866        let mut cfg = Config::default();
2867        cfg.set("proxy.telemetry.batch_size", "50").unwrap();
2868        cfg.set("proxy.telemetry.batch_interval_secs", "15")
2869            .unwrap();
2870        cfg.set("proxy.telemetry.offline_queue_max", "2000")
2871            .unwrap();
2872
2873        assert_eq!(cfg.proxy.telemetry.batch_size, 50);
2874        assert_eq!(cfg.proxy.telemetry.batch_interval_secs, 15);
2875        assert_eq!(cfg.proxy.telemetry.offline_queue_max, 2000);
2876    }
2877
2878    #[test]
2879    fn test_unknown_proxy_section_or_field_errors() {
2880        let mut cfg = Config::default();
2881        assert!(cfg.set("proxy.unknown.foo", "1").is_err());
2882        assert!(cfg.set("proxy.routing.unknown", "1").is_err());
2883        assert!(cfg.get("proxy.unknown.foo").is_err());
2884        assert!(cfg.get("proxy.routing.unknown").is_err());
2885    }
2886
2887    #[test]
2888    fn test_four_part_key_rejected() {
2889        let mut cfg = Config::default();
2890        assert!(cfg.set("proxy.routing.strategy.extra", "local").is_err());
2891    }
2892
2893    // =========================================================================
2894    // Config: backward compat
2895    // =========================================================================
2896
2897    #[test]
2898    fn test_legacy_config_without_proxy_section_still_parses() {
2899        // A config written before this feature must keep deserializing cleanly.
2900        let toml_str = r#"
2901            [github]
2902            owner = "me"
2903            repo = "repo"
2904
2905            [[proxy_mcp_servers]]
2906            name = "cloud"
2907            url = "https://api.example.com/mcp"
2908        "#;
2909        let config: Config = toml::from_str(toml_str).unwrap();
2910        assert_eq!(config.github.unwrap().owner, "me");
2911        assert_eq!(config.proxy_mcp_servers.len(), 1);
2912        assert!(config.proxy.is_default());
2913    }
2914
2915    // =========================================================================
2916    // glob matcher tests
2917    // =========================================================================
2918
2919    #[test]
2920    fn test_matches_glob_exact() {
2921        assert!(matches_glob("get_issues", "get_issues"));
2922        assert!(!matches_glob("get_issues", "get_issue"));
2923        assert!(!matches_glob("get_issues", "gets_issues"));
2924    }
2925
2926    #[test]
2927    fn test_matches_glob_star_alone() {
2928        assert!(matches_glob("*", ""));
2929        assert!(matches_glob("*", "anything"));
2930        assert!(matches_glob("*", "create_merge_request"));
2931    }
2932
2933    #[test]
2934    fn test_matches_glob_prefix() {
2935        assert!(matches_glob("get_*", "get_issues"));
2936        assert!(matches_glob("get_*", "get_"));
2937        assert!(!matches_glob("get_*", "create_issues"));
2938    }
2939
2940    #[test]
2941    fn test_matches_glob_suffix() {
2942        assert!(matches_glob("*_issue", "create_issue"));
2943        assert!(matches_glob("*_issue", "_issue"));
2944        assert!(!matches_glob("*_issue", "create_issues"));
2945    }
2946
2947    #[test]
2948    fn test_matches_glob_contains() {
2949        assert!(matches_glob("*issue*", "get_issues"));
2950        assert!(matches_glob("*issue*", "issue"));
2951        assert!(!matches_glob("*issue*", "merge_request"));
2952    }
2953
2954    #[test]
2955    fn test_matches_glob_multiple_wildcards() {
2956        assert!(matches_glob("get_*_by_*", "get_issue_by_id"));
2957        assert!(matches_glob("get_*_by_*", "get_user_by_email"));
2958        assert!(!matches_glob("get_*_by_*", "get_issue"));
2959        assert!(!matches_glob("get_*_by_*", "create_issue_by_id"));
2960    }
2961
2962    #[test]
2963    fn test_matches_glob_collapses_double_star() {
2964        assert!(matches_glob("get_**_issue", "get_new_issue"));
2965    }
2966
2967    // =========================================================================
2968    // BuiltinToolsConfig tests
2969    // =========================================================================
2970
2971    #[test]
2972    fn test_builtin_tools_config_default_is_empty() {
2973        let config = BuiltinToolsConfig::default();
2974        assert!(config.is_empty());
2975        assert!(config.validate().is_ok());
2976        assert!(config.is_tool_allowed("get_issues"));
2977    }
2978
2979    #[test]
2980    fn test_builtin_tools_disabled_mode() {
2981        let config = BuiltinToolsConfig {
2982            disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
2983            enabled: vec![],
2984        };
2985        assert!(!config.is_empty());
2986        assert!(config.validate().is_ok());
2987        assert!(!config.is_tool_allowed("get_issues"));
2988        assert!(!config.is_tool_allowed("create_issue"));
2989        assert!(config.is_tool_allowed("get_merge_requests"));
2990        assert!(config.is_tool_allowed("list_contexts"));
2991    }
2992
2993    #[test]
2994    fn test_builtin_tools_enabled_mode() {
2995        let config = BuiltinToolsConfig {
2996            disabled: vec![],
2997            enabled: vec![
2998                "list_contexts".to_string(),
2999                "use_context".to_string(),
3000                "get_current_context".to_string(),
3001            ],
3002        };
3003        assert!(!config.is_empty());
3004        assert!(config.validate().is_ok());
3005        assert!(config.is_tool_allowed("list_contexts"));
3006        assert!(config.is_tool_allowed("use_context"));
3007        assert!(!config.is_tool_allowed("get_issues"));
3008        assert!(!config.is_tool_allowed("create_issue"));
3009    }
3010
3011    #[test]
3012    fn test_builtin_tools_mutually_exclusive_error() {
3013        let config = BuiltinToolsConfig {
3014            disabled: vec!["get_issues".to_string()],
3015            enabled: vec!["list_contexts".to_string()],
3016        };
3017        assert!(config.validate().is_err());
3018        let err = config.validate().unwrap_err().to_string();
3019        assert!(err.contains("mutually exclusive"));
3020    }
3021
3022    #[test]
3023    fn test_builtin_tools_toml_parsing_disabled() {
3024        let toml_str = r#"
3025            [builtin_tools]
3026            disabled = ["get_issues", "create_issue"]
3027        "#;
3028        let config: Config = toml::from_str(toml_str).unwrap();
3029        assert!(!config.builtin_tools.is_empty());
3030        assert_eq!(config.builtin_tools.disabled.len(), 2);
3031        assert!(config.builtin_tools.enabled.is_empty());
3032    }
3033
3034    #[test]
3035    fn test_builtin_tools_toml_parsing_enabled() {
3036        let toml_str = r#"
3037            [builtin_tools]
3038            enabled = ["list_contexts", "use_context", "get_current_context"]
3039        "#;
3040        let config: Config = toml::from_str(toml_str).unwrap();
3041        assert_eq!(config.builtin_tools.enabled.len(), 3);
3042        assert!(config.builtin_tools.disabled.is_empty());
3043    }
3044
3045    #[test]
3046    fn test_builtin_tools_not_serialized_when_empty() {
3047        let config = Config::default();
3048        let toml_str = toml::to_string_pretty(&config).unwrap();
3049        assert!(!toml_str.contains("builtin_tools"));
3050    }
3051
3052    #[test]
3053    fn test_builtin_tools_serialization_roundtrip() {
3054        let config = Config {
3055            builtin_tools: BuiltinToolsConfig {
3056                disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
3057                enabled: vec![],
3058            },
3059            ..Default::default()
3060        };
3061        let toml_str = toml::to_string_pretty(&config).unwrap();
3062        assert!(toml_str.contains("[builtin_tools]"));
3063        assert!(toml_str.contains("get_issues"));
3064
3065        let parsed: Config = toml::from_str(&toml_str).unwrap();
3066        assert_eq!(parsed.builtin_tools.disabled.len(), 2);
3067    }
3068
3069    #[test]
3070    fn test_builtin_tools_warn_unknown_with_unknown_names() {
3071        let known = &["get_issues", "create_issue"];
3072        let config = BuiltinToolsConfig {
3073            disabled: vec!["get_issues".to_string(), "nonexistent_tool".to_string()],
3074            enabled: vec![],
3075        };
3076        // Should not panic, logs a warning for nonexistent_tool
3077        config.warn_unknown_tools(known);
3078    }
3079
3080    #[test]
3081    fn test_builtin_tools_warn_unknown_all_known() {
3082        let known = &["get_issues", "create_issue"];
3083        let config = BuiltinToolsConfig {
3084            disabled: vec!["get_issues".to_string()],
3085            enabled: vec![],
3086        };
3087        // All names are known — no warnings expected
3088        config.warn_unknown_tools(known);
3089    }
3090
3091    #[test]
3092    fn test_builtin_tools_warn_unknown_in_enabled_list() {
3093        let known = &["get_issues", "create_issue"];
3094        let config = BuiltinToolsConfig {
3095            disabled: vec![],
3096            enabled: vec!["get_issues".to_string(), "unknown_tool".to_string()],
3097        };
3098        // Verify that the enabled list is also checked
3099        config.warn_unknown_tools(known);
3100    }
3101
3102    #[test]
3103    fn test_builtin_tools_warn_unknown_empty_config() {
3104        let known = &["get_issues"];
3105        let config = BuiltinToolsConfig::default();
3106        // Empty config — nothing to check
3107        config.warn_unknown_tools(known);
3108    }
3109}