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