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