1use 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
36const CONFIG_DIR_NAME: &str = "devboy-tools";
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct Config {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub github: Option<GitHubConfig>,
48
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub gitlab: Option<GitLabConfig>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub clickup: Option<ClickUpConfig>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub jira: Option<JiraConfig>,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub linear: Option<LinearConfig>,
60 pub yougile: Option<YouGileConfig>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub fireflies: Option<FirefliesConfig>,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub confluence: Option<ConfluenceConfig>,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub slack: Option<SlackConfig>,
73
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub telegram: Option<TelegramConfig>,
77
78 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
80 pub contexts: BTreeMap<String, ContextConfig>,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub active_context: Option<String>,
85
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub proxy_mcp_servers: Vec<ProxyMcpServerConfig>,
89
90 #[serde(default, skip_serializing_if = "BuiltinToolsConfig::is_empty")]
92 pub builtin_tools: BuiltinToolsConfig,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub format_pipeline: Option<FormatPipelineConfig>,
97
98 #[serde(default, skip_serializing_if = "ProxyConfig::is_default")]
101 pub proxy: ProxyConfig,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub sentry: Option<SentryConfig>,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub remote_config: Option<RemoteConfigSettings>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub secrets: Option<SecretsConfig>,
120}
121
122impl Config {
123 pub fn is_secrets_migration_complete(&self) -> bool {
127 self.secrets
128 .as_ref()
129 .map(|s| s.migration_complete)
130 .unwrap_or(false)
131 }
132}
133
134#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
140pub struct SecretsConfig {
141 #[serde(default)]
149 pub migration_complete: bool,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ProxyMcpServerConfig {
155 pub name: String,
157 pub url: String,
159 #[serde(default = "default_auth_none")]
161 pub auth_type: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub token_key: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub tool_prefix: Option<String>,
168 #[serde(default = "default_transport_sse")]
170 pub transport: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub routing: Option<ProxyRoutingOverride>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub oauth: Option<ProxyOAuthConfig>,
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
195pub struct ProxyOAuthConfig {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub client_id: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub scopes: Option<Vec<String>>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub authorization_server: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub token_endpoint: Option<String>,
210}
211
212fn default_transport_sse() -> String {
213 "sse".to_string()
214}
215
216fn default_auth_none() -> String {
217 "none".to_string()
218}
219
220fn default_linear_url() -> String {
221 "https://api.linear.app/graphql".to_string()
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226pub struct ContextConfig {
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub github: Option<GitHubConfig>,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub gitlab: Option<GitLabConfig>,
232
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub clickup: Option<ClickUpConfig>,
235
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub jira: Option<JiraConfig>,
238
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub linear: Option<LinearConfig>,
241 pub yougile: Option<YouGileConfig>,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub fireflies: Option<FirefliesConfig>,
246
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub confluence: Option<ConfluenceConfig>,
250
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub slack: Option<SlackConfig>,
254
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub telegram: Option<TelegramConfig>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct GitHubConfig {
262 pub owner: String,
264 pub repo: String,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub base_url: Option<String>,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct GitLabConfig {
272 #[serde(default = "default_gitlab_url")]
274 pub url: String,
275 pub project_id: String,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ClickUpConfig {
281 pub list_id: String,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub team_id: Option<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct JiraConfig {
289 pub url: String,
291 pub project_key: String,
293 pub email: String,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct LinearConfig {
299 #[serde(default = "default_linear_url")]
301 pub url: String,
302 pub team_id: String,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub team_key: Option<String>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct YouGileConfig {
312 #[serde(default = "default_yougile_url")]
314 pub url: String,
315 pub board_id: String,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct FirefliesConfig {
322 }
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct ConfluenceConfig {
328 pub base_url: String,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub flavor: Option<ConfluenceFlavor>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub cloud_id: Option<String>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub api_version: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub username: Option<String>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub client_id: Option<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub redirect_uri: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub space_key: Option<String>,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
354#[serde(rename_all = "snake_case")]
355pub enum ConfluenceFlavor {
356 SelfHosted,
357 Cloud,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct SlackConfig {
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub team_id: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub workspace: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub base_url: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub client_id: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub redirect_uri: Option<String>,
377 #[serde(
379 default = "default_slack_required_scopes",
380 skip_serializing_if = "is_default_slack_required_scopes"
381 )]
382 pub required_scopes: Vec<String>,
383}
384
385impl Default for SlackConfig {
386 fn default() -> Self {
387 Self {
388 team_id: None,
389 workspace: None,
390 base_url: None,
391 client_id: None,
392 redirect_uri: None,
393 required_scopes: default_slack_required_scopes(),
394 }
395 }
396}
397
398#[derive(Debug, Clone, Default, Serialize, Deserialize)]
400pub struct TelegramConfig {
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub base_url: Option<String>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub bot_username: Option<String>,
407}
408
409fn default_yougile_url() -> String {
410 "https://yougile.com/api-v2".to_string()
411}
412
413pub fn default_slack_required_scopes() -> Vec<String> {
414 vec![
415 "channels:read".to_string(),
416 "channels:history".to_string(),
417 "groups:read".to_string(),
418 "groups:history".to_string(),
419 "im:read".to_string(),
420 "im:history".to_string(),
421 "mpim:read".to_string(),
422 "mpim:history".to_string(),
423 "chat:write".to_string(),
424 "users:read".to_string(),
425 ]
426}
427
428fn parse_confluence_flavor(value: &str) -> Result<ConfluenceFlavor> {
429 match value.trim().to_ascii_lowercase().as_str() {
430 "self_hosted" | "self-hosted" | "selfhosted" | "server" | "dc" | "data_center"
431 | "data-center" => Ok(ConfluenceFlavor::SelfHosted),
432 "cloud" => Ok(ConfluenceFlavor::Cloud),
433 other => Err(Error::Config(format!(
434 "Unknown Confluence config field value for flavor: {}",
435 other
436 ))),
437 }
438}
439
440fn confluence_flavor_slug(flavor: ConfluenceFlavor) -> String {
441 match flavor {
442 ConfluenceFlavor::SelfHosted => "self_hosted".to_string(),
443 ConfluenceFlavor::Cloud => "cloud".to_string(),
444 }
445}
446
447fn is_default_slack_required_scopes(scopes: &[String]) -> bool {
448 scopes == default_slack_required_scopes().as_slice()
449}
450
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
457pub struct BuiltinToolsConfig {
458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
460 pub disabled: Vec<String>,
461
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
464 pub enabled: Vec<String>,
465}
466
467impl BuiltinToolsConfig {
468 pub fn is_empty(&self) -> bool {
470 self.disabled.is_empty() && self.enabled.is_empty()
471 }
472
473 pub fn validate(&self) -> Result<()> {
475 if !self.disabled.is_empty() && !self.enabled.is_empty() {
476 return Err(Error::Config(
477 "builtin_tools: 'disabled' and 'enabled' are mutually exclusive, use only one"
478 .to_string(),
479 ));
480 }
481 Ok(())
482 }
483
484 pub fn is_tool_allowed(&self, name: &str) -> bool {
486 if !self.enabled.is_empty() {
487 return self.enabled.iter().any(|n| n == name);
488 }
489 if !self.disabled.is_empty() {
490 return !self.disabled.iter().any(|n| n == name);
491 }
492 true
493 }
494
495 pub fn warn_unknown_tools(&self, known: &[&str]) {
497 for name in self.disabled.iter().chain(self.enabled.iter()) {
498 if !known.iter().any(|k| k == name) {
499 tracing::warn!(
500 "builtin_tools: unknown tool name '{}', it will have no effect",
501 name
502 );
503 }
504 }
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct FormatPipelineConfig {
534 #[serde(default = "default_budget_tokens")]
537 pub budget_tokens: usize,
538
539 #[serde(default = "default_margin")]
542 pub margin: f64,
543
544 #[serde(default = "default_max_iterations")]
547 pub max_iterations: usize,
548
549 #[serde(default = "default_format_toon")]
551 pub default_format: String,
552
553 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
558 pub strategies: HashMap<String, String>,
559
560 #[serde(default)]
561 pub proxy_matching: ProxyMatchingConfig,
562}
563
564impl Default for FormatPipelineConfig {
565 fn default() -> Self {
566 Self {
567 budget_tokens: default_budget_tokens(),
568 margin: default_margin(),
569 max_iterations: default_max_iterations(),
570 default_format: default_format_toon(),
571 strategies: HashMap::new(),
572 proxy_matching: ProxyMatchingConfig::default(),
573 }
574 }
575}
576
577fn default_budget_tokens() -> usize {
578 8000
579}
580
581fn default_margin() -> f64 {
582 0.20
583}
584
585fn default_max_iterations() -> usize {
586 3
587}
588
589fn default_format_toon() -> String {
590 "toon".to_string()
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct ProxyMatchingConfig {
595 #[serde(default = "default_true")]
598 pub enabled: bool,
599}
600
601impl Default for ProxyMatchingConfig {
602 fn default() -> Self {
603 Self {
604 enabled: default_true(),
605 }
606 }
607}
608
609fn default_true() -> bool {
610 true
611}
612
613#[derive(Clone, Default, Serialize, Deserialize)]
633pub struct SentryConfig {
634 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub dsn: Option<String>,
637
638 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub environment: Option<String>,
641
642 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub sample_rate: Option<f32>,
645
646 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub traces_sample_rate: Option<f32>,
649}
650
651impl std::fmt::Debug for SentryConfig {
652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653 f.debug_struct("SentryConfig")
654 .field("dsn", &self.dsn.as_ref().map(|_| "<redacted>"))
655 .field("environment", &self.environment)
656 .field("sample_rate", &self.sample_rate)
657 .field("traces_sample_rate", &self.traces_sample_rate)
658 .finish()
659 }
660}
661
662#[derive(Debug, Clone, Default, Serialize, Deserialize)]
679pub struct RemoteConfigSettings {
680 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub url: Option<String>,
683
684 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub token_key: Option<String>,
687}
688
689fn default_gitlab_url() -> String {
690 "https://gitlab.com".to_string()
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
703#[serde(rename_all = "kebab-case")]
704pub enum RoutingStrategy {
705 #[default]
708 Remote,
709 Local,
712 #[serde(rename = "local-first")]
715 LocalFirst,
716 #[serde(rename = "remote-first")]
719 RemoteFirst,
720}
721
722impl RoutingStrategy {
723 pub fn parse(s: &str) -> Option<Self> {
725 match s.trim().to_ascii_lowercase().as_str() {
726 "remote" => Some(Self::Remote),
727 "local" => Some(Self::Local),
728 "local-first" | "local_first" | "localfirst" => Some(Self::LocalFirst),
729 "remote-first" | "remote_first" | "remotefirst" => Some(Self::RemoteFirst),
730 _ => None,
731 }
732 }
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize)]
739#[serde(deny_unknown_fields)]
740pub struct ProxyToolRule {
741 pub pattern: String,
744 pub strategy: RoutingStrategy,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
750#[serde(deny_unknown_fields)]
751pub struct ProxyRoutingConfig {
752 #[serde(default)]
754 pub strategy: RoutingStrategy,
755 #[serde(default = "default_true")]
758 pub fallback_on_error: bool,
759 #[serde(default, skip_serializing_if = "Vec::is_empty")]
761 pub tool_overrides: Vec<ProxyToolRule>,
762}
763
764impl Default for ProxyRoutingConfig {
765 fn default() -> Self {
766 Self {
767 strategy: RoutingStrategy::default(),
768 fallback_on_error: true,
769 tool_overrides: Vec::new(),
770 }
771 }
772}
773
774impl ProxyRoutingConfig {
775 pub fn strategy_for(&self, tool_name: &str) -> RoutingStrategy {
778 for rule in &self.tool_overrides {
779 if matches_glob(&rule.pattern, tool_name) {
780 return rule.strategy;
781 }
782 }
783 self.strategy
784 }
785
786 pub fn merged_with(&self, override_cfg: Option<&ProxyRoutingOverride>) -> ProxyRoutingConfig {
792 let Some(o) = override_cfg else {
793 return self.clone();
794 };
795 let mut merged = self.clone();
796 if let Some(strategy) = o.strategy {
797 merged.strategy = strategy;
798 }
799 if let Some(fallback_on_error) = o.fallback_on_error {
800 merged.fallback_on_error = fallback_on_error;
801 }
802 if let Some(extra) = &o.tool_overrides
803 && !extra.is_empty()
804 {
805 let mut combined = extra.clone();
806 combined.extend(self.tool_overrides.iter().cloned());
807 merged.tool_overrides = combined;
808 }
809 merged
810 }
811
812 pub fn is_default(&self) -> bool {
814 self.strategy == RoutingStrategy::default()
815 && self.fallback_on_error
816 && self.tool_overrides.is_empty()
817 }
818}
819
820#[derive(Debug, Clone, Default, Serialize, Deserialize)]
827#[serde(deny_unknown_fields)]
828pub struct ProxyRoutingOverride {
829 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub strategy: Option<RoutingStrategy>,
831 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub fallback_on_error: Option<bool>,
833 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub tool_overrides: Option<Vec<ProxyToolRule>>,
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize)]
839#[serde(deny_unknown_fields)]
840pub struct ProxySecretsConfig {
841 #[serde(default = "default_secrets_cache_ttl")]
846 pub cache_ttl_secs: u64,
847}
848
849impl Default for ProxySecretsConfig {
850 fn default() -> Self {
851 Self {
852 cache_ttl_secs: default_secrets_cache_ttl(),
853 }
854 }
855}
856
857impl ProxySecretsConfig {
858 pub fn is_default(&self) -> bool {
859 self.cache_ttl_secs == default_secrets_cache_ttl()
860 }
861}
862
863fn default_secrets_cache_ttl() -> u64 {
864 300
865}
866
867#[derive(Debug, Clone, Serialize, Deserialize)]
870#[serde(deny_unknown_fields)]
871pub struct ProxyTelemetryConfig {
872 #[serde(default = "default_true")]
874 pub enabled: bool,
875 #[serde(default = "default_batch_size")]
877 pub batch_size: usize,
878 #[serde(default = "default_batch_interval_secs")]
880 pub batch_interval_secs: u64,
881 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub endpoint: Option<String>,
884 #[serde(default, skip_serializing_if = "Option::is_none")]
887 pub token_key: Option<String>,
888 #[serde(default = "default_offline_queue_max")]
891 pub offline_queue_max: usize,
892}
893
894impl Default for ProxyTelemetryConfig {
895 fn default() -> Self {
896 Self {
897 enabled: true,
898 batch_size: default_batch_size(),
899 batch_interval_secs: default_batch_interval_secs(),
900 endpoint: None,
901 token_key: None,
902 offline_queue_max: default_offline_queue_max(),
903 }
904 }
905}
906
907impl ProxyTelemetryConfig {
908 pub fn is_default(&self) -> bool {
909 self.enabled
910 && self.batch_size == default_batch_size()
911 && self.batch_interval_secs == default_batch_interval_secs()
912 && self.endpoint.is_none()
913 && self.token_key.is_none()
914 && self.offline_queue_max == default_offline_queue_max()
915 }
916}
917
918fn default_batch_size() -> usize {
919 100
920}
921
922fn default_batch_interval_secs() -> u64 {
923 30
924}
925
926fn default_offline_queue_max() -> usize {
927 10_000
928}
929
930#[derive(Debug, Clone, Default, Serialize, Deserialize)]
932#[serde(deny_unknown_fields)]
933pub struct ProxyConfig {
934 #[serde(default, skip_serializing_if = "ProxyRoutingConfig::is_default")]
935 pub routing: ProxyRoutingConfig,
936
937 #[serde(default, skip_serializing_if = "ProxySecretsConfig::is_default")]
938 pub secrets: ProxySecretsConfig,
939
940 #[serde(default, skip_serializing_if = "ProxyTelemetryConfig::is_default")]
941 pub telemetry: ProxyTelemetryConfig,
942}
943
944impl ProxyConfig {
945 pub fn is_default(&self) -> bool {
946 self.routing.is_default() && self.secrets.is_default() && self.telemetry.is_default()
947 }
948}
949
950pub fn matches_glob(pattern: &str, name: &str) -> bool {
959 if pattern == "*" {
961 return true;
962 }
963 if !pattern.contains('*') {
964 return pattern == name;
965 }
966
967 let segments: Vec<&str> = pattern.split('*').collect();
968 let mut cursor = 0usize;
969 let last_idx = segments.len() - 1;
970
971 if !segments[0].is_empty() {
973 if !name.starts_with(segments[0]) {
974 return false;
975 }
976 cursor = segments[0].len();
977 }
978
979 for seg in &segments[1..last_idx] {
981 if seg.is_empty() {
982 continue; }
984 match name[cursor..].find(seg) {
985 Some(pos) => cursor += pos + seg.len(),
986 None => return false,
987 }
988 }
989
990 let last = segments[last_idx];
992 if last.is_empty() {
993 return true;
994 }
995 if cursor > name.len() {
996 return false;
997 }
998 name[cursor..].ends_with(last)
999}
1000
1001impl Config {
1006 pub const DEFAULT_CONTEXT_NAME: &'static str = "default";
1008
1009 pub fn config_dir() -> Result<PathBuf> {
1011 dirs::config_dir()
1012 .map(|p| p.join(CONFIG_DIR_NAME))
1013 .ok_or_else(|| Error::Config("Could not determine config directory".to_string()))
1014 }
1015
1016 pub fn config_path() -> Result<PathBuf> {
1018 Ok(Self::config_dir()?.join(CONFIG_FILE_NAME))
1019 }
1020
1021 pub fn load() -> Result<Self> {
1025 let path = Self::config_path()?;
1026 Self::load_from(&path)
1027 }
1028
1029 pub fn load_from(path: &PathBuf) -> Result<Self> {
1033 if !path.exists() {
1034 debug!(path = ?path, "Config file does not exist, using defaults");
1035 return Ok(Self::default());
1036 }
1037
1038 debug!(path = ?path, "Loading config");
1039
1040 let contents = std::fs::read_to_string(path)
1041 .map_err(|e| Error::Config(format!("Failed to read config file: {}", e)))?;
1042
1043 let mut config: Config = toml::from_str(&contents)
1044 .map_err(|e| Error::Config(format!("Failed to parse config file: {}", e)))?;
1045
1046 config.sanitize();
1052 config.validate()?;
1053
1054 info!(path = ?path, "Config loaded successfully");
1055 Ok(config)
1056 }
1057
1058 pub fn sanitize(&mut self) {
1064 if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref()
1065 && endpoint.is_empty()
1066 {
1067 self.proxy.telemetry.endpoint = None;
1068 }
1069 }
1070
1071 pub fn validate(&self) -> Result<()> {
1079 if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref() {
1080 validate_http_url(endpoint, "proxy.telemetry.endpoint")?;
1081 }
1082 Ok(())
1083 }
1084
1085 pub fn save(&self) -> Result<()> {
1087 let path = Self::config_path()?;
1088 self.save_to(&path)
1089 }
1090
1091 pub fn save_to(&self, path: &PathBuf) -> Result<()> {
1093 if let Some(parent) = path.parent() {
1095 std::fs::create_dir_all(parent)
1096 .map_err(|e| Error::Config(format!("Failed to create config directory: {}", e)))?;
1097 }
1098
1099 debug!(path = ?path, "Saving config");
1100
1101 let contents = toml::to_string_pretty(self)
1102 .map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
1103
1104 std::fs::write(path, contents)
1105 .map_err(|e| Error::Config(format!("Failed to write config file: {}", e)))?;
1106
1107 info!(path = ?path, "Config saved successfully");
1108 Ok(())
1109 }
1110
1111 pub fn has_any_provider(&self) -> bool {
1113 self.github.is_some()
1114 || self.gitlab.is_some()
1115 || self.clickup.is_some()
1116 || self.jira.is_some()
1117 || self.linear.is_some()
1118 || self.yougile.is_some()
1119 || self.fireflies.is_some()
1120 || self.confluence.is_some()
1121 || self.slack.is_some()
1122 || self.telegram.is_some()
1123 || self.contexts.values().any(ContextConfig::has_any_provider)
1124 }
1125
1126 pub fn configured_providers(&self) -> Vec<&'static str> {
1128 let mut providers = Vec::new();
1129 if self.github.is_some() {
1130 providers.push("github");
1131 }
1132 if self.gitlab.is_some() {
1133 providers.push("gitlab");
1134 }
1135 if self.clickup.is_some() {
1136 providers.push("clickup");
1137 }
1138 if self.jira.is_some() {
1139 providers.push("jira");
1140 }
1141 if self.linear.is_some() {
1142 providers.push("linear");
1143 }
1144 if self.yougile.is_some() {
1145 providers.push("yougile");
1146 }
1147 if self.confluence.is_some() {
1148 providers.push("confluence");
1149 }
1150 if self.slack.is_some() {
1151 providers.push("slack");
1152 }
1153 if self.telegram.is_some() {
1154 providers.push("telegram");
1155 }
1156 providers
1157 }
1158
1159 pub fn context_names(&self) -> Vec<String> {
1161 let mut names: Vec<String> = self.contexts.keys().cloned().collect();
1162 if self.legacy_default_context().is_some()
1163 && !names.iter().any(|n| n == Self::DEFAULT_CONTEXT_NAME)
1164 {
1165 names.push(Self::DEFAULT_CONTEXT_NAME.to_string());
1166 }
1167 names.sort();
1168 names
1169 }
1170
1171 pub fn get_context(&self, name: &str) -> Option<ContextConfig> {
1173 if name == Self::DEFAULT_CONTEXT_NAME {
1174 return self
1175 .contexts
1176 .get(name)
1177 .cloned()
1178 .or_else(|| self.legacy_default_context());
1179 }
1180
1181 self.contexts.get(name).cloned()
1182 }
1183
1184 pub fn resolve_active_context_name(&self) -> Option<String> {
1186 if let Some(active) = &self.active_context
1187 && self.get_context(active).is_some()
1188 {
1189 return Some(active.clone());
1190 }
1191
1192 if self.get_context(Self::DEFAULT_CONTEXT_NAME).is_some() {
1193 return Some(Self::DEFAULT_CONTEXT_NAME.to_string());
1194 }
1195
1196 self.context_names().into_iter().next()
1197 }
1198
1199 pub fn set_active_context(&mut self, name: &str) -> Result<()> {
1201 if self.get_context(name).is_none() {
1202 return Err(Error::Config(format!("Unknown context: {}", name)));
1203 }
1204 self.active_context = Some(name.to_string());
1205 Ok(())
1206 }
1207
1208 pub fn legacy_default_context(&self) -> Option<ContextConfig> {
1210 let ctx = ContextConfig {
1211 github: self.github.clone(),
1212 gitlab: self.gitlab.clone(),
1213 clickup: self.clickup.clone(),
1214 jira: self.jira.clone(),
1215 linear: self.linear.clone(),
1216 yougile: self.yougile.clone(),
1217 fireflies: self.fireflies.clone(),
1218 confluence: self.confluence.clone(),
1219 slack: self.slack.clone(),
1220 telegram: self.telegram.clone(),
1221 };
1222
1223 if ctx.has_any_provider() {
1224 Some(ctx)
1225 } else {
1226 None
1227 }
1228 }
1229
1230 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
1236 let parts: Vec<&str> = key.split('.').collect();
1237
1238 if parts.len() == 3 && parts[0] == "proxy" {
1240 return self.set_proxy_field(parts[1], parts[2], value);
1241 }
1242
1243 if parts.len() != 2 {
1244 return Err(Error::Config(format!(
1245 "Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
1246 key
1247 )));
1248 }
1249
1250 let (provider, field) = (parts[0], parts[1]);
1251
1252 match provider {
1253 "github" => {
1254 let config = self.github.get_or_insert_with(|| GitHubConfig {
1255 owner: String::new(),
1256 repo: String::new(),
1257 base_url: None,
1258 });
1259 match field {
1260 "owner" => config.owner = value.to_string(),
1261 "repo" => config.repo = value.to_string(),
1262 "base_url" | "url" => config.base_url = Some(value.to_string()),
1263 _ => {
1264 return Err(Error::Config(format!(
1265 "Unknown GitHub config field: {}",
1266 field
1267 )));
1268 }
1269 }
1270 }
1271 "gitlab" => {
1272 let config = self.gitlab.get_or_insert_with(|| GitLabConfig {
1273 url: default_gitlab_url(),
1274 project_id: String::new(),
1275 });
1276 match field {
1277 "url" => config.url = value.to_string(),
1278 "project_id" | "project" => config.project_id = value.to_string(),
1279 _ => {
1280 return Err(Error::Config(format!(
1281 "Unknown GitLab config field: {}",
1282 field
1283 )));
1284 }
1285 }
1286 }
1287 "clickup" => {
1288 let config = self.clickup.get_or_insert_with(|| ClickUpConfig {
1289 list_id: String::new(),
1290 team_id: None,
1291 });
1292 match field {
1293 "list_id" | "list" => config.list_id = value.to_string(),
1294 "team_id" | "team" => config.team_id = Some(value.to_string()),
1295 _ => {
1296 return Err(Error::Config(format!(
1297 "Unknown ClickUp config field: {}",
1298 field
1299 )));
1300 }
1301 }
1302 }
1303 "jira" => {
1304 let config = self.jira.get_or_insert_with(|| JiraConfig {
1305 url: String::new(),
1306 project_key: String::new(),
1307 email: String::new(),
1308 });
1309 match field {
1310 "url" => config.url = value.to_string(),
1311 "project_key" | "project" => config.project_key = value.to_string(),
1312 "email" => config.email = value.to_string(),
1313 _ => {
1314 return Err(Error::Config(format!(
1315 "Unknown Jira config field: {}",
1316 field
1317 )));
1318 }
1319 }
1320 }
1321 "linear" => {
1322 let config = self.linear.get_or_insert_with(|| LinearConfig {
1323 url: default_linear_url(),
1324 team_id: String::new(),
1325 team_key: None,
1326 });
1327 match field {
1328 "url" | "base_url" => config.url = value.to_string(),
1329 "team_id" | "team" => config.team_id = value.to_string(),
1330 "team_key" | "key" => config.team_key = Some(value.to_string()),
1331 _ => {
1332 return Err(Error::Config(format!(
1333 "Unknown Linear config field: {}",
1334 field
1335 )));
1336 }
1337 }
1338 }
1339 "yougile" => {
1340 let config = self.yougile.get_or_insert_with(|| YouGileConfig {
1341 url: default_yougile_url(),
1342 board_id: String::new(),
1343 });
1344 match field {
1345 "url" | "base_url" => config.url = value.to_string(),
1346 "board_id" | "board" => config.board_id = value.to_string(),
1347 _ => {
1348 return Err(Error::Config(format!(
1349 "Unknown YouGile config field: {}",
1350 field
1351 )));
1352 }
1353 }
1354 }
1355 "confluence" => {
1356 let config = self.confluence.get_or_insert_with(|| ConfluenceConfig {
1357 base_url: String::new(),
1358 flavor: None,
1359 cloud_id: None,
1360 api_version: None,
1361 username: None,
1362 client_id: None,
1363 redirect_uri: None,
1364 space_key: None,
1365 });
1366 match field {
1367 "base_url" | "url" => config.base_url = value.to_string(),
1368 "flavor" => config.flavor = Some(parse_confluence_flavor(value)?),
1369 "cloud_id" | "cloud" => config.cloud_id = Some(value.to_string()),
1370 "api_version" | "api" | "version" => {
1371 config.api_version = Some(value.to_string())
1372 }
1373 "username" | "email" | "user" => config.username = Some(value.to_string()),
1374 "client_id" => config.client_id = Some(value.to_string()),
1375 "redirect_uri" => config.redirect_uri = Some(value.to_string()),
1376 "space_key" | "space" => config.space_key = Some(value.to_string()),
1377 _ => {
1378 return Err(Error::Config(format!(
1379 "Unknown Confluence config field: {}",
1380 field
1381 )));
1382 }
1383 }
1384 }
1385 "slack" => {
1386 let config = self.slack.get_or_insert_with(SlackConfig::default);
1387 match field {
1388 "team_id" | "team" => config.team_id = Some(value.to_string()),
1389 "workspace" => config.workspace = Some(value.to_string()),
1390 "base_url" | "url" => config.base_url = Some(value.to_string()),
1391 "client_id" => config.client_id = Some(value.to_string()),
1392 "redirect_uri" => config.redirect_uri = Some(value.to_string()),
1393 _ => {
1394 return Err(Error::Config(format!(
1395 "Unknown Slack config field: {}",
1396 field
1397 )));
1398 }
1399 }
1400 }
1401 "telegram" => {
1402 let config = self.telegram.get_or_insert_with(TelegramConfig::default);
1403 match field {
1404 "base_url" | "url" => config.base_url = Some(value.to_string()),
1405 "bot_username" | "bot" | "username" => {
1406 config.bot_username = Some(value.to_string())
1407 }
1408 _ => {
1409 return Err(Error::Config(format!(
1410 "Unknown Telegram config field: {}",
1411 field
1412 )));
1413 }
1414 }
1415 }
1416 _ => {
1417 return Err(Error::Config(format!("Unknown provider: {}", provider)));
1418 }
1419 }
1420
1421 Ok(())
1422 }
1423
1424 pub fn get(&self, key: &str) -> Result<Option<String>> {
1430 let parts: Vec<&str> = key.split('.').collect();
1431
1432 if parts.len() == 3 && parts[0] == "proxy" {
1433 return self.get_proxy_field(parts[1], parts[2]);
1434 }
1435
1436 if parts.len() != 2 {
1437 return Err(Error::Config(format!(
1438 "Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
1439 key
1440 )));
1441 }
1442
1443 let (provider, field) = (parts[0], parts[1]);
1444
1445 match provider {
1446 "github" => {
1447 let Some(config) = &self.github else {
1448 return Ok(None);
1449 };
1450 match field {
1451 "owner" => Ok(Some(config.owner.clone())),
1452 "repo" => Ok(Some(config.repo.clone())),
1453 "base_url" | "url" => Ok(config.base_url.clone()),
1454 _ => Err(Error::Config(format!(
1455 "Unknown GitHub config field: {}",
1456 field
1457 ))),
1458 }
1459 }
1460 "gitlab" => {
1461 let Some(config) = &self.gitlab else {
1462 return Ok(None);
1463 };
1464 match field {
1465 "url" => Ok(Some(config.url.clone())),
1466 "project_id" | "project" => Ok(Some(config.project_id.clone())),
1467 _ => Err(Error::Config(format!(
1468 "Unknown GitLab config field: {}",
1469 field
1470 ))),
1471 }
1472 }
1473 "clickup" => {
1474 let Some(config) = &self.clickup else {
1475 return Ok(None);
1476 };
1477 match field {
1478 "list_id" | "list" => Ok(Some(config.list_id.clone())),
1479 "team_id" | "team" => Ok(config.team_id.clone()),
1480 _ => Err(Error::Config(format!(
1481 "Unknown ClickUp config field: {}",
1482 field
1483 ))),
1484 }
1485 }
1486 "jira" => {
1487 let Some(config) = &self.jira else {
1488 return Ok(None);
1489 };
1490 match field {
1491 "url" => Ok(Some(config.url.clone())),
1492 "project_key" | "project" => Ok(Some(config.project_key.clone())),
1493 "email" => Ok(Some(config.email.clone())),
1494 _ => Err(Error::Config(format!(
1495 "Unknown Jira config field: {}",
1496 field
1497 ))),
1498 }
1499 }
1500 "linear" => {
1501 let Some(config) = &self.linear else {
1502 return Ok(None);
1503 };
1504 match field {
1505 "url" | "base_url" => Ok(Some(config.url.clone())),
1506 "team_id" | "team" => Ok(Some(config.team_id.clone())),
1507 "team_key" | "key" => Ok(config.team_key.clone()),
1508 _ => Err(Error::Config(format!(
1509 "Unknown Linear config field: {}",
1510 field
1511 ))),
1512 }
1513 }
1514 "yougile" => {
1515 let Some(config) = &self.yougile else {
1516 return Ok(None);
1517 };
1518 match field {
1519 "url" | "base_url" => Ok(Some(config.url.clone())),
1520 "board_id" | "board" => Ok(Some(config.board_id.clone())),
1521 _ => Err(Error::Config(format!(
1522 "Unknown YouGile config field: {}",
1523 field
1524 ))),
1525 }
1526 }
1527 "confluence" => {
1528 let Some(config) = &self.confluence else {
1529 return Ok(None);
1530 };
1531 match field {
1532 "base_url" | "url" => Ok(Some(config.base_url.clone())),
1533 "flavor" => Ok(config.flavor.map(confluence_flavor_slug)),
1534 "cloud_id" | "cloud" => Ok(config.cloud_id.clone()),
1535 "api_version" | "api" | "version" => Ok(config.api_version.clone()),
1536 "username" | "email" | "user" => Ok(config.username.clone()),
1537 "client_id" => Ok(config.client_id.clone()),
1538 "redirect_uri" => Ok(config.redirect_uri.clone()),
1539 "space_key" | "space" => Ok(config.space_key.clone()),
1540 _ => Err(Error::Config(format!(
1541 "Unknown Confluence config field: {}",
1542 field
1543 ))),
1544 }
1545 }
1546 "slack" => {
1547 let Some(config) = &self.slack else {
1548 return Ok(None);
1549 };
1550 match field {
1551 "team_id" | "team" => Ok(config.team_id.clone()),
1552 "workspace" => Ok(config.workspace.clone()),
1553 "base_url" | "url" => Ok(config.base_url.clone()),
1554 "client_id" => Ok(config.client_id.clone()),
1555 "redirect_uri" => Ok(config.redirect_uri.clone()),
1556 _ => Err(Error::Config(format!(
1557 "Unknown Slack config field: {}",
1558 field
1559 ))),
1560 }
1561 }
1562 "telegram" => {
1563 let Some(config) = &self.telegram else {
1564 return Ok(None);
1565 };
1566 match field {
1567 "base_url" | "url" => Ok(config.base_url.clone()),
1568 "bot_username" | "bot" | "username" => Ok(config.bot_username.clone()),
1569 _ => Err(Error::Config(format!(
1570 "Unknown Telegram config field: {}",
1571 field
1572 ))),
1573 }
1574 }
1575 _ => Err(Error::Config(format!("Unknown provider: {}", provider))),
1576 }
1577 }
1578
1579 fn set_proxy_field(&mut self, section: &str, field: &str, value: &str) -> Result<()> {
1581 match section {
1582 "routing" => match field {
1583 "strategy" => {
1584 let strat = RoutingStrategy::parse(value).ok_or_else(|| {
1585 Error::Config(format!(
1586 "Invalid routing strategy '{}'. Allowed (case-insensitive): \
1587 remote, local, local-first, remote-first",
1588 value
1589 ))
1590 })?;
1591 self.proxy.routing.strategy = strat;
1592 Ok(())
1593 }
1594 "fallback_on_error" => {
1595 self.proxy.routing.fallback_on_error = parse_bool(value)?;
1596 Ok(())
1597 }
1598 _ => Err(Error::Config(format!(
1599 "Unknown proxy.routing field: {}",
1600 field
1601 ))),
1602 },
1603 "secrets" => match field {
1604 "cache_ttl_secs" => {
1605 self.proxy.secrets.cache_ttl_secs = parse_u64(value, field)?;
1606 Ok(())
1607 }
1608 _ => Err(Error::Config(format!(
1609 "Unknown proxy.secrets field: {}",
1610 field
1611 ))),
1612 },
1613 "telemetry" => match field {
1614 "enabled" => {
1615 self.proxy.telemetry.enabled = parse_bool(value)?;
1616 Ok(())
1617 }
1618 "endpoint" => {
1619 self.proxy.telemetry.endpoint = if value.is_empty() {
1620 None
1621 } else {
1622 validate_http_url(value, "proxy.telemetry.endpoint")?;
1623 Some(value.to_string())
1624 };
1625 Ok(())
1626 }
1627 "token_key" => {
1628 self.proxy.telemetry.token_key = if value.is_empty() {
1629 None
1630 } else {
1631 Some(value.to_string())
1632 };
1633 Ok(())
1634 }
1635 "batch_size" => {
1636 self.proxy.telemetry.batch_size = parse_usize(value, field)?;
1637 Ok(())
1638 }
1639 "batch_interval_secs" => {
1640 self.proxy.telemetry.batch_interval_secs = parse_u64(value, field)?;
1641 Ok(())
1642 }
1643 "offline_queue_max" => {
1644 self.proxy.telemetry.offline_queue_max = parse_usize(value, field)?;
1645 Ok(())
1646 }
1647 _ => Err(Error::Config(format!(
1648 "Unknown proxy.telemetry field: {}",
1649 field
1650 ))),
1651 },
1652 _ => Err(Error::Config(format!(
1653 "Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
1654 section
1655 ))),
1656 }
1657 }
1658
1659 fn get_proxy_field(&self, section: &str, field: &str) -> Result<Option<String>> {
1662 match section {
1663 "routing" => match field {
1664 "strategy" => Ok(Some(routing_strategy_slug(self.proxy.routing.strategy))),
1665 "fallback_on_error" => Ok(Some(self.proxy.routing.fallback_on_error.to_string())),
1666 _ => Err(Error::Config(format!(
1667 "Unknown proxy.routing field: {}",
1668 field
1669 ))),
1670 },
1671 "secrets" => match field {
1672 "cache_ttl_secs" => Ok(Some(self.proxy.secrets.cache_ttl_secs.to_string())),
1673 _ => Err(Error::Config(format!(
1674 "Unknown proxy.secrets field: {}",
1675 field
1676 ))),
1677 },
1678 "telemetry" => match field {
1679 "enabled" => Ok(Some(self.proxy.telemetry.enabled.to_string())),
1680 "endpoint" => Ok(self.proxy.telemetry.endpoint.clone()),
1681 "token_key" => Ok(self.proxy.telemetry.token_key.clone()),
1682 "batch_size" => Ok(Some(self.proxy.telemetry.batch_size.to_string())),
1683 "batch_interval_secs" => {
1684 Ok(Some(self.proxy.telemetry.batch_interval_secs.to_string()))
1685 }
1686 "offline_queue_max" => Ok(Some(self.proxy.telemetry.offline_queue_max.to_string())),
1687 _ => Err(Error::Config(format!(
1688 "Unknown proxy.telemetry field: {}",
1689 field
1690 ))),
1691 },
1692 _ => Err(Error::Config(format!(
1693 "Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
1694 section
1695 ))),
1696 }
1697 }
1698}
1699
1700fn parse_bool(value: &str) -> Result<bool> {
1701 match value.trim().to_ascii_lowercase().as_str() {
1702 "true" | "1" | "yes" | "on" => Ok(true),
1703 "false" | "0" | "no" | "off" => Ok(false),
1704 _ => Err(Error::Config(format!(
1705 "Invalid boolean '{}'. Allowed: true/false, 1/0, yes/no, on/off",
1706 value
1707 ))),
1708 }
1709}
1710
1711fn parse_u64(value: &str, field: &str) -> Result<u64> {
1712 value.trim().parse::<u64>().map_err(|_| {
1713 Error::Config(format!(
1714 "Invalid value for {}: '{}'. Expected non-negative integer",
1715 field, value
1716 ))
1717 })
1718}
1719
1720fn parse_usize(value: &str, field: &str) -> Result<usize> {
1721 value.trim().parse::<usize>().map_err(|_| {
1722 Error::Config(format!(
1723 "Invalid value for {}: '{}'. Expected non-negative integer",
1724 field, value
1725 ))
1726 })
1727}
1728
1729fn validate_http_url(value: &str, field: &str) -> Result<()> {
1741 if value.contains(|c: char| c.is_whitespace()) {
1745 return Err(Error::Config(format!(
1746 "Invalid URL for {}: '{}'. Must not contain whitespace",
1747 field, value
1748 )));
1749 }
1750
1751 let rest = if let Some(r) = value.strip_prefix("https://") {
1752 r
1753 } else if let Some(r) = value.strip_prefix("http://") {
1754 r
1755 } else {
1756 return Err(Error::Config(format!(
1757 "Invalid URL for {}: '{}'. Must start with http:// or https://",
1758 field, value
1759 )));
1760 };
1761
1762 let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1764 let host = &rest[..host_end];
1765 if host.is_empty() {
1766 return Err(Error::Config(format!(
1767 "Invalid URL for {}: '{}'. Missing host",
1768 field, value
1769 )));
1770 }
1771
1772 Ok(())
1773}
1774
1775pub fn routing_strategy_slug(s: RoutingStrategy) -> String {
1779 match s {
1780 RoutingStrategy::Remote => "remote",
1781 RoutingStrategy::Local => "local",
1782 RoutingStrategy::LocalFirst => "local-first",
1783 RoutingStrategy::RemoteFirst => "remote-first",
1784 }
1785 .to_string()
1786}
1787
1788impl ContextConfig {
1789 pub fn has_any_provider(&self) -> bool {
1791 self.github.is_some()
1792 || self.gitlab.is_some()
1793 || self.clickup.is_some()
1794 || self.jira.is_some()
1795 || self.linear.is_some()
1796 || self.yougile.is_some()
1797 || self.fireflies.is_some()
1798 || self.confluence.is_some()
1799 || self.slack.is_some()
1800 || self.telegram.is_some()
1801 }
1802
1803 pub fn configured_providers(&self) -> Vec<&'static str> {
1805 let mut providers = Vec::new();
1806 if self.github.is_some() {
1807 providers.push("github");
1808 }
1809 if self.gitlab.is_some() {
1810 providers.push("gitlab");
1811 }
1812 if self.clickup.is_some() {
1813 providers.push("clickup");
1814 }
1815 if self.jira.is_some() {
1816 providers.push("jira");
1817 }
1818 if self.linear.is_some() {
1819 providers.push("linear");
1820 }
1821 if self.yougile.is_some() {
1822 providers.push("yougile");
1823 }
1824 if self.confluence.is_some() {
1825 providers.push("confluence");
1826 }
1827 if self.slack.is_some() {
1828 providers.push("slack");
1829 }
1830 if self.telegram.is_some() {
1831 providers.push("telegram");
1832 }
1833 providers
1834 }
1835}
1836
1837#[cfg(test)]
1842mod tests {
1843 use super::*;
1844 use tempfile::NamedTempFile;
1845
1846 #[test]
1847 fn test_default_config() {
1848 let config = Config::default();
1849 assert!(config.github.is_none());
1850 assert!(config.gitlab.is_none());
1851 assert!(config.telegram.is_none());
1852 assert!(config.contexts.is_empty());
1853 assert!(!config.has_any_provider());
1854 assert!(config.configured_providers().is_empty());
1855 }
1856
1857 #[test]
1858 fn test_set_and_get() {
1859 let mut config = Config::default();
1860
1861 config.set("github.owner", "test-owner").unwrap();
1863 config.set("github.repo", "test-repo").unwrap();
1864
1865 assert_eq!(
1866 config.get("github.owner").unwrap(),
1867 Some("test-owner".to_string())
1868 );
1869 assert_eq!(
1870 config.get("github.repo").unwrap(),
1871 Some("test-repo".to_string())
1872 );
1873
1874 config
1876 .set("gitlab.url", "https://gitlab.example.com")
1877 .unwrap();
1878 config.set("gitlab.project_id", "123").unwrap();
1879
1880 assert_eq!(
1881 config.get("gitlab.url").unwrap(),
1882 Some("https://gitlab.example.com".to_string())
1883 );
1884
1885 assert!(config.has_any_provider());
1887 let providers = config.configured_providers();
1888 assert!(providers.contains(&"github"));
1889 assert!(providers.contains(&"gitlab"));
1890 }
1891
1892 #[test]
1893 fn test_set_and_get_telegram() {
1894 let mut config = Config::default();
1895
1896 config
1897 .set("telegram.base_url", "https://api.telegram.org")
1898 .unwrap();
1899 config.set("telegram.bot_username", "devboy_bot").unwrap();
1900
1901 assert_eq!(
1902 config.get("telegram.base_url").unwrap(),
1903 Some("https://api.telegram.org".to_string())
1904 );
1905 assert_eq!(
1906 config.get("telegram.url").unwrap(),
1907 Some("https://api.telegram.org".to_string())
1908 );
1909 assert_eq!(
1910 config.get("telegram.bot_username").unwrap(),
1911 Some("devboy_bot".to_string())
1912 );
1913 assert_eq!(
1914 config.get("telegram.bot").unwrap(),
1915 Some("devboy_bot".to_string())
1916 );
1917 }
1918
1919 #[test]
1920 fn test_default_slack_required_scopes_cover_default_conversation_types() {
1921 let scopes = default_slack_required_scopes();
1922
1923 assert!(scopes.contains(&"channels:read".to_string()));
1924 assert!(scopes.contains(&"channels:history".to_string()));
1925 assert!(scopes.contains(&"groups:read".to_string()));
1926 assert!(scopes.contains(&"groups:history".to_string()));
1927 assert!(scopes.contains(&"im:read".to_string()));
1928 assert!(scopes.contains(&"im:history".to_string()));
1929 assert!(scopes.contains(&"mpim:read".to_string()));
1930 assert!(scopes.contains(&"mpim:history".to_string()));
1931 }
1932
1933 #[test]
1934 fn test_invalid_key() {
1935 let mut config = Config::default();
1936
1937 assert!(config.set("invalid", "value").is_err());
1939 assert!(config.set("too.many.parts", "value").is_err());
1940
1941 assert!(config.set("unknown.field", "value").is_err());
1943 assert!(config.set("telegram.unknown", "value").is_err());
1944
1945 assert_eq!(config.get("github.owner").unwrap(), None);
1947
1948 config.set("github.owner", "test").unwrap();
1950 assert!(config.get("github.unknown_field").is_err());
1951 }
1952
1953 #[test]
1954 fn is_secrets_migration_complete_defaults_to_false() {
1955 let config = Config::default();
1956 assert!(!config.is_secrets_migration_complete());
1957 }
1958
1959 #[test]
1960 fn is_secrets_migration_complete_reads_explicit_flag() {
1961 let config = Config {
1962 secrets: Some(SecretsConfig {
1963 migration_complete: true,
1964 }),
1965 ..Config::default()
1966 };
1967 assert!(config.is_secrets_migration_complete());
1968 }
1969
1970 #[test]
1971 fn secrets_section_round_trips_through_toml() {
1972 let toml = "[secrets]\nmigration_complete = true\n";
1973 let config: Config = toml::from_str(toml).unwrap();
1974 assert!(config.is_secrets_migration_complete());
1975 let serialized = toml::to_string(&config).unwrap();
1976 assert!(serialized.contains("[secrets]"));
1977 assert!(serialized.contains("migration_complete = true"));
1978 }
1979
1980 #[test]
1981 fn secrets_section_omitted_when_unset() {
1982 let config = Config::default();
1983 let serialized = toml::to_string(&config).unwrap();
1984 assert!(
1985 !serialized.contains("[secrets]"),
1986 "default Config should not write a [secrets] section"
1987 );
1988 }
1989
1990 #[test]
1991 fn test_save_and_load() {
1992 let config = Config {
1993 github: Some(GitHubConfig {
1994 owner: "test-owner".to_string(),
1995 repo: "test-repo".to_string(),
1996 base_url: None,
1997 }),
1998 ..Default::default()
1999 };
2000
2001 let temp_file = NamedTempFile::new().unwrap();
2003 let path = temp_file.path().to_path_buf();
2004
2005 config.save_to(&path).unwrap();
2006
2007 let contents = std::fs::read_to_string(&path).unwrap();
2009 assert!(contents.contains("owner = \"test-owner\""));
2010 assert!(contents.contains("repo = \"test-repo\""));
2011
2012 let loaded = Config::load_from(&path).unwrap();
2014 assert!(loaded.github.is_some());
2015 let gh = loaded.github.unwrap();
2016 assert_eq!(gh.owner, "test-owner");
2017 assert_eq!(gh.repo, "test-repo");
2018 }
2019
2020 #[test]
2021 fn test_load_nonexistent() {
2022 let path = PathBuf::from("/nonexistent/path/config.toml");
2023 let config = Config::load_from(&path).unwrap();
2024 assert!(config.github.is_none());
2025 }
2026
2027 #[test]
2028 fn test_set_and_get_gitlab() {
2029 let mut config = Config::default();
2030
2031 config
2032 .set("gitlab.url", "https://gitlab.example.com")
2033 .unwrap();
2034 config.set("gitlab.project_id", "456").unwrap();
2035
2036 assert_eq!(
2037 config.get("gitlab.url").unwrap(),
2038 Some("https://gitlab.example.com".to_string())
2039 );
2040 assert_eq!(
2041 config.get("gitlab.project_id").unwrap(),
2042 Some("456".to_string())
2043 );
2044 assert_eq!(
2046 config.get("gitlab.project").unwrap(),
2047 Some("456".to_string())
2048 );
2049 }
2050
2051 #[test]
2052 fn test_set_and_get_gitlab_alias() {
2053 let mut config = Config::default();
2054
2055 config.set("gitlab.project", "789").unwrap();
2056
2057 assert_eq!(
2058 config.get("gitlab.project_id").unwrap(),
2059 Some("789".to_string())
2060 );
2061 }
2062
2063 #[test]
2064 fn test_set_and_get_clickup() {
2065 let mut config = Config::default();
2066
2067 config.set("clickup.list_id", "list123").unwrap();
2068
2069 assert_eq!(
2070 config.get("clickup.list_id").unwrap(),
2071 Some("list123".to_string())
2072 );
2073 assert_eq!(
2075 config.get("clickup.list").unwrap(),
2076 Some("list123".to_string())
2077 );
2078 }
2079
2080 #[test]
2081 fn test_set_and_get_clickup_alias() {
2082 let mut config = Config::default();
2083
2084 config.set("clickup.list", "list456").unwrap();
2085
2086 assert_eq!(
2087 config.get("clickup.list_id").unwrap(),
2088 Some("list456".to_string())
2089 );
2090 }
2091
2092 #[test]
2093 fn test_set_and_get_jira() {
2094 let mut config = Config::default();
2095
2096 config.set("jira.url", "https://jira.example.com").unwrap();
2097 config.set("jira.project_key", "PROJ").unwrap();
2098 config.set("jira.email", "user@example.com").unwrap();
2099
2100 assert_eq!(
2101 config.get("jira.url").unwrap(),
2102 Some("https://jira.example.com".to_string())
2103 );
2104 assert_eq!(
2105 config.get("jira.project_key").unwrap(),
2106 Some("PROJ".to_string())
2107 );
2108 assert_eq!(
2109 config.get("jira.email").unwrap(),
2110 Some("user@example.com".to_string())
2111 );
2112 assert_eq!(
2114 config.get("jira.project").unwrap(),
2115 Some("PROJ".to_string())
2116 );
2117 }
2118
2119 #[test]
2120 fn test_set_and_get_jira_alias() {
2121 let mut config = Config::default();
2122
2123 config.set("jira.project", "KEY").unwrap();
2124
2125 assert_eq!(
2126 config.get("jira.project_key").unwrap(),
2127 Some("KEY".to_string())
2128 );
2129 }
2130
2131 #[test]
2132 fn test_set_and_get_linear() {
2133 let mut config = Config::default();
2134
2135 config
2136 .set("linear.url", "https://linear.example.com/graphql")
2137 .unwrap();
2138 config.set("linear.team_id", "team-123").unwrap();
2139 config.set("linear.team_key", "ENG").unwrap();
2140
2141 assert_eq!(
2142 config.get("linear.url").unwrap(),
2143 Some("https://linear.example.com/graphql".to_string())
2144 );
2145 assert_eq!(
2146 config.get("linear.base_url").unwrap(),
2147 Some("https://linear.example.com/graphql".to_string())
2148 );
2149 assert_eq!(
2150 config.get("linear.team_id").unwrap(),
2151 Some("team-123".to_string())
2152 );
2153 assert_eq!(
2154 config.get("linear.team").unwrap(),
2155 Some("team-123".to_string())
2156 );
2157 assert_eq!(
2158 config.get("linear.team_key").unwrap(),
2159 Some("ENG".to_string())
2160 );
2161 assert_eq!(config.get("linear.key").unwrap(), Some("ENG".to_string()));
2162 }
2163
2164 #[test]
2165 fn test_set_and_get_yougile() {
2166 let mut config = Config::default();
2167
2168 config
2169 .set("yougile.url", "https://company.yougile.com/api-v2")
2170 .unwrap();
2171 config.set("yougile.board_id", "board-123").unwrap();
2172
2173 assert_eq!(
2174 config.get("yougile.url").unwrap(),
2175 Some("https://company.yougile.com/api-v2".to_string())
2176 );
2177 assert_eq!(
2178 config.get("yougile.base_url").unwrap(),
2179 Some("https://company.yougile.com/api-v2".to_string())
2180 );
2181 assert_eq!(
2182 config.get("yougile.board_id").unwrap(),
2183 Some("board-123".to_string())
2184 );
2185 assert_eq!(
2186 config.get("yougile.board").unwrap(),
2187 Some("board-123".to_string())
2188 );
2189 }
2190
2191 #[test]
2192 fn test_set_and_get_yougile_alias() {
2193 let mut config = Config::default();
2194
2195 config.set("yougile.board", "board-456").unwrap();
2196
2197 assert_eq!(
2198 config.get("yougile.board_id").unwrap(),
2199 Some("board-456".to_string())
2200 );
2201 }
2202
2203 #[test]
2204 fn test_set_and_get_confluence() {
2205 let mut config = Config::default();
2206
2207 config
2208 .set("confluence.base_url", "https://wiki.example.com")
2209 .unwrap();
2210 config.set("confluence.flavor", "cloud").unwrap();
2211 config.set("confluence.cloud_id", "cloud-123").unwrap();
2212 config.set("confluence.api_version", "v1").unwrap();
2213 config
2214 .set("confluence.username", "dev@example.com")
2215 .unwrap();
2216 config.set("confluence.client_id", "client-123").unwrap();
2217 config
2218 .set("confluence.redirect_uri", "http://localhost:8787/callback")
2219 .unwrap();
2220 config.set("confluence.space_key", "ENG").unwrap();
2221
2222 assert_eq!(
2223 config.get("confluence.base_url").unwrap(),
2224 Some("https://wiki.example.com".to_string())
2225 );
2226 assert_eq!(
2227 config.get("confluence.url").unwrap(),
2228 Some("https://wiki.example.com".to_string())
2229 );
2230 assert_eq!(
2231 config.get("confluence.flavor").unwrap(),
2232 Some("cloud".to_string())
2233 );
2234 assert_eq!(
2235 config.get("confluence.cloud").unwrap(),
2236 Some("cloud-123".to_string())
2237 );
2238 assert_eq!(
2239 config.get("confluence.api").unwrap(),
2240 Some("v1".to_string())
2241 );
2242 assert_eq!(
2243 config.get("confluence.username").unwrap(),
2244 Some("dev@example.com".to_string())
2245 );
2246 assert_eq!(
2247 config.get("confluence.client_id").unwrap(),
2248 Some("client-123".to_string())
2249 );
2250 assert_eq!(
2251 config.get("confluence.redirect_uri").unwrap(),
2252 Some("http://localhost:8787/callback".to_string())
2253 );
2254 assert_eq!(
2255 config.get("confluence.space").unwrap(),
2256 Some("ENG".to_string())
2257 );
2258 }
2259
2260 #[test]
2261 fn test_set_github_base_url() {
2262 let mut config = Config::default();
2263
2264 config
2265 .set("github.base_url", "https://github.example.com/api/v3")
2266 .unwrap();
2267
2268 assert_eq!(
2269 config.get("github.base_url").unwrap(),
2270 Some("https://github.example.com/api/v3".to_string())
2271 );
2272 assert_eq!(
2274 config.get("github.url").unwrap(),
2275 Some("https://github.example.com/api/v3".to_string())
2276 );
2277 }
2278
2279 #[test]
2280 fn test_set_github_url_alias() {
2281 let mut config = Config::default();
2282
2283 config
2284 .set("github.url", "https://github.example.com/api/v3")
2285 .unwrap();
2286
2287 assert_eq!(
2288 config.get("github.base_url").unwrap(),
2289 Some("https://github.example.com/api/v3".to_string())
2290 );
2291 }
2292
2293 #[test]
2294 fn test_unknown_field_errors() {
2295 let mut config = Config::default();
2296
2297 assert!(config.set("github.unknown", "value").is_err());
2299 config.set("github.owner", "test").unwrap();
2300 assert!(config.get("github.unknown").is_err());
2301
2302 assert!(config.set("gitlab.unknown", "value").is_err());
2304 config.set("gitlab.url", "https://gitlab.com").unwrap();
2305 assert!(config.get("gitlab.unknown").is_err());
2306
2307 assert!(config.set("clickup.unknown", "value").is_err());
2309 config.set("clickup.list_id", "123").unwrap();
2310 assert!(config.get("clickup.unknown").is_err());
2311
2312 assert!(config.set("jira.unknown", "value").is_err());
2314 config.set("jira.url", "https://jira.com").unwrap();
2315 assert!(config.get("jira.unknown").is_err());
2316
2317 assert!(config.set("linear.unknown", "value").is_err());
2319 config.set("linear.team_id", "team-1").unwrap();
2320 assert!(config.get("linear.unknown").is_err());
2321 assert!(config.set("yougile.unknown", "value").is_err());
2323 config.set("yougile.board_id", "board-123").unwrap();
2324 assert!(config.get("yougile.unknown").is_err());
2325 }
2326
2327 #[test]
2328 fn test_get_unconfigured_providers() {
2329 let config = Config::default();
2330
2331 assert_eq!(config.get("github.owner").unwrap(), None);
2332 assert_eq!(config.get("gitlab.url").unwrap(), None);
2333 assert_eq!(config.get("clickup.list_id").unwrap(), None);
2334 assert_eq!(config.get("jira.url").unwrap(), None);
2335 assert_eq!(config.get("linear.team_id").unwrap(), None);
2336 assert_eq!(config.get("yougile.url").unwrap(), None);
2337 assert_eq!(config.get("confluence.base_url").unwrap(), None);
2338 assert_eq!(config.get("telegram.base_url").unwrap(), None);
2339 }
2340
2341 #[test]
2342 fn test_unknown_provider_set() {
2343 let mut config = Config::default();
2344 let result = config.set("unknown.field", "value");
2345 assert!(result.is_err());
2346 let err_msg = result.unwrap_err().to_string();
2347 assert!(err_msg.contains("Unknown provider: unknown"));
2348 }
2349
2350 #[test]
2351 fn test_unknown_provider_get() {
2352 let config = Config::default();
2353 let result = config.get("unknown.field");
2354 assert!(result.is_err());
2355 }
2356
2357 #[test]
2358 fn test_malformed_toml() {
2359 let temp_file = NamedTempFile::new().unwrap();
2360 let path = temp_file.path().to_path_buf();
2361
2362 std::fs::write(&path, "invalid toml content [[[").unwrap();
2363
2364 let result = Config::load_from(&path);
2365 assert!(result.is_err());
2366 let err_msg = result.unwrap_err().to_string();
2367 assert!(err_msg.contains("Failed to parse config file"));
2368 }
2369
2370 #[test]
2371 fn test_configured_providers_all() {
2372 let config = Config {
2373 github: Some(GitHubConfig {
2374 owner: "o".to_string(),
2375 repo: "r".to_string(),
2376 base_url: None,
2377 }),
2378 gitlab: Some(GitLabConfig {
2379 url: "u".to_string(),
2380 project_id: "p".to_string(),
2381 }),
2382 clickup: Some(ClickUpConfig {
2383 list_id: "l".to_string(),
2384 team_id: None,
2385 }),
2386 jira: Some(JiraConfig {
2387 url: "u".to_string(),
2388 project_key: "k".to_string(),
2389 email: "e".to_string(),
2390 }),
2391 linear: Some(LinearConfig {
2392 url: "https://api.linear.app/graphql".to_string(),
2393 team_id: "team-1".to_string(),
2394 team_key: Some("ENG".to_string()),
2395 }),
2396 yougile: Some(YouGileConfig {
2397 url: default_yougile_url(),
2398 board_id: "board-1".to_string(),
2399 }),
2400 fireflies: None,
2401 confluence: None,
2402 slack: None,
2403 telegram: Some(TelegramConfig {
2404 base_url: Some("https://api.telegram.org".to_string()),
2405 bot_username: Some("devboy_bot".to_string()),
2406 }),
2407 contexts: BTreeMap::new(),
2408 active_context: None,
2409 proxy_mcp_servers: Vec::new(),
2410 builtin_tools: BuiltinToolsConfig::default(),
2411 format_pipeline: None,
2412 proxy: ProxyConfig::default(),
2413 sentry: None,
2414 remote_config: None,
2415 secrets: None,
2416 };
2417
2418 let providers = config.configured_providers();
2419 assert_eq!(providers.len(), 7);
2420 assert!(providers.contains(&"github"));
2421 assert!(providers.contains(&"gitlab"));
2422 assert!(providers.contains(&"clickup"));
2423 assert!(providers.contains(&"jira"));
2424 assert!(providers.contains(&"linear"));
2425 assert!(providers.contains(&"yougile"));
2426 assert!(providers.contains(&"telegram"));
2427 assert!(config.has_any_provider());
2428 }
2429
2430 #[test]
2431 fn test_legacy_default_context_includes_linear() {
2432 let config = Config {
2433 linear: Some(LinearConfig {
2434 url: "https://api.linear.app/graphql".to_string(),
2435 team_id: "team-legacy".to_string(),
2436 team_key: Some("OPS".to_string()),
2437 }),
2438 ..Config::default()
2439 };
2440
2441 let context = config
2442 .legacy_default_context()
2443 .expect("legacy default context should exist");
2444 let linear = context.linear.expect("linear should be present");
2445 assert_eq!(linear.team_id, "team-legacy");
2446 assert_eq!(linear.team_key.as_deref(), Some("OPS"));
2447 }
2448
2449 #[test]
2450 fn test_config_dir() {
2451 let dir = Config::config_dir().unwrap();
2453 assert!(dir.ends_with("devboy-tools"));
2454 }
2455
2456 #[test]
2457 fn test_config_path() {
2458 let path = Config::config_path().unwrap();
2460 assert!(path.ends_with("config.toml"));
2461 assert!(path.parent().unwrap().ends_with("devboy-tools"));
2462 }
2463
2464 #[test]
2465 fn test_load_default_path() {
2466 let dir = tempfile::tempdir().unwrap();
2468 let path = dir.path().join("config.toml");
2469 let config = Config::load_from(&path).unwrap();
2471 assert!(!config.has_any_provider());
2472 }
2473
2474 #[test]
2475 fn test_save_default_path() {
2476 let dir = tempfile::tempdir().unwrap();
2478 let path = dir.path().join("config.toml");
2479
2480 let config = Config {
2481 github: Some(GitHubConfig {
2482 owner: "test".to_string(),
2483 repo: "repo".to_string(),
2484 base_url: None,
2485 }),
2486 ..Default::default()
2487 };
2488
2489 config.save_to(&path).unwrap();
2490 assert!(path.exists());
2491
2492 let loaded = Config::load_from(&path).unwrap();
2494 assert_eq!(loaded.github.unwrap().owner, "test");
2495 }
2496
2497 #[test]
2498 fn test_toml_serialization() {
2499 let config = Config {
2500 github: Some(GitHubConfig {
2501 owner: "owner".to_string(),
2502 repo: "repo".to_string(),
2503 base_url: Some("https://github.example.com".to_string()),
2504 }),
2505 gitlab: Some(GitLabConfig {
2506 url: "https://gitlab.example.com".to_string(),
2507 project_id: "123".to_string(),
2508 }),
2509 clickup: None,
2510 jira: None,
2511 linear: None,
2512 yougile: None,
2513 fireflies: None,
2514 confluence: None,
2515 slack: None,
2516 telegram: Some(TelegramConfig {
2517 base_url: Some("https://api.telegram.org".to_string()),
2518 bot_username: Some("devboy_bot".to_string()),
2519 }),
2520 contexts: BTreeMap::new(),
2521 active_context: None,
2522 proxy_mcp_servers: Vec::new(),
2523 builtin_tools: BuiltinToolsConfig::default(),
2524 format_pipeline: None,
2525 proxy: ProxyConfig::default(),
2526 sentry: None,
2527 remote_config: None,
2528 secrets: None,
2529 };
2530
2531 let toml_str = toml::to_string_pretty(&config).unwrap();
2532 assert!(toml_str.contains("[github]"));
2533 assert!(toml_str.contains("[gitlab]"));
2534 assert!(toml_str.contains("[telegram]"));
2535 assert!(!toml_str.contains("[clickup]"));
2536 assert!(!toml_str.contains("[jira]"));
2537 assert!(!toml_str.contains("[yougile]"));
2538
2539 let parsed: Config = toml::from_str(&toml_str).unwrap();
2541 assert!(parsed.github.is_some());
2542 assert!(parsed.gitlab.is_some());
2543 }
2544
2545 #[test]
2546 fn test_contexts_and_active_context() {
2547 let mut config = Config::default();
2548 config.contexts.insert(
2549 "dashboard".to_string(),
2550 ContextConfig {
2551 github: Some(GitHubConfig {
2552 owner: "meteora-pro".to_string(),
2553 repo: "my-project".to_string(),
2554 base_url: None,
2555 }),
2556 clickup: Some(ClickUpConfig {
2557 list_id: "abc123".to_string(),
2558 team_id: None,
2559 }),
2560 ..Default::default()
2561 },
2562 );
2563
2564 let names = config.context_names();
2565 assert_eq!(names, vec!["dashboard".to_string()]);
2566
2567 config.set_active_context("dashboard").unwrap();
2568 assert_eq!(
2569 config.resolve_active_context_name(),
2570 Some("dashboard".to_string())
2571 );
2572 }
2573
2574 #[test]
2575 fn test_context_names_include_legacy_default() {
2576 let mut config = Config {
2577 github: Some(GitHubConfig {
2578 owner: "legacy-owner".to_string(),
2579 repo: "legacy-repo".to_string(),
2580 base_url: None,
2581 }),
2582 ..Default::default()
2583 };
2584 config
2585 .contexts
2586 .insert("workspace".to_string(), ContextConfig::default());
2587
2588 assert_eq!(
2589 config.context_names(),
2590 vec!["default".to_string(), "workspace".to_string()]
2591 );
2592 }
2593
2594 #[test]
2595 fn test_get_context_prefers_explicit_default_over_legacy() {
2596 let mut config = Config {
2597 github: Some(GitHubConfig {
2598 owner: "legacy-owner".to_string(),
2599 repo: "legacy-repo".to_string(),
2600 base_url: None,
2601 }),
2602 ..Default::default()
2603 };
2604 config.contexts.insert(
2605 Config::DEFAULT_CONTEXT_NAME.to_string(),
2606 ContextConfig {
2607 github: Some(GitHubConfig {
2608 owner: "explicit-owner".to_string(),
2609 repo: "explicit-repo".to_string(),
2610 base_url: None,
2611 }),
2612 ..Default::default()
2613 },
2614 );
2615
2616 let default_ctx = config.get_context(Config::DEFAULT_CONTEXT_NAME).unwrap();
2617 let gh = default_ctx.github.unwrap();
2618 assert_eq!(gh.owner, "explicit-owner");
2619 assert_eq!(gh.repo, "explicit-repo");
2620 }
2621
2622 #[test]
2623 fn test_resolve_active_context_fallbacks() {
2624 let mut config = Config {
2625 active_context: Some("missing".to_string()),
2626 github: Some(GitHubConfig {
2627 owner: "legacy-owner".to_string(),
2628 repo: "legacy-repo".to_string(),
2629 base_url: None,
2630 }),
2631 ..Default::default()
2632 };
2633 config
2634 .contexts
2635 .insert("beta".to_string(), ContextConfig::default());
2636 config
2637 .contexts
2638 .insert("alpha".to_string(), ContextConfig::default());
2639
2640 assert_eq!(
2641 config.resolve_active_context_name(),
2642 Some("default".to_string())
2643 );
2644
2645 config.github = None;
2646 assert_eq!(
2647 config.resolve_active_context_name(),
2648 Some("alpha".to_string())
2649 );
2650 }
2651
2652 #[test]
2653 fn test_set_active_context_unknown_context_errors() {
2654 let mut config = Config::default();
2655 let result = config.set_active_context("missing");
2656 assert!(result.is_err());
2657 assert!(result.unwrap_err().to_string().contains("Unknown context"));
2658 }
2659
2660 #[test]
2661 fn test_context_config_configured_providers() {
2662 let context = ContextConfig {
2663 github: Some(GitHubConfig {
2664 owner: "owner".to_string(),
2665 repo: "repo".to_string(),
2666 base_url: None,
2667 }),
2668 jira: Some(JiraConfig {
2669 url: "https://jira.example.com".to_string(),
2670 project_key: "DEV".to_string(),
2671 email: "dev@example.com".to_string(),
2672 }),
2673 yougile: Some(YouGileConfig {
2674 url: default_yougile_url(),
2675 board_id: "board-2".to_string(),
2676 }),
2677 ..Default::default()
2678 };
2679
2680 let providers = context.configured_providers();
2681 assert_eq!(providers, vec!["github", "jira", "yougile"]);
2682 assert!(context.has_any_provider());
2683 }
2684
2685 #[test]
2690 fn test_proxy_mcp_server_config_defaults() {
2691 let toml_str = r#"
2692 [[proxy_mcp_servers]]
2693 name = "my-server"
2694 url = "https://example.com/mcp"
2695 "#;
2696
2697 let config: Config = toml::from_str(toml_str).unwrap();
2698 assert_eq!(config.proxy_mcp_servers.len(), 1);
2699
2700 let proxy = &config.proxy_mcp_servers[0];
2701 assert_eq!(proxy.name, "my-server");
2702 assert_eq!(proxy.url, "https://example.com/mcp");
2703 assert_eq!(proxy.auth_type, "none");
2704 assert_eq!(proxy.transport, "sse");
2705 assert!(proxy.token_key.is_none());
2706 assert!(proxy.tool_prefix.is_none());
2707 }
2708
2709 #[test]
2710 fn test_proxy_mcp_server_config_full() {
2711 let toml_str = r#"
2712 [[proxy_mcp_servers]]
2713 name = "devboy-cloud"
2714 url = "https://app.devboy.pro/api/mcp"
2715 auth_type = "bearer"
2716 token_key = "devboy-cloud.token"
2717 tool_prefix = "cloud"
2718 transport = "streamable-http"
2719 "#;
2720
2721 let config: Config = toml::from_str(toml_str).unwrap();
2722 let proxy = &config.proxy_mcp_servers[0];
2723
2724 assert_eq!(proxy.name, "devboy-cloud");
2725 assert_eq!(proxy.auth_type, "bearer");
2726 assert_eq!(proxy.token_key.as_deref(), Some("devboy-cloud.token"));
2727 assert_eq!(proxy.tool_prefix.as_deref(), Some("cloud"));
2728 assert_eq!(proxy.transport, "streamable-http");
2729 }
2730
2731 #[test]
2732 fn test_proxy_mcp_server_config_oauth2_full() {
2733 let toml_str = r#"
2734 [[proxy_mcp_servers]]
2735 name = "devboy-cloud"
2736 url = "https://app.devboy.pro/api/mcp"
2737 auth_type = "oauth2"
2738 transport = "streamable-http"
2739
2740 [proxy_mcp_servers.oauth]
2741 client_id = "cli-abc123"
2742 scopes = ["mcp:read", "mcp:write"]
2743 "#;
2744
2745 let config: Config = toml::from_str(toml_str).unwrap();
2746 let proxy = &config.proxy_mcp_servers[0];
2747 assert_eq!(proxy.auth_type, "oauth2");
2748 let oauth = proxy.oauth.as_ref().expect("oauth block should parse");
2749 assert_eq!(oauth.client_id.as_deref(), Some("cli-abc123"));
2750 assert_eq!(
2751 oauth.scopes,
2752 Some(vec!["mcp:read".to_string(), "mcp:write".to_string()])
2753 );
2754 assert!(oauth.authorization_server.is_none());
2755 }
2756
2757 #[test]
2758 fn test_proxy_mcp_server_config_oauth2_minimal() {
2759 let toml_str = r#"
2762 [[proxy_mcp_servers]]
2763 name = "srv"
2764 url = "https://example.com/mcp"
2765 auth_type = "oauth2"
2766 "#;
2767 let config: Config = toml::from_str(toml_str).unwrap();
2768 let proxy = &config.proxy_mcp_servers[0];
2769 assert_eq!(proxy.auth_type, "oauth2");
2770 assert!(proxy.oauth.is_none());
2771 }
2772
2773 #[test]
2774 fn test_proxy_mcp_server_config_multiple() {
2775 let toml_str = r#"
2776 [[proxy_mcp_servers]]
2777 name = "server1"
2778 url = "https://s1.example.com/mcp"
2779
2780 [[proxy_mcp_servers]]
2781 name = "server2"
2782 url = "https://s2.example.com/mcp"
2783 auth_type = "api_key"
2784 token_key = "s2.token"
2785 "#;
2786
2787 let config: Config = toml::from_str(toml_str).unwrap();
2788 assert_eq!(config.proxy_mcp_servers.len(), 2);
2789 assert_eq!(config.proxy_mcp_servers[0].name, "server1");
2790 assert_eq!(config.proxy_mcp_servers[1].name, "server2");
2791 assert_eq!(config.proxy_mcp_servers[1].auth_type, "api_key");
2792 }
2793
2794 #[test]
2795 fn test_proxy_mcp_server_config_serialization_roundtrip() {
2796 let config = Config {
2797 proxy_mcp_servers: vec![ProxyMcpServerConfig {
2798 name: "test".to_string(),
2799 url: "https://test.com/mcp".to_string(),
2800 auth_type: "bearer".to_string(),
2801 token_key: Some("test.token".to_string()),
2802 tool_prefix: Some("tst".to_string()),
2803 transport: "streamable-http".to_string(),
2804 routing: None,
2805 oauth: None,
2806 }],
2807 ..Default::default()
2808 };
2809
2810 let toml_str = toml::to_string_pretty(&config).unwrap();
2811 assert!(toml_str.contains("[[proxy_mcp_servers]]"));
2812 assert!(toml_str.contains("name = \"test\""));
2813
2814 let parsed: Config = toml::from_str(&toml_str).unwrap();
2815 assert_eq!(parsed.proxy_mcp_servers.len(), 1);
2816 assert_eq!(parsed.proxy_mcp_servers[0].name, "test");
2817 assert_eq!(parsed.proxy_mcp_servers[0].transport, "streamable-http");
2818 }
2819
2820 #[test]
2821 fn test_proxy_mcp_server_config_skips_none_fields_in_serialization() {
2822 let config = Config {
2823 proxy_mcp_servers: vec![ProxyMcpServerConfig {
2824 name: "minimal".to_string(),
2825 url: "https://test.com/mcp".to_string(),
2826 auth_type: "none".to_string(),
2827 token_key: None,
2828 tool_prefix: None,
2829 transport: "sse".to_string(),
2830 routing: None,
2831 oauth: None,
2832 }],
2833 ..Default::default()
2834 };
2835
2836 let toml_str = toml::to_string_pretty(&config).unwrap();
2837 assert!(!toml_str.contains("token_key"));
2838 assert!(!toml_str.contains("tool_prefix"));
2839 }
2840
2841 #[test]
2842 fn test_empty_proxy_mcp_servers_not_serialized() {
2843 let config = Config::default();
2844 let toml_str = toml::to_string_pretty(&config).unwrap();
2845 assert!(!toml_str.contains("proxy_mcp_servers"));
2846 }
2847
2848 #[test]
2853 fn test_proxy_config_default_is_default() {
2854 let cfg = ProxyConfig::default();
2855 assert!(cfg.is_default());
2856 }
2857
2858 #[test]
2859 fn test_default_proxy_section_not_serialized() {
2860 let config = Config::default();
2861 let toml_str = toml::to_string_pretty(&config).unwrap();
2862 assert!(!toml_str.contains("[proxy]"));
2863 assert!(!toml_str.contains("[proxy.routing]"));
2864 }
2865
2866 #[test]
2867 fn test_routing_strategy_default_is_remote() {
2868 let strategy = RoutingStrategy::default();
2869 assert_eq!(strategy, RoutingStrategy::Remote);
2870 }
2871
2872 #[test]
2873 fn test_routing_strategy_parse_tolerates_formats() {
2874 assert_eq!(
2875 RoutingStrategy::parse("remote"),
2876 Some(RoutingStrategy::Remote)
2877 );
2878 assert_eq!(
2879 RoutingStrategy::parse(" REMOTE "),
2880 Some(RoutingStrategy::Remote)
2881 );
2882 assert_eq!(
2883 RoutingStrategy::parse("local"),
2884 Some(RoutingStrategy::Local)
2885 );
2886 assert_eq!(
2887 RoutingStrategy::parse("local-first"),
2888 Some(RoutingStrategy::LocalFirst)
2889 );
2890 assert_eq!(
2891 RoutingStrategy::parse("local_first"),
2892 Some(RoutingStrategy::LocalFirst)
2893 );
2894 assert_eq!(
2895 RoutingStrategy::parse("remote-first"),
2896 Some(RoutingStrategy::RemoteFirst)
2897 );
2898 assert_eq!(RoutingStrategy::parse("unknown"), None);
2899 }
2900
2901 #[test]
2902 fn test_routing_strategy_serde_kebab_case() {
2903 let toml_str = r#"
2904 [proxy.routing]
2905 strategy = "local-first"
2906 "#;
2907 let config: Config = toml::from_str(toml_str).unwrap();
2908 assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2909
2910 let serialized = toml::to_string_pretty(&config).unwrap();
2912 assert!(serialized.contains("strategy = \"local-first\""));
2913 }
2914
2915 #[test]
2916 fn test_proxy_routing_strategy_for_picks_first_matching_override() {
2917 let routing = ProxyRoutingConfig {
2918 strategy: RoutingStrategy::Remote,
2919 fallback_on_error: true,
2920 tool_overrides: vec![
2921 ProxyToolRule {
2922 pattern: "create_*".to_string(),
2923 strategy: RoutingStrategy::Remote,
2924 },
2925 ProxyToolRule {
2926 pattern: "get_*".to_string(),
2927 strategy: RoutingStrategy::LocalFirst,
2928 },
2929 ProxyToolRule {
2930 pattern: "*".to_string(),
2931 strategy: RoutingStrategy::Local,
2932 },
2933 ],
2934 };
2935
2936 assert_eq!(
2937 routing.strategy_for("create_issue"),
2938 RoutingStrategy::Remote
2939 );
2940 assert_eq!(
2941 routing.strategy_for("get_issues"),
2942 RoutingStrategy::LocalFirst
2943 );
2944 assert_eq!(
2945 routing.strategy_for("anything_else"),
2946 RoutingStrategy::Local
2947 );
2948 }
2949
2950 #[test]
2951 fn test_proxy_routing_strategy_for_falls_back_to_global() {
2952 let routing = ProxyRoutingConfig {
2953 strategy: RoutingStrategy::Remote,
2954 fallback_on_error: true,
2955 tool_overrides: vec![ProxyToolRule {
2956 pattern: "get_*".to_string(),
2957 strategy: RoutingStrategy::LocalFirst,
2958 }],
2959 };
2960
2961 assert_eq!(
2962 routing.strategy_for("unrelated_tool"),
2963 RoutingStrategy::Remote
2964 );
2965 }
2966
2967 #[test]
2968 fn test_proxy_routing_merged_with_override_wins() {
2969 let global = ProxyRoutingConfig {
2970 strategy: RoutingStrategy::Remote,
2971 fallback_on_error: true,
2972 tool_overrides: vec![ProxyToolRule {
2973 pattern: "get_*".to_string(),
2974 strategy: RoutingStrategy::LocalFirst,
2975 }],
2976 };
2977 let override_cfg = ProxyRoutingOverride {
2978 strategy: Some(RoutingStrategy::Local),
2979 fallback_on_error: Some(false),
2980 tool_overrides: Some(vec![ProxyToolRule {
2981 pattern: "create_*".to_string(),
2982 strategy: RoutingStrategy::Remote,
2983 }]),
2984 };
2985
2986 let merged = global.merged_with(Some(&override_cfg));
2987 assert_eq!(merged.strategy, RoutingStrategy::Local);
2988 assert!(!merged.fallback_on_error);
2989 assert_eq!(merged.tool_overrides.len(), 2);
2991 assert_eq!(merged.tool_overrides[0].pattern, "create_*");
2992 assert_eq!(merged.tool_overrides[1].pattern, "get_*");
2993 }
2994
2995 #[test]
2996 fn test_proxy_routing_merged_with_partial_override_preserves_unset_fields() {
2997 let global = ProxyRoutingConfig {
3000 strategy: RoutingStrategy::Remote,
3001 fallback_on_error: false, tool_overrides: vec![ProxyToolRule {
3003 pattern: "get_*".to_string(),
3004 strategy: RoutingStrategy::LocalFirst,
3005 }],
3006 };
3007 let override_cfg = ProxyRoutingOverride {
3009 strategy: Some(RoutingStrategy::Local),
3010 fallback_on_error: None,
3011 tool_overrides: None,
3012 };
3013
3014 let merged = global.merged_with(Some(&override_cfg));
3015 assert_eq!(merged.strategy, RoutingStrategy::Local);
3016 assert!(
3017 !merged.fallback_on_error,
3018 "fallback_on_error must inherit from global, not snap to default"
3019 );
3020 assert_eq!(
3021 merged.tool_overrides.len(),
3022 1,
3023 "tool_overrides must inherit from global when override omits them"
3024 );
3025 assert_eq!(merged.tool_overrides[0].pattern, "get_*");
3026 }
3027
3028 #[test]
3029 fn test_proxy_routing_merged_with_none_returns_clone() {
3030 let global = ProxyRoutingConfig {
3031 strategy: RoutingStrategy::LocalFirst,
3032 ..Default::default()
3033 };
3034 let merged = global.merged_with(None);
3035 assert_eq!(merged.strategy, RoutingStrategy::LocalFirst);
3036 }
3037
3038 #[test]
3039 fn test_proxy_secrets_default_cache_ttl() {
3040 let s = ProxySecretsConfig::default();
3041 assert_eq!(s.cache_ttl_secs, 300);
3042 assert!(s.is_default());
3043 }
3044
3045 #[test]
3046 fn test_proxy_telemetry_defaults() {
3047 let t = ProxyTelemetryConfig::default();
3048 assert!(t.enabled);
3049 assert_eq!(t.batch_size, 100);
3050 assert_eq!(t.batch_interval_secs, 30);
3051 assert!(t.endpoint.is_none());
3052 assert!(t.is_default());
3053 }
3054
3055 #[test]
3056 fn test_proxy_toml_parse_full() {
3057 let toml_str = r#"
3058 [proxy.routing]
3059 strategy = "local-first"
3060 fallback_on_error = false
3061
3062 [[proxy.routing.tool_overrides]]
3063 pattern = "create_*"
3064 strategy = "remote"
3065
3066 [proxy.secrets]
3067 cache_ttl_secs = 120
3068
3069 [proxy.telemetry]
3070 enabled = true
3071 batch_size = 50
3072 batch_interval_secs = 10
3073 endpoint = "https://telemetry.example.com/api/events"
3074 "#;
3075
3076 let config: Config = toml::from_str(toml_str).unwrap();
3077 assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
3078 assert!(!config.proxy.routing.fallback_on_error);
3079 assert_eq!(config.proxy.routing.tool_overrides.len(), 1);
3080 assert_eq!(config.proxy.secrets.cache_ttl_secs, 120);
3081 assert_eq!(config.proxy.telemetry.batch_size, 50);
3082 assert_eq!(
3083 config.proxy.telemetry.endpoint.as_deref(),
3084 Some("https://telemetry.example.com/api/events")
3085 );
3086 }
3087
3088 #[test]
3089 fn test_proxy_mcp_server_per_server_routing_override() {
3090 let toml_str = r#"
3091 [[proxy_mcp_servers]]
3092 name = "cloud"
3093 url = "https://api.example.com/mcp"
3094
3095 [proxy_mcp_servers.routing]
3096 strategy = "local-first"
3097 "#;
3098
3099 let config: Config = toml::from_str(toml_str).unwrap();
3100 let server = &config.proxy_mcp_servers[0];
3101 let override_cfg = server.routing.as_ref().expect("override present");
3102 assert_eq!(override_cfg.strategy, Some(RoutingStrategy::LocalFirst));
3104 assert!(override_cfg.fallback_on_error.is_none());
3105 assert!(override_cfg.tool_overrides.is_none());
3106 }
3107
3108 #[test]
3113 fn test_set_get_proxy_routing_strategy_roundtrip() {
3114 let mut cfg = Config::default();
3115 cfg.set("proxy.routing.strategy", "local-first").unwrap();
3116 assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
3117 assert_eq!(
3118 cfg.get("proxy.routing.strategy").unwrap().as_deref(),
3119 Some("local-first")
3120 );
3121
3122 cfg.set("proxy.routing.strategy", "remote").unwrap();
3123 assert_eq!(
3124 cfg.get("proxy.routing.strategy").unwrap().as_deref(),
3125 Some("remote")
3126 );
3127 }
3128
3129 #[test]
3130 fn test_set_proxy_routing_strategy_rejects_garbage() {
3131 let mut cfg = Config::default();
3132 let err = cfg
3133 .set("proxy.routing.strategy", "teleport")
3134 .unwrap_err()
3135 .to_string();
3136 assert!(err.contains("Invalid routing strategy"));
3137 }
3138
3139 #[test]
3140 fn test_set_proxy_routing_booleans_accept_many_forms() {
3141 let mut cfg = Config::default();
3142 for truthy in ["true", "TRUE", "1", "yes", "on"] {
3143 cfg.set("proxy.routing.fallback_on_error", truthy).unwrap();
3144 assert!(cfg.proxy.routing.fallback_on_error);
3145 }
3146 for falsy in ["false", "0", "no", "off"] {
3147 cfg.set("proxy.routing.fallback_on_error", falsy).unwrap();
3148 assert!(!cfg.proxy.routing.fallback_on_error);
3149 }
3150 }
3151
3152 #[test]
3153 fn test_set_proxy_secrets_cache_ttl() {
3154 let mut cfg = Config::default();
3155 cfg.set("proxy.secrets.cache_ttl_secs", "120").unwrap();
3156 assert_eq!(cfg.proxy.secrets.cache_ttl_secs, 120);
3157 assert_eq!(
3158 cfg.get("proxy.secrets.cache_ttl_secs").unwrap().as_deref(),
3159 Some("120")
3160 );
3161
3162 assert!(cfg.set("proxy.secrets.cache_ttl_secs", "-5").is_err());
3163 }
3164
3165 #[test]
3166 fn test_set_proxy_telemetry_endpoint_and_clear() {
3167 let mut cfg = Config::default();
3168 cfg.set("proxy.telemetry.endpoint", "https://example.com/t")
3169 .unwrap();
3170 assert_eq!(
3171 cfg.proxy.telemetry.endpoint.as_deref(),
3172 Some("https://example.com/t")
3173 );
3174
3175 cfg.set("proxy.telemetry.endpoint", "").unwrap();
3177 assert!(cfg.proxy.telemetry.endpoint.is_none());
3178 }
3179
3180 #[test]
3181 fn test_set_proxy_telemetry_endpoint_rejects_garbage() {
3182 let mut cfg = Config::default();
3183 for bad in [
3184 "not-a-url",
3185 "ftp://host.example.com",
3186 "//example.com",
3187 "https://",
3188 "http:// space.example.com",
3189 "https://example.com/a b",
3191 "https://example.com/path?key=a b",
3192 "https://example.com/\tpath",
3193 "https://example.com/ ",
3194 ] {
3195 match cfg.set("proxy.telemetry.endpoint", bad) {
3196 Ok(()) => panic!("expected reject for {}", bad),
3197 Err(e) => assert!(
3198 e.to_string().contains("Invalid URL"),
3199 "bad={}, err={}",
3200 bad,
3201 e
3202 ),
3203 }
3204 }
3205 }
3206
3207 #[test]
3208 fn test_set_proxy_telemetry_endpoint_accepts_common_forms() {
3209 let mut cfg = Config::default();
3210 for good in [
3211 "https://app.example.com/api/telemetry/tool-invocations",
3212 "http://localhost:4335/api/telemetry/tool-invocations",
3213 "https://example.com",
3214 "http://10.0.0.1:8080/",
3215 ] {
3216 cfg.set("proxy.telemetry.endpoint", good)
3217 .unwrap_or_else(|e| panic!("expected accept for {}: {}", good, e));
3218 }
3219 }
3220
3221 #[test]
3226 fn test_validate_rejects_bad_endpoint_from_toml() {
3227 let toml_str = r#"
3230 [proxy.telemetry]
3231 endpoint = "not-a-url"
3232 "#;
3233 let config: Config = toml::from_str(toml_str).unwrap();
3234 let err = config
3235 .validate()
3236 .expect_err("expected validation to fail for 'not-a-url'");
3237 assert!(
3238 err.to_string().contains("Invalid URL"),
3239 "unexpected error: {}",
3240 err
3241 );
3242 }
3243
3244 #[test]
3245 fn test_validate_accepts_empty_endpoint_as_absent() {
3246 let config = Config::default();
3249 config.validate().expect("default config validates");
3250 }
3251
3252 #[test]
3253 fn test_sanitize_normalizes_empty_endpoint_to_none() {
3254 let mut config: Config = toml::from_str(
3258 r#"
3259[proxy.telemetry]
3260endpoint = ""
3261"#,
3262 )
3263 .unwrap();
3264 assert_eq!(config.proxy.telemetry.endpoint.as_deref(), Some(""));
3265 config.sanitize();
3266 assert!(config.proxy.telemetry.endpoint.is_none());
3267 config.validate().expect("sanitized config must validate");
3268 }
3269
3270 #[test]
3271 fn test_load_from_sanitizes_empty_endpoint() {
3272 use std::fs::write;
3273 let dir = tempfile::tempdir().unwrap();
3274 let path = dir.path().join("config.toml");
3275 write(
3276 &path,
3277 r#"
3278[proxy.telemetry]
3279endpoint = ""
3280"#,
3281 )
3282 .unwrap();
3283
3284 let cfg = Config::load_from(&path).expect("empty endpoint must be normalised on load");
3285 assert!(
3286 cfg.proxy.telemetry.endpoint.is_none(),
3287 "empty string must load as None, not Some(\"\")"
3288 );
3289 }
3290
3291 #[test]
3292 fn test_validate_rejects_naked_empty_string_endpoint() {
3293 let mut config = Config::default();
3296 config.proxy.telemetry.endpoint = Some(String::new());
3297 let err = config
3298 .validate()
3299 .expect_err("empty string must be rejected if caller skipped sanitize");
3300 assert!(
3301 err.to_string().contains("Invalid URL"),
3302 "unexpected error: {}",
3303 err
3304 );
3305 }
3306
3307 #[test]
3308 fn test_load_from_runs_validation() {
3309 use std::fs::write;
3310 let dir = tempfile::tempdir().unwrap();
3311 let path = dir.path().join("config.toml");
3312 write(
3313 &path,
3314 r#"
3315[proxy.telemetry]
3316endpoint = "ftp://wrong-scheme.example.com"
3317"#,
3318 )
3319 .unwrap();
3320
3321 let err = Config::load_from(&path).expect_err("must reject bad URL from file");
3322 assert!(
3323 err.to_string().contains("Invalid URL"),
3324 "unexpected error: {}",
3325 err
3326 );
3327 }
3328
3329 #[test]
3334 fn test_unknown_field_in_proxy_routing_rejected() {
3335 let toml_str = r#"
3336 [proxy.routing]
3337 strategy = "local-first"
3338 startegy = "typo"
3339 "#;
3340 let err = toml::from_str::<Config>(toml_str)
3341 .expect_err("expected parse error for typo 'startegy'");
3342 let msg = err.to_string();
3343 assert!(
3344 msg.contains("startegy") || msg.contains("unknown field"),
3345 "unexpected error: {}",
3346 msg
3347 );
3348 }
3349
3350 #[test]
3351 fn test_unknown_field_in_proxy_secrets_rejected() {
3352 let toml_str = r#"
3353 [proxy.secrets]
3354 cache_ttl_secs = 60
3355 chache_ttl_secs = 120
3356 "#;
3357 let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
3358 assert!(
3359 err.to_string().contains("chache_ttl_secs")
3360 || err.to_string().contains("unknown field")
3361 );
3362 }
3363
3364 #[test]
3365 fn test_unknown_field_in_proxy_telemetry_rejected() {
3366 let toml_str = r#"
3367 [proxy.telemetry]
3368 enabled = true
3369 endpooint = "https://example.com"
3370 "#;
3371 let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
3372 assert!(err.to_string().contains("endpooint") || err.to_string().contains("unknown field"));
3373 }
3374
3375 #[test]
3376 fn test_unknown_field_in_tool_override_rejected() {
3377 let toml_str = r#"
3378 [[proxy.routing.tool_overrides]]
3379 pattern = "get_*"
3380 strategy = "local"
3381 unknown = 1
3382 "#;
3383 let err = toml::from_str::<Config>(toml_str).expect_err("typo in rule must fail");
3384 assert!(err.to_string().contains("unknown"));
3385 }
3386
3387 #[test]
3388 fn test_unknown_top_level_proxy_section_rejected() {
3389 let toml_str = r#"
3391 [proxy.typo]
3392 foo = 1
3393 "#;
3394 let err = toml::from_str::<Config>(toml_str).expect_err("unknown section must fail");
3395 let msg = err.to_string();
3396 assert!(msg.contains("typo") || msg.contains("unknown field"));
3397 }
3398
3399 #[test]
3400 fn test_load_from_accepts_valid_proxy_config() {
3401 use std::fs::write;
3402 let dir = tempfile::tempdir().unwrap();
3403 let path = dir.path().join("config.toml");
3404 write(
3405 &path,
3406 r#"
3407[proxy.routing]
3408strategy = "local-first"
3409
3410[proxy.telemetry]
3411endpoint = "https://app.example.com/api/telemetry/tool-invocations"
3412"#,
3413 )
3414 .unwrap();
3415
3416 let cfg = Config::load_from(&path).expect("valid config must load");
3417 assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
3418 assert_eq!(
3419 cfg.proxy.telemetry.endpoint.as_deref(),
3420 Some("https://app.example.com/api/telemetry/tool-invocations")
3421 );
3422 }
3423
3424 #[test]
3425 fn test_set_proxy_telemetry_batch_fields() {
3426 let mut cfg = Config::default();
3427 cfg.set("proxy.telemetry.batch_size", "50").unwrap();
3428 cfg.set("proxy.telemetry.batch_interval_secs", "15")
3429 .unwrap();
3430 cfg.set("proxy.telemetry.offline_queue_max", "2000")
3431 .unwrap();
3432
3433 assert_eq!(cfg.proxy.telemetry.batch_size, 50);
3434 assert_eq!(cfg.proxy.telemetry.batch_interval_secs, 15);
3435 assert_eq!(cfg.proxy.telemetry.offline_queue_max, 2000);
3436 }
3437
3438 #[test]
3439 fn test_unknown_proxy_section_or_field_errors() {
3440 let mut cfg = Config::default();
3441 assert!(cfg.set("proxy.unknown.foo", "1").is_err());
3442 assert!(cfg.set("proxy.routing.unknown", "1").is_err());
3443 assert!(cfg.get("proxy.unknown.foo").is_err());
3444 assert!(cfg.get("proxy.routing.unknown").is_err());
3445 }
3446
3447 #[test]
3448 fn test_four_part_key_rejected() {
3449 let mut cfg = Config::default();
3450 assert!(cfg.set("proxy.routing.strategy.extra", "local").is_err());
3451 }
3452
3453 #[test]
3458 fn test_legacy_config_without_proxy_section_still_parses() {
3459 let toml_str = r#"
3461 [github]
3462 owner = "me"
3463 repo = "repo"
3464
3465 [[proxy_mcp_servers]]
3466 name = "cloud"
3467 url = "https://api.example.com/mcp"
3468 "#;
3469 let config: Config = toml::from_str(toml_str).unwrap();
3470 assert_eq!(config.github.unwrap().owner, "me");
3471 assert_eq!(config.proxy_mcp_servers.len(), 1);
3472 assert!(config.proxy.is_default());
3473 }
3474
3475 #[test]
3480 fn test_matches_glob_exact() {
3481 assert!(matches_glob("get_issues", "get_issues"));
3482 assert!(!matches_glob("get_issues", "get_issue"));
3483 assert!(!matches_glob("get_issues", "gets_issues"));
3484 }
3485
3486 #[test]
3487 fn test_matches_glob_star_alone() {
3488 assert!(matches_glob("*", ""));
3489 assert!(matches_glob("*", "anything"));
3490 assert!(matches_glob("*", "create_merge_request"));
3491 }
3492
3493 #[test]
3494 fn test_matches_glob_prefix() {
3495 assert!(matches_glob("get_*", "get_issues"));
3496 assert!(matches_glob("get_*", "get_"));
3497 assert!(!matches_glob("get_*", "create_issues"));
3498 }
3499
3500 #[test]
3501 fn test_matches_glob_suffix() {
3502 assert!(matches_glob("*_issue", "create_issue"));
3503 assert!(matches_glob("*_issue", "_issue"));
3504 assert!(!matches_glob("*_issue", "create_issues"));
3505 }
3506
3507 #[test]
3508 fn test_matches_glob_contains() {
3509 assert!(matches_glob("*issue*", "get_issues"));
3510 assert!(matches_glob("*issue*", "issue"));
3511 assert!(!matches_glob("*issue*", "merge_request"));
3512 }
3513
3514 #[test]
3515 fn test_matches_glob_multiple_wildcards() {
3516 assert!(matches_glob("get_*_by_*", "get_issue_by_id"));
3517 assert!(matches_glob("get_*_by_*", "get_user_by_email"));
3518 assert!(!matches_glob("get_*_by_*", "get_issue"));
3519 assert!(!matches_glob("get_*_by_*", "create_issue_by_id"));
3520 }
3521
3522 #[test]
3523 fn test_matches_glob_collapses_double_star() {
3524 assert!(matches_glob("get_**_issue", "get_new_issue"));
3525 }
3526
3527 #[test]
3532 fn test_builtin_tools_config_default_is_empty() {
3533 let config = BuiltinToolsConfig::default();
3534 assert!(config.is_empty());
3535 assert!(config.validate().is_ok());
3536 assert!(config.is_tool_allowed("get_issues"));
3537 }
3538
3539 #[test]
3540 fn test_builtin_tools_disabled_mode() {
3541 let config = BuiltinToolsConfig {
3542 disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
3543 enabled: vec![],
3544 };
3545 assert!(!config.is_empty());
3546 assert!(config.validate().is_ok());
3547 assert!(!config.is_tool_allowed("get_issues"));
3548 assert!(!config.is_tool_allowed("create_issue"));
3549 assert!(config.is_tool_allowed("get_merge_requests"));
3550 assert!(config.is_tool_allowed("list_contexts"));
3551 }
3552
3553 #[test]
3554 fn test_builtin_tools_enabled_mode() {
3555 let config = BuiltinToolsConfig {
3556 disabled: vec![],
3557 enabled: vec![
3558 "list_contexts".to_string(),
3559 "use_context".to_string(),
3560 "get_current_context".to_string(),
3561 ],
3562 };
3563 assert!(!config.is_empty());
3564 assert!(config.validate().is_ok());
3565 assert!(config.is_tool_allowed("list_contexts"));
3566 assert!(config.is_tool_allowed("use_context"));
3567 assert!(!config.is_tool_allowed("get_issues"));
3568 assert!(!config.is_tool_allowed("create_issue"));
3569 }
3570
3571 #[test]
3572 fn test_builtin_tools_mutually_exclusive_error() {
3573 let config = BuiltinToolsConfig {
3574 disabled: vec!["get_issues".to_string()],
3575 enabled: vec!["list_contexts".to_string()],
3576 };
3577 assert!(config.validate().is_err());
3578 let err = config.validate().unwrap_err().to_string();
3579 assert!(err.contains("mutually exclusive"));
3580 }
3581
3582 #[test]
3583 fn test_builtin_tools_toml_parsing_disabled() {
3584 let toml_str = r#"
3585 [builtin_tools]
3586 disabled = ["get_issues", "create_issue"]
3587 "#;
3588 let config: Config = toml::from_str(toml_str).unwrap();
3589 assert!(!config.builtin_tools.is_empty());
3590 assert_eq!(config.builtin_tools.disabled.len(), 2);
3591 assert!(config.builtin_tools.enabled.is_empty());
3592 }
3593
3594 #[test]
3595 fn test_builtin_tools_toml_parsing_enabled() {
3596 let toml_str = r#"
3597 [builtin_tools]
3598 enabled = ["list_contexts", "use_context", "get_current_context"]
3599 "#;
3600 let config: Config = toml::from_str(toml_str).unwrap();
3601 assert_eq!(config.builtin_tools.enabled.len(), 3);
3602 assert!(config.builtin_tools.disabled.is_empty());
3603 }
3604
3605 #[test]
3606 fn test_builtin_tools_not_serialized_when_empty() {
3607 let config = Config::default();
3608 let toml_str = toml::to_string_pretty(&config).unwrap();
3609 assert!(!toml_str.contains("builtin_tools"));
3610 }
3611
3612 #[test]
3613 fn test_builtin_tools_serialization_roundtrip() {
3614 let config = Config {
3615 builtin_tools: BuiltinToolsConfig {
3616 disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
3617 enabled: vec![],
3618 },
3619 ..Default::default()
3620 };
3621 let toml_str = toml::to_string_pretty(&config).unwrap();
3622 assert!(toml_str.contains("[builtin_tools]"));
3623 assert!(toml_str.contains("get_issues"));
3624
3625 let parsed: Config = toml::from_str(&toml_str).unwrap();
3626 assert_eq!(parsed.builtin_tools.disabled.len(), 2);
3627 }
3628
3629 #[test]
3630 fn test_builtin_tools_warn_unknown_with_unknown_names() {
3631 let known = &["get_issues", "create_issue"];
3632 let config = BuiltinToolsConfig {
3633 disabled: vec!["get_issues".to_string(), "nonexistent_tool".to_string()],
3634 enabled: vec![],
3635 };
3636 config.warn_unknown_tools(known);
3638 }
3639
3640 #[test]
3641 fn test_builtin_tools_warn_unknown_all_known() {
3642 let known = &["get_issues", "create_issue"];
3643 let config = BuiltinToolsConfig {
3644 disabled: vec!["get_issues".to_string()],
3645 enabled: vec![],
3646 };
3647 config.warn_unknown_tools(known);
3649 }
3650
3651 #[test]
3652 fn test_builtin_tools_warn_unknown_in_enabled_list() {
3653 let known = &["get_issues", "create_issue"];
3654 let config = BuiltinToolsConfig {
3655 disabled: vec![],
3656 enabled: vec!["get_issues".to_string(), "unknown_tool".to_string()],
3657 };
3658 config.warn_unknown_tools(known);
3660 }
3661
3662 #[test]
3663 fn test_builtin_tools_warn_unknown_empty_config() {
3664 let known = &["get_issues"];
3665 let config = BuiltinToolsConfig::default();
3666 config.warn_unknown_tools(known);
3668 }
3669}