Skip to main content

devboy_core/
config.rs

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