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