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")]
60 pub fireflies: Option<FirefliesConfig>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub confluence: Option<ConfluenceConfig>,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub slack: Option<SlackConfig>,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub telegram: Option<TelegramConfig>,
73
74 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
76 pub contexts: BTreeMap<String, ContextConfig>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub active_context: Option<String>,
81
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub proxy_mcp_servers: Vec<ProxyMcpServerConfig>,
85
86 #[serde(default, skip_serializing_if = "BuiltinToolsConfig::is_empty")]
88 pub builtin_tools: BuiltinToolsConfig,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub format_pipeline: Option<FormatPipelineConfig>,
93
94 #[serde(default, skip_serializing_if = "ProxyConfig::is_default")]
97 pub proxy: ProxyConfig,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub sentry: Option<SentryConfig>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub remote_config: Option<RemoteConfigSettings>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub secrets: Option<SecretsConfig>,
116}
117
118impl Config {
119 pub fn is_secrets_migration_complete(&self) -> bool {
123 self.secrets
124 .as_ref()
125 .map(|s| s.migration_complete)
126 .unwrap_or(false)
127 }
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
136pub struct SecretsConfig {
137 #[serde(default)]
145 pub migration_complete: bool,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ProxyMcpServerConfig {
151 pub name: String,
153 pub url: String,
155 #[serde(default = "default_auth_none")]
157 pub auth_type: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub token_key: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub tool_prefix: Option<String>,
164 #[serde(default = "default_transport_sse")]
166 pub transport: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub routing: Option<ProxyRoutingOverride>,
173}
174
175fn default_transport_sse() -> String {
176 "sse".to_string()
177}
178
179fn default_auth_none() -> String {
180 "none".to_string()
181}
182
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185pub struct ContextConfig {
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub github: Option<GitHubConfig>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub gitlab: Option<GitLabConfig>,
191
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub clickup: Option<ClickUpConfig>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub jira: Option<JiraConfig>,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub fireflies: Option<FirefliesConfig>,
201
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub confluence: Option<ConfluenceConfig>,
205
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub slack: Option<SlackConfig>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub telegram: Option<TelegramConfig>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct GitHubConfig {
217 pub owner: String,
219 pub repo: String,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub base_url: Option<String>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct GitLabConfig {
227 #[serde(default = "default_gitlab_url")]
229 pub url: String,
230 pub project_id: String,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct ClickUpConfig {
236 pub list_id: String,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub team_id: Option<String>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct JiraConfig {
244 pub url: String,
246 pub project_key: String,
248 pub email: String,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct FirefliesConfig {
255 }
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct ConfluenceConfig {
261 pub base_url: String,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub api_version: Option<String>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub username: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub space_key: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct SlackConfig {
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub team_id: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub workspace: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub base_url: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub client_id: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub redirect_uri: Option<String>,
291 #[serde(
293 default = "default_slack_required_scopes",
294 skip_serializing_if = "is_default_slack_required_scopes"
295 )]
296 pub required_scopes: Vec<String>,
297}
298
299impl Default for SlackConfig {
300 fn default() -> Self {
301 Self {
302 team_id: None,
303 workspace: None,
304 base_url: None,
305 client_id: None,
306 redirect_uri: None,
307 required_scopes: default_slack_required_scopes(),
308 }
309 }
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct TelegramConfig {
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub base_url: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub bot_username: Option<String>,
321}
322
323pub fn default_slack_required_scopes() -> Vec<String> {
324 vec![
325 "channels:read".to_string(),
326 "channels:history".to_string(),
327 "groups:read".to_string(),
328 "groups:history".to_string(),
329 "im:read".to_string(),
330 "im:history".to_string(),
331 "mpim:read".to_string(),
332 "mpim:history".to_string(),
333 "chat:write".to_string(),
334 "users:read".to_string(),
335 ]
336}
337
338fn is_default_slack_required_scopes(scopes: &[String]) -> bool {
339 scopes == default_slack_required_scopes().as_slice()
340}
341
342#[derive(Debug, Clone, Default, Serialize, Deserialize)]
348pub struct BuiltinToolsConfig {
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub disabled: Vec<String>,
352
353 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 pub enabled: Vec<String>,
356}
357
358impl BuiltinToolsConfig {
359 pub fn is_empty(&self) -> bool {
361 self.disabled.is_empty() && self.enabled.is_empty()
362 }
363
364 pub fn validate(&self) -> Result<()> {
366 if !self.disabled.is_empty() && !self.enabled.is_empty() {
367 return Err(Error::Config(
368 "builtin_tools: 'disabled' and 'enabled' are mutually exclusive, use only one"
369 .to_string(),
370 ));
371 }
372 Ok(())
373 }
374
375 pub fn is_tool_allowed(&self, name: &str) -> bool {
377 if !self.enabled.is_empty() {
378 return self.enabled.iter().any(|n| n == name);
379 }
380 if !self.disabled.is_empty() {
381 return !self.disabled.iter().any(|n| n == name);
382 }
383 true
384 }
385
386 pub fn warn_unknown_tools(&self, known: &[&str]) {
388 for name in self.disabled.iter().chain(self.enabled.iter()) {
389 if !known.iter().any(|k| k == name) {
390 tracing::warn!(
391 "builtin_tools: unknown tool name '{}', it will have no effect",
392 name
393 );
394 }
395 }
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct FormatPipelineConfig {
425 #[serde(default = "default_budget_tokens")]
428 pub budget_tokens: usize,
429
430 #[serde(default = "default_margin")]
433 pub margin: f64,
434
435 #[serde(default = "default_max_iterations")]
438 pub max_iterations: usize,
439
440 #[serde(default = "default_format_toon")]
442 pub default_format: String,
443
444 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
449 pub strategies: HashMap<String, String>,
450
451 #[serde(default)]
452 pub proxy_matching: ProxyMatchingConfig,
453}
454
455impl Default for FormatPipelineConfig {
456 fn default() -> Self {
457 Self {
458 budget_tokens: default_budget_tokens(),
459 margin: default_margin(),
460 max_iterations: default_max_iterations(),
461 default_format: default_format_toon(),
462 strategies: HashMap::new(),
463 proxy_matching: ProxyMatchingConfig::default(),
464 }
465 }
466}
467
468fn default_budget_tokens() -> usize {
469 8000
470}
471
472fn default_margin() -> f64 {
473 0.20
474}
475
476fn default_max_iterations() -> usize {
477 3
478}
479
480fn default_format_toon() -> String {
481 "toon".to_string()
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct ProxyMatchingConfig {
486 #[serde(default = "default_true")]
489 pub enabled: bool,
490}
491
492impl Default for ProxyMatchingConfig {
493 fn default() -> Self {
494 Self {
495 enabled: default_true(),
496 }
497 }
498}
499
500fn default_true() -> bool {
501 true
502}
503
504#[derive(Clone, Default, Serialize, Deserialize)]
524pub struct SentryConfig {
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub dsn: Option<String>,
528
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub environment: Option<String>,
532
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub sample_rate: Option<f32>,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub traces_sample_rate: Option<f32>,
540}
541
542impl std::fmt::Debug for SentryConfig {
543 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544 f.debug_struct("SentryConfig")
545 .field("dsn", &self.dsn.as_ref().map(|_| "<redacted>"))
546 .field("environment", &self.environment)
547 .field("sample_rate", &self.sample_rate)
548 .field("traces_sample_rate", &self.traces_sample_rate)
549 .finish()
550 }
551}
552
553#[derive(Debug, Clone, Default, Serialize, Deserialize)]
570pub struct RemoteConfigSettings {
571 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub url: Option<String>,
574
575 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub token_key: Option<String>,
578}
579
580fn default_gitlab_url() -> String {
581 "https://gitlab.com".to_string()
582}
583
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
594#[serde(rename_all = "kebab-case")]
595pub enum RoutingStrategy {
596 #[default]
599 Remote,
600 Local,
603 #[serde(rename = "local-first")]
606 LocalFirst,
607 #[serde(rename = "remote-first")]
610 RemoteFirst,
611}
612
613impl RoutingStrategy {
614 pub fn parse(s: &str) -> Option<Self> {
616 match s.trim().to_ascii_lowercase().as_str() {
617 "remote" => Some(Self::Remote),
618 "local" => Some(Self::Local),
619 "local-first" | "local_first" | "localfirst" => Some(Self::LocalFirst),
620 "remote-first" | "remote_first" | "remotefirst" => Some(Self::RemoteFirst),
621 _ => None,
622 }
623 }
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
630#[serde(deny_unknown_fields)]
631pub struct ProxyToolRule {
632 pub pattern: String,
635 pub strategy: RoutingStrategy,
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize)]
641#[serde(deny_unknown_fields)]
642pub struct ProxyRoutingConfig {
643 #[serde(default)]
645 pub strategy: RoutingStrategy,
646 #[serde(default = "default_true")]
649 pub fallback_on_error: bool,
650 #[serde(default, skip_serializing_if = "Vec::is_empty")]
652 pub tool_overrides: Vec<ProxyToolRule>,
653}
654
655impl Default for ProxyRoutingConfig {
656 fn default() -> Self {
657 Self {
658 strategy: RoutingStrategy::default(),
659 fallback_on_error: true,
660 tool_overrides: Vec::new(),
661 }
662 }
663}
664
665impl ProxyRoutingConfig {
666 pub fn strategy_for(&self, tool_name: &str) -> RoutingStrategy {
669 for rule in &self.tool_overrides {
670 if matches_glob(&rule.pattern, tool_name) {
671 return rule.strategy;
672 }
673 }
674 self.strategy
675 }
676
677 pub fn merged_with(&self, override_cfg: Option<&ProxyRoutingOverride>) -> ProxyRoutingConfig {
683 let Some(o) = override_cfg else {
684 return self.clone();
685 };
686 let mut merged = self.clone();
687 if let Some(strategy) = o.strategy {
688 merged.strategy = strategy;
689 }
690 if let Some(fallback_on_error) = o.fallback_on_error {
691 merged.fallback_on_error = fallback_on_error;
692 }
693 if let Some(extra) = &o.tool_overrides
694 && !extra.is_empty()
695 {
696 let mut combined = extra.clone();
697 combined.extend(self.tool_overrides.iter().cloned());
698 merged.tool_overrides = combined;
699 }
700 merged
701 }
702
703 pub fn is_default(&self) -> bool {
705 self.strategy == RoutingStrategy::default()
706 && self.fallback_on_error
707 && self.tool_overrides.is_empty()
708 }
709}
710
711#[derive(Debug, Clone, Default, Serialize, Deserialize)]
718#[serde(deny_unknown_fields)]
719pub struct ProxyRoutingOverride {
720 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub strategy: Option<RoutingStrategy>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub fallback_on_error: Option<bool>,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub tool_overrides: Option<Vec<ProxyToolRule>>,
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
730#[serde(deny_unknown_fields)]
731pub struct ProxySecretsConfig {
732 #[serde(default = "default_secrets_cache_ttl")]
737 pub cache_ttl_secs: u64,
738}
739
740impl Default for ProxySecretsConfig {
741 fn default() -> Self {
742 Self {
743 cache_ttl_secs: default_secrets_cache_ttl(),
744 }
745 }
746}
747
748impl ProxySecretsConfig {
749 pub fn is_default(&self) -> bool {
750 self.cache_ttl_secs == default_secrets_cache_ttl()
751 }
752}
753
754fn default_secrets_cache_ttl() -> u64 {
755 300
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize)]
761#[serde(deny_unknown_fields)]
762pub struct ProxyTelemetryConfig {
763 #[serde(default = "default_true")]
765 pub enabled: bool,
766 #[serde(default = "default_batch_size")]
768 pub batch_size: usize,
769 #[serde(default = "default_batch_interval_secs")]
771 pub batch_interval_secs: u64,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub endpoint: Option<String>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub token_key: Option<String>,
779 #[serde(default = "default_offline_queue_max")]
782 pub offline_queue_max: usize,
783}
784
785impl Default for ProxyTelemetryConfig {
786 fn default() -> Self {
787 Self {
788 enabled: true,
789 batch_size: default_batch_size(),
790 batch_interval_secs: default_batch_interval_secs(),
791 endpoint: None,
792 token_key: None,
793 offline_queue_max: default_offline_queue_max(),
794 }
795 }
796}
797
798impl ProxyTelemetryConfig {
799 pub fn is_default(&self) -> bool {
800 self.enabled
801 && self.batch_size == default_batch_size()
802 && self.batch_interval_secs == default_batch_interval_secs()
803 && self.endpoint.is_none()
804 && self.token_key.is_none()
805 && self.offline_queue_max == default_offline_queue_max()
806 }
807}
808
809fn default_batch_size() -> usize {
810 100
811}
812
813fn default_batch_interval_secs() -> u64 {
814 30
815}
816
817fn default_offline_queue_max() -> usize {
818 10_000
819}
820
821#[derive(Debug, Clone, Default, Serialize, Deserialize)]
823#[serde(deny_unknown_fields)]
824pub struct ProxyConfig {
825 #[serde(default, skip_serializing_if = "ProxyRoutingConfig::is_default")]
826 pub routing: ProxyRoutingConfig,
827
828 #[serde(default, skip_serializing_if = "ProxySecretsConfig::is_default")]
829 pub secrets: ProxySecretsConfig,
830
831 #[serde(default, skip_serializing_if = "ProxyTelemetryConfig::is_default")]
832 pub telemetry: ProxyTelemetryConfig,
833}
834
835impl ProxyConfig {
836 pub fn is_default(&self) -> bool {
837 self.routing.is_default() && self.secrets.is_default() && self.telemetry.is_default()
838 }
839}
840
841pub fn matches_glob(pattern: &str, name: &str) -> bool {
850 if pattern == "*" {
852 return true;
853 }
854 if !pattern.contains('*') {
855 return pattern == name;
856 }
857
858 let segments: Vec<&str> = pattern.split('*').collect();
859 let mut cursor = 0usize;
860 let last_idx = segments.len() - 1;
861
862 if !segments[0].is_empty() {
864 if !name.starts_with(segments[0]) {
865 return false;
866 }
867 cursor = segments[0].len();
868 }
869
870 for seg in &segments[1..last_idx] {
872 if seg.is_empty() {
873 continue; }
875 match name[cursor..].find(seg) {
876 Some(pos) => cursor += pos + seg.len(),
877 None => return false,
878 }
879 }
880
881 let last = segments[last_idx];
883 if last.is_empty() {
884 return true;
885 }
886 if cursor > name.len() {
887 return false;
888 }
889 name[cursor..].ends_with(last)
890}
891
892impl Config {
897 pub const DEFAULT_CONTEXT_NAME: &'static str = "default";
899
900 pub fn config_dir() -> Result<PathBuf> {
902 dirs::config_dir()
903 .map(|p| p.join(CONFIG_DIR_NAME))
904 .ok_or_else(|| Error::Config("Could not determine config directory".to_string()))
905 }
906
907 pub fn config_path() -> Result<PathBuf> {
909 Ok(Self::config_dir()?.join(CONFIG_FILE_NAME))
910 }
911
912 pub fn load() -> Result<Self> {
916 let path = Self::config_path()?;
917 Self::load_from(&path)
918 }
919
920 pub fn load_from(path: &PathBuf) -> Result<Self> {
924 if !path.exists() {
925 debug!(path = ?path, "Config file does not exist, using defaults");
926 return Ok(Self::default());
927 }
928
929 debug!(path = ?path, "Loading config");
930
931 let contents = std::fs::read_to_string(path)
932 .map_err(|e| Error::Config(format!("Failed to read config file: {}", e)))?;
933
934 let mut config: Config = toml::from_str(&contents)
935 .map_err(|e| Error::Config(format!("Failed to parse config file: {}", e)))?;
936
937 config.sanitize();
943 config.validate()?;
944
945 info!(path = ?path, "Config loaded successfully");
946 Ok(config)
947 }
948
949 pub fn sanitize(&mut self) {
955 if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref()
956 && endpoint.is_empty()
957 {
958 self.proxy.telemetry.endpoint = None;
959 }
960 }
961
962 pub fn validate(&self) -> Result<()> {
970 if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref() {
971 validate_http_url(endpoint, "proxy.telemetry.endpoint")?;
972 }
973 Ok(())
974 }
975
976 pub fn save(&self) -> Result<()> {
978 let path = Self::config_path()?;
979 self.save_to(&path)
980 }
981
982 pub fn save_to(&self, path: &PathBuf) -> Result<()> {
984 if let Some(parent) = path.parent() {
986 std::fs::create_dir_all(parent)
987 .map_err(|e| Error::Config(format!("Failed to create config directory: {}", e)))?;
988 }
989
990 debug!(path = ?path, "Saving config");
991
992 let contents = toml::to_string_pretty(self)
993 .map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
994
995 std::fs::write(path, contents)
996 .map_err(|e| Error::Config(format!("Failed to write config file: {}", e)))?;
997
998 info!(path = ?path, "Config saved successfully");
999 Ok(())
1000 }
1001
1002 pub fn has_any_provider(&self) -> bool {
1004 self.github.is_some()
1005 || self.gitlab.is_some()
1006 || self.clickup.is_some()
1007 || self.jira.is_some()
1008 || self.fireflies.is_some()
1009 || self.confluence.is_some()
1010 || self.slack.is_some()
1011 || self.telegram.is_some()
1012 || self.contexts.values().any(ContextConfig::has_any_provider)
1013 }
1014
1015 pub fn configured_providers(&self) -> Vec<&'static str> {
1017 let mut providers = Vec::new();
1018 if self.github.is_some() {
1019 providers.push("github");
1020 }
1021 if self.gitlab.is_some() {
1022 providers.push("gitlab");
1023 }
1024 if self.clickup.is_some() {
1025 providers.push("clickup");
1026 }
1027 if self.jira.is_some() {
1028 providers.push("jira");
1029 }
1030 if self.confluence.is_some() {
1031 providers.push("confluence");
1032 }
1033 if self.slack.is_some() {
1034 providers.push("slack");
1035 }
1036 if self.telegram.is_some() {
1037 providers.push("telegram");
1038 }
1039 providers
1040 }
1041
1042 pub fn context_names(&self) -> Vec<String> {
1044 let mut names: Vec<String> = self.contexts.keys().cloned().collect();
1045 if self.legacy_default_context().is_some()
1046 && !names.iter().any(|n| n == Self::DEFAULT_CONTEXT_NAME)
1047 {
1048 names.push(Self::DEFAULT_CONTEXT_NAME.to_string());
1049 }
1050 names.sort();
1051 names
1052 }
1053
1054 pub fn get_context(&self, name: &str) -> Option<ContextConfig> {
1056 if name == Self::DEFAULT_CONTEXT_NAME {
1057 return self
1058 .contexts
1059 .get(name)
1060 .cloned()
1061 .or_else(|| self.legacy_default_context());
1062 }
1063
1064 self.contexts.get(name).cloned()
1065 }
1066
1067 pub fn resolve_active_context_name(&self) -> Option<String> {
1069 if let Some(active) = &self.active_context
1070 && self.get_context(active).is_some()
1071 {
1072 return Some(active.clone());
1073 }
1074
1075 if self.get_context(Self::DEFAULT_CONTEXT_NAME).is_some() {
1076 return Some(Self::DEFAULT_CONTEXT_NAME.to_string());
1077 }
1078
1079 self.context_names().into_iter().next()
1080 }
1081
1082 pub fn set_active_context(&mut self, name: &str) -> Result<()> {
1084 if self.get_context(name).is_none() {
1085 return Err(Error::Config(format!("Unknown context: {}", name)));
1086 }
1087 self.active_context = Some(name.to_string());
1088 Ok(())
1089 }
1090
1091 pub fn legacy_default_context(&self) -> Option<ContextConfig> {
1093 let ctx = ContextConfig {
1094 github: self.github.clone(),
1095 gitlab: self.gitlab.clone(),
1096 clickup: self.clickup.clone(),
1097 jira: self.jira.clone(),
1098 fireflies: self.fireflies.clone(),
1099 confluence: self.confluence.clone(),
1100 slack: self.slack.clone(),
1101 telegram: self.telegram.clone(),
1102 };
1103
1104 if ctx.has_any_provider() {
1105 Some(ctx)
1106 } else {
1107 None
1108 }
1109 }
1110
1111 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
1117 let parts: Vec<&str> = key.split('.').collect();
1118
1119 if parts.len() == 3 && parts[0] == "proxy" {
1121 return self.set_proxy_field(parts[1], parts[2], value);
1122 }
1123
1124 if parts.len() != 2 {
1125 return Err(Error::Config(format!(
1126 "Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
1127 key
1128 )));
1129 }
1130
1131 let (provider, field) = (parts[0], parts[1]);
1132
1133 match provider {
1134 "github" => {
1135 let config = self.github.get_or_insert_with(|| GitHubConfig {
1136 owner: String::new(),
1137 repo: String::new(),
1138 base_url: None,
1139 });
1140 match field {
1141 "owner" => config.owner = value.to_string(),
1142 "repo" => config.repo = value.to_string(),
1143 "base_url" | "url" => config.base_url = Some(value.to_string()),
1144 _ => {
1145 return Err(Error::Config(format!(
1146 "Unknown GitHub config field: {}",
1147 field
1148 )));
1149 }
1150 }
1151 }
1152 "gitlab" => {
1153 let config = self.gitlab.get_or_insert_with(|| GitLabConfig {
1154 url: default_gitlab_url(),
1155 project_id: String::new(),
1156 });
1157 match field {
1158 "url" => config.url = value.to_string(),
1159 "project_id" | "project" => config.project_id = value.to_string(),
1160 _ => {
1161 return Err(Error::Config(format!(
1162 "Unknown GitLab config field: {}",
1163 field
1164 )));
1165 }
1166 }
1167 }
1168 "clickup" => {
1169 let config = self.clickup.get_or_insert_with(|| ClickUpConfig {
1170 list_id: String::new(),
1171 team_id: None,
1172 });
1173 match field {
1174 "list_id" | "list" => config.list_id = value.to_string(),
1175 "team_id" | "team" => config.team_id = Some(value.to_string()),
1176 _ => {
1177 return Err(Error::Config(format!(
1178 "Unknown ClickUp config field: {}",
1179 field
1180 )));
1181 }
1182 }
1183 }
1184 "jira" => {
1185 let config = self.jira.get_or_insert_with(|| JiraConfig {
1186 url: String::new(),
1187 project_key: String::new(),
1188 email: String::new(),
1189 });
1190 match field {
1191 "url" => config.url = value.to_string(),
1192 "project_key" | "project" => config.project_key = value.to_string(),
1193 "email" => config.email = value.to_string(),
1194 _ => {
1195 return Err(Error::Config(format!(
1196 "Unknown Jira config field: {}",
1197 field
1198 )));
1199 }
1200 }
1201 }
1202 "confluence" => {
1203 let config = self.confluence.get_or_insert_with(|| ConfluenceConfig {
1204 base_url: String::new(),
1205 api_version: None,
1206 username: None,
1207 space_key: None,
1208 });
1209 match field {
1210 "base_url" | "url" => config.base_url = value.to_string(),
1211 "api_version" | "api" | "version" => {
1212 config.api_version = Some(value.to_string())
1213 }
1214 "username" | "email" | "user" => config.username = Some(value.to_string()),
1215 "space_key" | "space" => config.space_key = Some(value.to_string()),
1216 _ => {
1217 return Err(Error::Config(format!(
1218 "Unknown Confluence config field: {}",
1219 field
1220 )));
1221 }
1222 }
1223 }
1224 "slack" => {
1225 let config = self.slack.get_or_insert_with(SlackConfig::default);
1226 match field {
1227 "team_id" | "team" => config.team_id = Some(value.to_string()),
1228 "workspace" => config.workspace = Some(value.to_string()),
1229 "base_url" | "url" => config.base_url = Some(value.to_string()),
1230 "client_id" => config.client_id = Some(value.to_string()),
1231 "redirect_uri" => config.redirect_uri = Some(value.to_string()),
1232 _ => {
1233 return Err(Error::Config(format!(
1234 "Unknown Slack config field: {}",
1235 field
1236 )));
1237 }
1238 }
1239 }
1240 "telegram" => {
1241 let config = self.telegram.get_or_insert_with(TelegramConfig::default);
1242 match field {
1243 "base_url" | "url" => config.base_url = Some(value.to_string()),
1244 "bot_username" | "bot" | "username" => {
1245 config.bot_username = Some(value.to_string())
1246 }
1247 _ => {
1248 return Err(Error::Config(format!(
1249 "Unknown Telegram config field: {}",
1250 field
1251 )));
1252 }
1253 }
1254 }
1255 _ => {
1256 return Err(Error::Config(format!("Unknown provider: {}", provider)));
1257 }
1258 }
1259
1260 Ok(())
1261 }
1262
1263 pub fn get(&self, key: &str) -> Result<Option<String>> {
1269 let parts: Vec<&str> = key.split('.').collect();
1270
1271 if parts.len() == 3 && parts[0] == "proxy" {
1272 return self.get_proxy_field(parts[1], parts[2]);
1273 }
1274
1275 if parts.len() != 2 {
1276 return Err(Error::Config(format!(
1277 "Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
1278 key
1279 )));
1280 }
1281
1282 let (provider, field) = (parts[0], parts[1]);
1283
1284 match provider {
1285 "github" => {
1286 let Some(config) = &self.github else {
1287 return Ok(None);
1288 };
1289 match field {
1290 "owner" => Ok(Some(config.owner.clone())),
1291 "repo" => Ok(Some(config.repo.clone())),
1292 "base_url" | "url" => Ok(config.base_url.clone()),
1293 _ => Err(Error::Config(format!(
1294 "Unknown GitHub config field: {}",
1295 field
1296 ))),
1297 }
1298 }
1299 "gitlab" => {
1300 let Some(config) = &self.gitlab else {
1301 return Ok(None);
1302 };
1303 match field {
1304 "url" => Ok(Some(config.url.clone())),
1305 "project_id" | "project" => Ok(Some(config.project_id.clone())),
1306 _ => Err(Error::Config(format!(
1307 "Unknown GitLab config field: {}",
1308 field
1309 ))),
1310 }
1311 }
1312 "clickup" => {
1313 let Some(config) = &self.clickup else {
1314 return Ok(None);
1315 };
1316 match field {
1317 "list_id" | "list" => Ok(Some(config.list_id.clone())),
1318 "team_id" | "team" => Ok(config.team_id.clone()),
1319 _ => Err(Error::Config(format!(
1320 "Unknown ClickUp config field: {}",
1321 field
1322 ))),
1323 }
1324 }
1325 "jira" => {
1326 let Some(config) = &self.jira else {
1327 return Ok(None);
1328 };
1329 match field {
1330 "url" => Ok(Some(config.url.clone())),
1331 "project_key" | "project" => Ok(Some(config.project_key.clone())),
1332 "email" => Ok(Some(config.email.clone())),
1333 _ => Err(Error::Config(format!(
1334 "Unknown Jira config field: {}",
1335 field
1336 ))),
1337 }
1338 }
1339 "confluence" => {
1340 let Some(config) = &self.confluence else {
1341 return Ok(None);
1342 };
1343 match field {
1344 "base_url" | "url" => Ok(Some(config.base_url.clone())),
1345 "api_version" | "api" | "version" => Ok(config.api_version.clone()),
1346 "username" | "email" | "user" => Ok(config.username.clone()),
1347 "space_key" | "space" => Ok(config.space_key.clone()),
1348 _ => Err(Error::Config(format!(
1349 "Unknown Confluence config field: {}",
1350 field
1351 ))),
1352 }
1353 }
1354 "slack" => {
1355 let Some(config) = &self.slack else {
1356 return Ok(None);
1357 };
1358 match field {
1359 "team_id" | "team" => Ok(config.team_id.clone()),
1360 "workspace" => Ok(config.workspace.clone()),
1361 "base_url" | "url" => Ok(config.base_url.clone()),
1362 "client_id" => Ok(config.client_id.clone()),
1363 "redirect_uri" => Ok(config.redirect_uri.clone()),
1364 _ => Err(Error::Config(format!(
1365 "Unknown Slack config field: {}",
1366 field
1367 ))),
1368 }
1369 }
1370 "telegram" => {
1371 let Some(config) = &self.telegram else {
1372 return Ok(None);
1373 };
1374 match field {
1375 "base_url" | "url" => Ok(config.base_url.clone()),
1376 "bot_username" | "bot" | "username" => Ok(config.bot_username.clone()),
1377 _ => Err(Error::Config(format!(
1378 "Unknown Telegram config field: {}",
1379 field
1380 ))),
1381 }
1382 }
1383 _ => Err(Error::Config(format!("Unknown provider: {}", provider))),
1384 }
1385 }
1386
1387 fn set_proxy_field(&mut self, section: &str, field: &str, value: &str) -> Result<()> {
1389 match section {
1390 "routing" => match field {
1391 "strategy" => {
1392 let strat = RoutingStrategy::parse(value).ok_or_else(|| {
1393 Error::Config(format!(
1394 "Invalid routing strategy '{}'. Allowed (case-insensitive): \
1395 remote, local, local-first, remote-first",
1396 value
1397 ))
1398 })?;
1399 self.proxy.routing.strategy = strat;
1400 Ok(())
1401 }
1402 "fallback_on_error" => {
1403 self.proxy.routing.fallback_on_error = parse_bool(value)?;
1404 Ok(())
1405 }
1406 _ => Err(Error::Config(format!(
1407 "Unknown proxy.routing field: {}",
1408 field
1409 ))),
1410 },
1411 "secrets" => match field {
1412 "cache_ttl_secs" => {
1413 self.proxy.secrets.cache_ttl_secs = parse_u64(value, field)?;
1414 Ok(())
1415 }
1416 _ => Err(Error::Config(format!(
1417 "Unknown proxy.secrets field: {}",
1418 field
1419 ))),
1420 },
1421 "telemetry" => match field {
1422 "enabled" => {
1423 self.proxy.telemetry.enabled = parse_bool(value)?;
1424 Ok(())
1425 }
1426 "endpoint" => {
1427 self.proxy.telemetry.endpoint = if value.is_empty() {
1428 None
1429 } else {
1430 validate_http_url(value, "proxy.telemetry.endpoint")?;
1431 Some(value.to_string())
1432 };
1433 Ok(())
1434 }
1435 "token_key" => {
1436 self.proxy.telemetry.token_key = if value.is_empty() {
1437 None
1438 } else {
1439 Some(value.to_string())
1440 };
1441 Ok(())
1442 }
1443 "batch_size" => {
1444 self.proxy.telemetry.batch_size = parse_usize(value, field)?;
1445 Ok(())
1446 }
1447 "batch_interval_secs" => {
1448 self.proxy.telemetry.batch_interval_secs = parse_u64(value, field)?;
1449 Ok(())
1450 }
1451 "offline_queue_max" => {
1452 self.proxy.telemetry.offline_queue_max = parse_usize(value, field)?;
1453 Ok(())
1454 }
1455 _ => Err(Error::Config(format!(
1456 "Unknown proxy.telemetry field: {}",
1457 field
1458 ))),
1459 },
1460 _ => Err(Error::Config(format!(
1461 "Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
1462 section
1463 ))),
1464 }
1465 }
1466
1467 fn get_proxy_field(&self, section: &str, field: &str) -> Result<Option<String>> {
1470 match section {
1471 "routing" => match field {
1472 "strategy" => Ok(Some(routing_strategy_slug(self.proxy.routing.strategy))),
1473 "fallback_on_error" => Ok(Some(self.proxy.routing.fallback_on_error.to_string())),
1474 _ => Err(Error::Config(format!(
1475 "Unknown proxy.routing field: {}",
1476 field
1477 ))),
1478 },
1479 "secrets" => match field {
1480 "cache_ttl_secs" => Ok(Some(self.proxy.secrets.cache_ttl_secs.to_string())),
1481 _ => Err(Error::Config(format!(
1482 "Unknown proxy.secrets field: {}",
1483 field
1484 ))),
1485 },
1486 "telemetry" => match field {
1487 "enabled" => Ok(Some(self.proxy.telemetry.enabled.to_string())),
1488 "endpoint" => Ok(self.proxy.telemetry.endpoint.clone()),
1489 "token_key" => Ok(self.proxy.telemetry.token_key.clone()),
1490 "batch_size" => Ok(Some(self.proxy.telemetry.batch_size.to_string())),
1491 "batch_interval_secs" => {
1492 Ok(Some(self.proxy.telemetry.batch_interval_secs.to_string()))
1493 }
1494 "offline_queue_max" => Ok(Some(self.proxy.telemetry.offline_queue_max.to_string())),
1495 _ => Err(Error::Config(format!(
1496 "Unknown proxy.telemetry field: {}",
1497 field
1498 ))),
1499 },
1500 _ => Err(Error::Config(format!(
1501 "Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
1502 section
1503 ))),
1504 }
1505 }
1506}
1507
1508fn parse_bool(value: &str) -> Result<bool> {
1509 match value.trim().to_ascii_lowercase().as_str() {
1510 "true" | "1" | "yes" | "on" => Ok(true),
1511 "false" | "0" | "no" | "off" => Ok(false),
1512 _ => Err(Error::Config(format!(
1513 "Invalid boolean '{}'. Allowed: true/false, 1/0, yes/no, on/off",
1514 value
1515 ))),
1516 }
1517}
1518
1519fn parse_u64(value: &str, field: &str) -> Result<u64> {
1520 value.trim().parse::<u64>().map_err(|_| {
1521 Error::Config(format!(
1522 "Invalid value for {}: '{}'. Expected non-negative integer",
1523 field, value
1524 ))
1525 })
1526}
1527
1528fn parse_usize(value: &str, field: &str) -> Result<usize> {
1529 value.trim().parse::<usize>().map_err(|_| {
1530 Error::Config(format!(
1531 "Invalid value for {}: '{}'. Expected non-negative integer",
1532 field, value
1533 ))
1534 })
1535}
1536
1537fn validate_http_url(value: &str, field: &str) -> Result<()> {
1549 if value.contains(|c: char| c.is_whitespace()) {
1553 return Err(Error::Config(format!(
1554 "Invalid URL for {}: '{}'. Must not contain whitespace",
1555 field, value
1556 )));
1557 }
1558
1559 let rest = if let Some(r) = value.strip_prefix("https://") {
1560 r
1561 } else if let Some(r) = value.strip_prefix("http://") {
1562 r
1563 } else {
1564 return Err(Error::Config(format!(
1565 "Invalid URL for {}: '{}'. Must start with http:// or https://",
1566 field, value
1567 )));
1568 };
1569
1570 let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1572 let host = &rest[..host_end];
1573 if host.is_empty() {
1574 return Err(Error::Config(format!(
1575 "Invalid URL for {}: '{}'. Missing host",
1576 field, value
1577 )));
1578 }
1579
1580 Ok(())
1581}
1582
1583pub fn routing_strategy_slug(s: RoutingStrategy) -> String {
1587 match s {
1588 RoutingStrategy::Remote => "remote",
1589 RoutingStrategy::Local => "local",
1590 RoutingStrategy::LocalFirst => "local-first",
1591 RoutingStrategy::RemoteFirst => "remote-first",
1592 }
1593 .to_string()
1594}
1595
1596impl ContextConfig {
1597 pub fn has_any_provider(&self) -> bool {
1599 self.github.is_some()
1600 || self.gitlab.is_some()
1601 || self.clickup.is_some()
1602 || self.jira.is_some()
1603 || self.fireflies.is_some()
1604 || self.confluence.is_some()
1605 || self.slack.is_some()
1606 || self.telegram.is_some()
1607 }
1608
1609 pub fn configured_providers(&self) -> Vec<&'static str> {
1611 let mut providers = Vec::new();
1612 if self.github.is_some() {
1613 providers.push("github");
1614 }
1615 if self.gitlab.is_some() {
1616 providers.push("gitlab");
1617 }
1618 if self.clickup.is_some() {
1619 providers.push("clickup");
1620 }
1621 if self.jira.is_some() {
1622 providers.push("jira");
1623 }
1624 if self.confluence.is_some() {
1625 providers.push("confluence");
1626 }
1627 if self.slack.is_some() {
1628 providers.push("slack");
1629 }
1630 if self.telegram.is_some() {
1631 providers.push("telegram");
1632 }
1633 providers
1634 }
1635}
1636
1637#[cfg(test)]
1642mod tests {
1643 use super::*;
1644 use tempfile::NamedTempFile;
1645
1646 #[test]
1647 fn test_default_config() {
1648 let config = Config::default();
1649 assert!(config.github.is_none());
1650 assert!(config.gitlab.is_none());
1651 assert!(config.telegram.is_none());
1652 assert!(config.contexts.is_empty());
1653 assert!(!config.has_any_provider());
1654 assert!(config.configured_providers().is_empty());
1655 }
1656
1657 #[test]
1658 fn test_set_and_get() {
1659 let mut config = Config::default();
1660
1661 config.set("github.owner", "test-owner").unwrap();
1663 config.set("github.repo", "test-repo").unwrap();
1664
1665 assert_eq!(
1666 config.get("github.owner").unwrap(),
1667 Some("test-owner".to_string())
1668 );
1669 assert_eq!(
1670 config.get("github.repo").unwrap(),
1671 Some("test-repo".to_string())
1672 );
1673
1674 config
1676 .set("gitlab.url", "https://gitlab.example.com")
1677 .unwrap();
1678 config.set("gitlab.project_id", "123").unwrap();
1679
1680 assert_eq!(
1681 config.get("gitlab.url").unwrap(),
1682 Some("https://gitlab.example.com".to_string())
1683 );
1684
1685 assert!(config.has_any_provider());
1687 let providers = config.configured_providers();
1688 assert!(providers.contains(&"github"));
1689 assert!(providers.contains(&"gitlab"));
1690 }
1691
1692 #[test]
1693 fn test_set_and_get_telegram() {
1694 let mut config = Config::default();
1695
1696 config
1697 .set("telegram.base_url", "https://api.telegram.org")
1698 .unwrap();
1699 config.set("telegram.bot_username", "devboy_bot").unwrap();
1700
1701 assert_eq!(
1702 config.get("telegram.base_url").unwrap(),
1703 Some("https://api.telegram.org".to_string())
1704 );
1705 assert_eq!(
1706 config.get("telegram.url").unwrap(),
1707 Some("https://api.telegram.org".to_string())
1708 );
1709 assert_eq!(
1710 config.get("telegram.bot_username").unwrap(),
1711 Some("devboy_bot".to_string())
1712 );
1713 assert_eq!(
1714 config.get("telegram.bot").unwrap(),
1715 Some("devboy_bot".to_string())
1716 );
1717 }
1718
1719 #[test]
1720 fn test_default_slack_required_scopes_cover_default_conversation_types() {
1721 let scopes = default_slack_required_scopes();
1722
1723 assert!(scopes.contains(&"channels:read".to_string()));
1724 assert!(scopes.contains(&"channels:history".to_string()));
1725 assert!(scopes.contains(&"groups:read".to_string()));
1726 assert!(scopes.contains(&"groups:history".to_string()));
1727 assert!(scopes.contains(&"im:read".to_string()));
1728 assert!(scopes.contains(&"im:history".to_string()));
1729 assert!(scopes.contains(&"mpim:read".to_string()));
1730 assert!(scopes.contains(&"mpim:history".to_string()));
1731 }
1732
1733 #[test]
1734 fn test_invalid_key() {
1735 let mut config = Config::default();
1736
1737 assert!(config.set("invalid", "value").is_err());
1739 assert!(config.set("too.many.parts", "value").is_err());
1740
1741 assert!(config.set("unknown.field", "value").is_err());
1743 assert!(config.set("telegram.unknown", "value").is_err());
1744
1745 assert_eq!(config.get("github.owner").unwrap(), None);
1747
1748 config.set("github.owner", "test").unwrap();
1750 assert!(config.get("github.unknown_field").is_err());
1751 }
1752
1753 #[test]
1754 fn is_secrets_migration_complete_defaults_to_false() {
1755 let config = Config::default();
1756 assert!(!config.is_secrets_migration_complete());
1757 }
1758
1759 #[test]
1760 fn is_secrets_migration_complete_reads_explicit_flag() {
1761 let config = Config {
1762 secrets: Some(SecretsConfig {
1763 migration_complete: true,
1764 }),
1765 ..Config::default()
1766 };
1767 assert!(config.is_secrets_migration_complete());
1768 }
1769
1770 #[test]
1771 fn secrets_section_round_trips_through_toml() {
1772 let toml = "[secrets]\nmigration_complete = true\n";
1773 let config: Config = toml::from_str(toml).unwrap();
1774 assert!(config.is_secrets_migration_complete());
1775 let serialized = toml::to_string(&config).unwrap();
1776 assert!(serialized.contains("[secrets]"));
1777 assert!(serialized.contains("migration_complete = true"));
1778 }
1779
1780 #[test]
1781 fn secrets_section_omitted_when_unset() {
1782 let config = Config::default();
1783 let serialized = toml::to_string(&config).unwrap();
1784 assert!(
1785 !serialized.contains("[secrets]"),
1786 "default Config should not write a [secrets] section"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_save_and_load() {
1792 let config = Config {
1793 github: Some(GitHubConfig {
1794 owner: "test-owner".to_string(),
1795 repo: "test-repo".to_string(),
1796 base_url: None,
1797 }),
1798 ..Default::default()
1799 };
1800
1801 let temp_file = NamedTempFile::new().unwrap();
1803 let path = temp_file.path().to_path_buf();
1804
1805 config.save_to(&path).unwrap();
1806
1807 let contents = std::fs::read_to_string(&path).unwrap();
1809 assert!(contents.contains("owner = \"test-owner\""));
1810 assert!(contents.contains("repo = \"test-repo\""));
1811
1812 let loaded = Config::load_from(&path).unwrap();
1814 assert!(loaded.github.is_some());
1815 let gh = loaded.github.unwrap();
1816 assert_eq!(gh.owner, "test-owner");
1817 assert_eq!(gh.repo, "test-repo");
1818 }
1819
1820 #[test]
1821 fn test_load_nonexistent() {
1822 let path = PathBuf::from("/nonexistent/path/config.toml");
1823 let config = Config::load_from(&path).unwrap();
1824 assert!(config.github.is_none());
1825 }
1826
1827 #[test]
1828 fn test_set_and_get_gitlab() {
1829 let mut config = Config::default();
1830
1831 config
1832 .set("gitlab.url", "https://gitlab.example.com")
1833 .unwrap();
1834 config.set("gitlab.project_id", "456").unwrap();
1835
1836 assert_eq!(
1837 config.get("gitlab.url").unwrap(),
1838 Some("https://gitlab.example.com".to_string())
1839 );
1840 assert_eq!(
1841 config.get("gitlab.project_id").unwrap(),
1842 Some("456".to_string())
1843 );
1844 assert_eq!(
1846 config.get("gitlab.project").unwrap(),
1847 Some("456".to_string())
1848 );
1849 }
1850
1851 #[test]
1852 fn test_set_and_get_gitlab_alias() {
1853 let mut config = Config::default();
1854
1855 config.set("gitlab.project", "789").unwrap();
1856
1857 assert_eq!(
1858 config.get("gitlab.project_id").unwrap(),
1859 Some("789".to_string())
1860 );
1861 }
1862
1863 #[test]
1864 fn test_set_and_get_clickup() {
1865 let mut config = Config::default();
1866
1867 config.set("clickup.list_id", "list123").unwrap();
1868
1869 assert_eq!(
1870 config.get("clickup.list_id").unwrap(),
1871 Some("list123".to_string())
1872 );
1873 assert_eq!(
1875 config.get("clickup.list").unwrap(),
1876 Some("list123".to_string())
1877 );
1878 }
1879
1880 #[test]
1881 fn test_set_and_get_clickup_alias() {
1882 let mut config = Config::default();
1883
1884 config.set("clickup.list", "list456").unwrap();
1885
1886 assert_eq!(
1887 config.get("clickup.list_id").unwrap(),
1888 Some("list456".to_string())
1889 );
1890 }
1891
1892 #[test]
1893 fn test_set_and_get_jira() {
1894 let mut config = Config::default();
1895
1896 config.set("jira.url", "https://jira.example.com").unwrap();
1897 config.set("jira.project_key", "PROJ").unwrap();
1898 config.set("jira.email", "user@example.com").unwrap();
1899
1900 assert_eq!(
1901 config.get("jira.url").unwrap(),
1902 Some("https://jira.example.com".to_string())
1903 );
1904 assert_eq!(
1905 config.get("jira.project_key").unwrap(),
1906 Some("PROJ".to_string())
1907 );
1908 assert_eq!(
1909 config.get("jira.email").unwrap(),
1910 Some("user@example.com".to_string())
1911 );
1912 assert_eq!(
1914 config.get("jira.project").unwrap(),
1915 Some("PROJ".to_string())
1916 );
1917 }
1918
1919 #[test]
1920 fn test_set_and_get_jira_alias() {
1921 let mut config = Config::default();
1922
1923 config.set("jira.project", "KEY").unwrap();
1924
1925 assert_eq!(
1926 config.get("jira.project_key").unwrap(),
1927 Some("KEY".to_string())
1928 );
1929 }
1930
1931 #[test]
1932 fn test_set_and_get_confluence() {
1933 let mut config = Config::default();
1934
1935 config
1936 .set("confluence.base_url", "https://wiki.example.com")
1937 .unwrap();
1938 config.set("confluence.api_version", "v1").unwrap();
1939 config
1940 .set("confluence.username", "dev@example.com")
1941 .unwrap();
1942 config.set("confluence.space_key", "ENG").unwrap();
1943
1944 assert_eq!(
1945 config.get("confluence.base_url").unwrap(),
1946 Some("https://wiki.example.com".to_string())
1947 );
1948 assert_eq!(
1949 config.get("confluence.url").unwrap(),
1950 Some("https://wiki.example.com".to_string())
1951 );
1952 assert_eq!(
1953 config.get("confluence.api").unwrap(),
1954 Some("v1".to_string())
1955 );
1956 assert_eq!(
1957 config.get("confluence.username").unwrap(),
1958 Some("dev@example.com".to_string())
1959 );
1960 assert_eq!(
1961 config.get("confluence.space").unwrap(),
1962 Some("ENG".to_string())
1963 );
1964 }
1965
1966 #[test]
1967 fn test_set_github_base_url() {
1968 let mut config = Config::default();
1969
1970 config
1971 .set("github.base_url", "https://github.example.com/api/v3")
1972 .unwrap();
1973
1974 assert_eq!(
1975 config.get("github.base_url").unwrap(),
1976 Some("https://github.example.com/api/v3".to_string())
1977 );
1978 assert_eq!(
1980 config.get("github.url").unwrap(),
1981 Some("https://github.example.com/api/v3".to_string())
1982 );
1983 }
1984
1985 #[test]
1986 fn test_set_github_url_alias() {
1987 let mut config = Config::default();
1988
1989 config
1990 .set("github.url", "https://github.example.com/api/v3")
1991 .unwrap();
1992
1993 assert_eq!(
1994 config.get("github.base_url").unwrap(),
1995 Some("https://github.example.com/api/v3".to_string())
1996 );
1997 }
1998
1999 #[test]
2000 fn test_unknown_field_errors() {
2001 let mut config = Config::default();
2002
2003 assert!(config.set("github.unknown", "value").is_err());
2005 config.set("github.owner", "test").unwrap();
2006 assert!(config.get("github.unknown").is_err());
2007
2008 assert!(config.set("gitlab.unknown", "value").is_err());
2010 config.set("gitlab.url", "https://gitlab.com").unwrap();
2011 assert!(config.get("gitlab.unknown").is_err());
2012
2013 assert!(config.set("clickup.unknown", "value").is_err());
2015 config.set("clickup.list_id", "123").unwrap();
2016 assert!(config.get("clickup.unknown").is_err());
2017
2018 assert!(config.set("jira.unknown", "value").is_err());
2020 config.set("jira.url", "https://jira.com").unwrap();
2021 assert!(config.get("jira.unknown").is_err());
2022 }
2023
2024 #[test]
2025 fn test_get_unconfigured_providers() {
2026 let config = Config::default();
2027
2028 assert_eq!(config.get("github.owner").unwrap(), None);
2029 assert_eq!(config.get("gitlab.url").unwrap(), None);
2030 assert_eq!(config.get("clickup.list_id").unwrap(), None);
2031 assert_eq!(config.get("jira.url").unwrap(), None);
2032 assert_eq!(config.get("confluence.base_url").unwrap(), None);
2033 assert_eq!(config.get("telegram.base_url").unwrap(), None);
2034 }
2035
2036 #[test]
2037 fn test_unknown_provider_set() {
2038 let mut config = Config::default();
2039 let result = config.set("unknown.field", "value");
2040 assert!(result.is_err());
2041 let err_msg = result.unwrap_err().to_string();
2042 assert!(err_msg.contains("Unknown provider: unknown"));
2043 }
2044
2045 #[test]
2046 fn test_unknown_provider_get() {
2047 let config = Config::default();
2048 let result = config.get("unknown.field");
2049 assert!(result.is_err());
2050 }
2051
2052 #[test]
2053 fn test_malformed_toml() {
2054 let temp_file = NamedTempFile::new().unwrap();
2055 let path = temp_file.path().to_path_buf();
2056
2057 std::fs::write(&path, "invalid toml content [[[").unwrap();
2058
2059 let result = Config::load_from(&path);
2060 assert!(result.is_err());
2061 let err_msg = result.unwrap_err().to_string();
2062 assert!(err_msg.contains("Failed to parse config file"));
2063 }
2064
2065 #[test]
2066 fn test_configured_providers_all() {
2067 let config = Config {
2068 github: Some(GitHubConfig {
2069 owner: "o".to_string(),
2070 repo: "r".to_string(),
2071 base_url: None,
2072 }),
2073 gitlab: Some(GitLabConfig {
2074 url: "u".to_string(),
2075 project_id: "p".to_string(),
2076 }),
2077 clickup: Some(ClickUpConfig {
2078 list_id: "l".to_string(),
2079 team_id: None,
2080 }),
2081 jira: Some(JiraConfig {
2082 url: "u".to_string(),
2083 project_key: "k".to_string(),
2084 email: "e".to_string(),
2085 }),
2086 fireflies: None,
2087 confluence: None,
2088 slack: None,
2089 telegram: Some(TelegramConfig {
2090 base_url: Some("https://api.telegram.org".to_string()),
2091 bot_username: Some("devboy_bot".to_string()),
2092 }),
2093 contexts: BTreeMap::new(),
2094 active_context: None,
2095 proxy_mcp_servers: Vec::new(),
2096 builtin_tools: BuiltinToolsConfig::default(),
2097 format_pipeline: None,
2098 proxy: ProxyConfig::default(),
2099 sentry: None,
2100 remote_config: None,
2101 secrets: None,
2102 };
2103
2104 let providers = config.configured_providers();
2105 assert_eq!(providers.len(), 5);
2106 assert!(providers.contains(&"github"));
2107 assert!(providers.contains(&"gitlab"));
2108 assert!(providers.contains(&"clickup"));
2109 assert!(providers.contains(&"jira"));
2110 assert!(providers.contains(&"telegram"));
2111 assert!(config.has_any_provider());
2112 }
2113
2114 #[test]
2115 fn test_config_dir() {
2116 let dir = Config::config_dir().unwrap();
2118 assert!(dir.ends_with("devboy-tools"));
2119 }
2120
2121 #[test]
2122 fn test_config_path() {
2123 let path = Config::config_path().unwrap();
2125 assert!(path.ends_with("config.toml"));
2126 assert!(path.parent().unwrap().ends_with("devboy-tools"));
2127 }
2128
2129 #[test]
2130 fn test_load_default_path() {
2131 let dir = tempfile::tempdir().unwrap();
2133 let path = dir.path().join("config.toml");
2134 let config = Config::load_from(&path).unwrap();
2136 assert!(!config.has_any_provider());
2137 }
2138
2139 #[test]
2140 fn test_save_default_path() {
2141 let dir = tempfile::tempdir().unwrap();
2143 let path = dir.path().join("config.toml");
2144
2145 let config = Config {
2146 github: Some(GitHubConfig {
2147 owner: "test".to_string(),
2148 repo: "repo".to_string(),
2149 base_url: None,
2150 }),
2151 ..Default::default()
2152 };
2153
2154 config.save_to(&path).unwrap();
2155 assert!(path.exists());
2156
2157 let loaded = Config::load_from(&path).unwrap();
2159 assert_eq!(loaded.github.unwrap().owner, "test");
2160 }
2161
2162 #[test]
2163 fn test_toml_serialization() {
2164 let config = Config {
2165 github: Some(GitHubConfig {
2166 owner: "owner".to_string(),
2167 repo: "repo".to_string(),
2168 base_url: Some("https://github.example.com".to_string()),
2169 }),
2170 gitlab: Some(GitLabConfig {
2171 url: "https://gitlab.example.com".to_string(),
2172 project_id: "123".to_string(),
2173 }),
2174 clickup: None,
2175 jira: None,
2176 fireflies: None,
2177 confluence: None,
2178 slack: None,
2179 telegram: Some(TelegramConfig {
2180 base_url: Some("https://api.telegram.org".to_string()),
2181 bot_username: Some("devboy_bot".to_string()),
2182 }),
2183 contexts: BTreeMap::new(),
2184 active_context: None,
2185 proxy_mcp_servers: Vec::new(),
2186 builtin_tools: BuiltinToolsConfig::default(),
2187 format_pipeline: None,
2188 proxy: ProxyConfig::default(),
2189 sentry: None,
2190 remote_config: None,
2191 secrets: None,
2192 };
2193
2194 let toml_str = toml::to_string_pretty(&config).unwrap();
2195 assert!(toml_str.contains("[github]"));
2196 assert!(toml_str.contains("[gitlab]"));
2197 assert!(toml_str.contains("[telegram]"));
2198 assert!(!toml_str.contains("[clickup]"));
2199 assert!(!toml_str.contains("[jira]"));
2200
2201 let parsed: Config = toml::from_str(&toml_str).unwrap();
2203 assert!(parsed.github.is_some());
2204 assert!(parsed.gitlab.is_some());
2205 }
2206
2207 #[test]
2208 fn test_contexts_and_active_context() {
2209 let mut config = Config::default();
2210 config.contexts.insert(
2211 "dashboard".to_string(),
2212 ContextConfig {
2213 github: Some(GitHubConfig {
2214 owner: "meteora-pro".to_string(),
2215 repo: "my-project".to_string(),
2216 base_url: None,
2217 }),
2218 clickup: Some(ClickUpConfig {
2219 list_id: "abc123".to_string(),
2220 team_id: None,
2221 }),
2222 ..Default::default()
2223 },
2224 );
2225
2226 let names = config.context_names();
2227 assert_eq!(names, vec!["dashboard".to_string()]);
2228
2229 config.set_active_context("dashboard").unwrap();
2230 assert_eq!(
2231 config.resolve_active_context_name(),
2232 Some("dashboard".to_string())
2233 );
2234 }
2235
2236 #[test]
2237 fn test_context_names_include_legacy_default() {
2238 let mut config = Config {
2239 github: Some(GitHubConfig {
2240 owner: "legacy-owner".to_string(),
2241 repo: "legacy-repo".to_string(),
2242 base_url: None,
2243 }),
2244 ..Default::default()
2245 };
2246 config
2247 .contexts
2248 .insert("workspace".to_string(), ContextConfig::default());
2249
2250 assert_eq!(
2251 config.context_names(),
2252 vec!["default".to_string(), "workspace".to_string()]
2253 );
2254 }
2255
2256 #[test]
2257 fn test_get_context_prefers_explicit_default_over_legacy() {
2258 let mut config = Config {
2259 github: Some(GitHubConfig {
2260 owner: "legacy-owner".to_string(),
2261 repo: "legacy-repo".to_string(),
2262 base_url: None,
2263 }),
2264 ..Default::default()
2265 };
2266 config.contexts.insert(
2267 Config::DEFAULT_CONTEXT_NAME.to_string(),
2268 ContextConfig {
2269 github: Some(GitHubConfig {
2270 owner: "explicit-owner".to_string(),
2271 repo: "explicit-repo".to_string(),
2272 base_url: None,
2273 }),
2274 ..Default::default()
2275 },
2276 );
2277
2278 let default_ctx = config.get_context(Config::DEFAULT_CONTEXT_NAME).unwrap();
2279 let gh = default_ctx.github.unwrap();
2280 assert_eq!(gh.owner, "explicit-owner");
2281 assert_eq!(gh.repo, "explicit-repo");
2282 }
2283
2284 #[test]
2285 fn test_resolve_active_context_fallbacks() {
2286 let mut config = Config {
2287 active_context: Some("missing".to_string()),
2288 github: Some(GitHubConfig {
2289 owner: "legacy-owner".to_string(),
2290 repo: "legacy-repo".to_string(),
2291 base_url: None,
2292 }),
2293 ..Default::default()
2294 };
2295 config
2296 .contexts
2297 .insert("beta".to_string(), ContextConfig::default());
2298 config
2299 .contexts
2300 .insert("alpha".to_string(), ContextConfig::default());
2301
2302 assert_eq!(
2303 config.resolve_active_context_name(),
2304 Some("default".to_string())
2305 );
2306
2307 config.github = None;
2308 assert_eq!(
2309 config.resolve_active_context_name(),
2310 Some("alpha".to_string())
2311 );
2312 }
2313
2314 #[test]
2315 fn test_set_active_context_unknown_context_errors() {
2316 let mut config = Config::default();
2317 let result = config.set_active_context("missing");
2318 assert!(result.is_err());
2319 assert!(result.unwrap_err().to_string().contains("Unknown context"));
2320 }
2321
2322 #[test]
2323 fn test_context_config_configured_providers() {
2324 let context = ContextConfig {
2325 github: Some(GitHubConfig {
2326 owner: "owner".to_string(),
2327 repo: "repo".to_string(),
2328 base_url: None,
2329 }),
2330 jira: Some(JiraConfig {
2331 url: "https://jira.example.com".to_string(),
2332 project_key: "DEV".to_string(),
2333 email: "dev@example.com".to_string(),
2334 }),
2335 ..Default::default()
2336 };
2337
2338 let providers = context.configured_providers();
2339 assert_eq!(providers, vec!["github", "jira"]);
2340 assert!(context.has_any_provider());
2341 }
2342
2343 #[test]
2348 fn test_proxy_mcp_server_config_defaults() {
2349 let toml_str = r#"
2350 [[proxy_mcp_servers]]
2351 name = "my-server"
2352 url = "https://example.com/mcp"
2353 "#;
2354
2355 let config: Config = toml::from_str(toml_str).unwrap();
2356 assert_eq!(config.proxy_mcp_servers.len(), 1);
2357
2358 let proxy = &config.proxy_mcp_servers[0];
2359 assert_eq!(proxy.name, "my-server");
2360 assert_eq!(proxy.url, "https://example.com/mcp");
2361 assert_eq!(proxy.auth_type, "none");
2362 assert_eq!(proxy.transport, "sse");
2363 assert!(proxy.token_key.is_none());
2364 assert!(proxy.tool_prefix.is_none());
2365 }
2366
2367 #[test]
2368 fn test_proxy_mcp_server_config_full() {
2369 let toml_str = r#"
2370 [[proxy_mcp_servers]]
2371 name = "devboy-cloud"
2372 url = "https://app.devboy.pro/api/mcp"
2373 auth_type = "bearer"
2374 token_key = "devboy-cloud.token"
2375 tool_prefix = "cloud"
2376 transport = "streamable-http"
2377 "#;
2378
2379 let config: Config = toml::from_str(toml_str).unwrap();
2380 let proxy = &config.proxy_mcp_servers[0];
2381
2382 assert_eq!(proxy.name, "devboy-cloud");
2383 assert_eq!(proxy.auth_type, "bearer");
2384 assert_eq!(proxy.token_key.as_deref(), Some("devboy-cloud.token"));
2385 assert_eq!(proxy.tool_prefix.as_deref(), Some("cloud"));
2386 assert_eq!(proxy.transport, "streamable-http");
2387 }
2388
2389 #[test]
2390 fn test_proxy_mcp_server_config_multiple() {
2391 let toml_str = r#"
2392 [[proxy_mcp_servers]]
2393 name = "server1"
2394 url = "https://s1.example.com/mcp"
2395
2396 [[proxy_mcp_servers]]
2397 name = "server2"
2398 url = "https://s2.example.com/mcp"
2399 auth_type = "api_key"
2400 token_key = "s2.token"
2401 "#;
2402
2403 let config: Config = toml::from_str(toml_str).unwrap();
2404 assert_eq!(config.proxy_mcp_servers.len(), 2);
2405 assert_eq!(config.proxy_mcp_servers[0].name, "server1");
2406 assert_eq!(config.proxy_mcp_servers[1].name, "server2");
2407 assert_eq!(config.proxy_mcp_servers[1].auth_type, "api_key");
2408 }
2409
2410 #[test]
2411 fn test_proxy_mcp_server_config_serialization_roundtrip() {
2412 let config = Config {
2413 proxy_mcp_servers: vec![ProxyMcpServerConfig {
2414 name: "test".to_string(),
2415 url: "https://test.com/mcp".to_string(),
2416 auth_type: "bearer".to_string(),
2417 token_key: Some("test.token".to_string()),
2418 tool_prefix: Some("tst".to_string()),
2419 transport: "streamable-http".to_string(),
2420 routing: None,
2421 }],
2422 ..Default::default()
2423 };
2424
2425 let toml_str = toml::to_string_pretty(&config).unwrap();
2426 assert!(toml_str.contains("[[proxy_mcp_servers]]"));
2427 assert!(toml_str.contains("name = \"test\""));
2428
2429 let parsed: Config = toml::from_str(&toml_str).unwrap();
2430 assert_eq!(parsed.proxy_mcp_servers.len(), 1);
2431 assert_eq!(parsed.proxy_mcp_servers[0].name, "test");
2432 assert_eq!(parsed.proxy_mcp_servers[0].transport, "streamable-http");
2433 }
2434
2435 #[test]
2436 fn test_proxy_mcp_server_config_skips_none_fields_in_serialization() {
2437 let config = Config {
2438 proxy_mcp_servers: vec![ProxyMcpServerConfig {
2439 name: "minimal".to_string(),
2440 url: "https://test.com/mcp".to_string(),
2441 auth_type: "none".to_string(),
2442 token_key: None,
2443 tool_prefix: None,
2444 transport: "sse".to_string(),
2445 routing: None,
2446 }],
2447 ..Default::default()
2448 };
2449
2450 let toml_str = toml::to_string_pretty(&config).unwrap();
2451 assert!(!toml_str.contains("token_key"));
2452 assert!(!toml_str.contains("tool_prefix"));
2453 }
2454
2455 #[test]
2456 fn test_empty_proxy_mcp_servers_not_serialized() {
2457 let config = Config::default();
2458 let toml_str = toml::to_string_pretty(&config).unwrap();
2459 assert!(!toml_str.contains("proxy_mcp_servers"));
2460 }
2461
2462 #[test]
2467 fn test_proxy_config_default_is_default() {
2468 let cfg = ProxyConfig::default();
2469 assert!(cfg.is_default());
2470 }
2471
2472 #[test]
2473 fn test_default_proxy_section_not_serialized() {
2474 let config = Config::default();
2475 let toml_str = toml::to_string_pretty(&config).unwrap();
2476 assert!(!toml_str.contains("[proxy]"));
2477 assert!(!toml_str.contains("[proxy.routing]"));
2478 }
2479
2480 #[test]
2481 fn test_routing_strategy_default_is_remote() {
2482 let strategy = RoutingStrategy::default();
2483 assert_eq!(strategy, RoutingStrategy::Remote);
2484 }
2485
2486 #[test]
2487 fn test_routing_strategy_parse_tolerates_formats() {
2488 assert_eq!(
2489 RoutingStrategy::parse("remote"),
2490 Some(RoutingStrategy::Remote)
2491 );
2492 assert_eq!(
2493 RoutingStrategy::parse(" REMOTE "),
2494 Some(RoutingStrategy::Remote)
2495 );
2496 assert_eq!(
2497 RoutingStrategy::parse("local"),
2498 Some(RoutingStrategy::Local)
2499 );
2500 assert_eq!(
2501 RoutingStrategy::parse("local-first"),
2502 Some(RoutingStrategy::LocalFirst)
2503 );
2504 assert_eq!(
2505 RoutingStrategy::parse("local_first"),
2506 Some(RoutingStrategy::LocalFirst)
2507 );
2508 assert_eq!(
2509 RoutingStrategy::parse("remote-first"),
2510 Some(RoutingStrategy::RemoteFirst)
2511 );
2512 assert_eq!(RoutingStrategy::parse("unknown"), None);
2513 }
2514
2515 #[test]
2516 fn test_routing_strategy_serde_kebab_case() {
2517 let toml_str = r#"
2518 [proxy.routing]
2519 strategy = "local-first"
2520 "#;
2521 let config: Config = toml::from_str(toml_str).unwrap();
2522 assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2523
2524 let serialized = toml::to_string_pretty(&config).unwrap();
2526 assert!(serialized.contains("strategy = \"local-first\""));
2527 }
2528
2529 #[test]
2530 fn test_proxy_routing_strategy_for_picks_first_matching_override() {
2531 let routing = ProxyRoutingConfig {
2532 strategy: RoutingStrategy::Remote,
2533 fallback_on_error: true,
2534 tool_overrides: vec![
2535 ProxyToolRule {
2536 pattern: "create_*".to_string(),
2537 strategy: RoutingStrategy::Remote,
2538 },
2539 ProxyToolRule {
2540 pattern: "get_*".to_string(),
2541 strategy: RoutingStrategy::LocalFirst,
2542 },
2543 ProxyToolRule {
2544 pattern: "*".to_string(),
2545 strategy: RoutingStrategy::Local,
2546 },
2547 ],
2548 };
2549
2550 assert_eq!(
2551 routing.strategy_for("create_issue"),
2552 RoutingStrategy::Remote
2553 );
2554 assert_eq!(
2555 routing.strategy_for("get_issues"),
2556 RoutingStrategy::LocalFirst
2557 );
2558 assert_eq!(
2559 routing.strategy_for("anything_else"),
2560 RoutingStrategy::Local
2561 );
2562 }
2563
2564 #[test]
2565 fn test_proxy_routing_strategy_for_falls_back_to_global() {
2566 let routing = ProxyRoutingConfig {
2567 strategy: RoutingStrategy::Remote,
2568 fallback_on_error: true,
2569 tool_overrides: vec![ProxyToolRule {
2570 pattern: "get_*".to_string(),
2571 strategy: RoutingStrategy::LocalFirst,
2572 }],
2573 };
2574
2575 assert_eq!(
2576 routing.strategy_for("unrelated_tool"),
2577 RoutingStrategy::Remote
2578 );
2579 }
2580
2581 #[test]
2582 fn test_proxy_routing_merged_with_override_wins() {
2583 let global = ProxyRoutingConfig {
2584 strategy: RoutingStrategy::Remote,
2585 fallback_on_error: true,
2586 tool_overrides: vec![ProxyToolRule {
2587 pattern: "get_*".to_string(),
2588 strategy: RoutingStrategy::LocalFirst,
2589 }],
2590 };
2591 let override_cfg = ProxyRoutingOverride {
2592 strategy: Some(RoutingStrategy::Local),
2593 fallback_on_error: Some(false),
2594 tool_overrides: Some(vec![ProxyToolRule {
2595 pattern: "create_*".to_string(),
2596 strategy: RoutingStrategy::Remote,
2597 }]),
2598 };
2599
2600 let merged = global.merged_with(Some(&override_cfg));
2601 assert_eq!(merged.strategy, RoutingStrategy::Local);
2602 assert!(!merged.fallback_on_error);
2603 assert_eq!(merged.tool_overrides.len(), 2);
2605 assert_eq!(merged.tool_overrides[0].pattern, "create_*");
2606 assert_eq!(merged.tool_overrides[1].pattern, "get_*");
2607 }
2608
2609 #[test]
2610 fn test_proxy_routing_merged_with_partial_override_preserves_unset_fields() {
2611 let global = ProxyRoutingConfig {
2614 strategy: RoutingStrategy::Remote,
2615 fallback_on_error: false, tool_overrides: vec![ProxyToolRule {
2617 pattern: "get_*".to_string(),
2618 strategy: RoutingStrategy::LocalFirst,
2619 }],
2620 };
2621 let override_cfg = ProxyRoutingOverride {
2623 strategy: Some(RoutingStrategy::Local),
2624 fallback_on_error: None,
2625 tool_overrides: None,
2626 };
2627
2628 let merged = global.merged_with(Some(&override_cfg));
2629 assert_eq!(merged.strategy, RoutingStrategy::Local);
2630 assert!(
2631 !merged.fallback_on_error,
2632 "fallback_on_error must inherit from global, not snap to default"
2633 );
2634 assert_eq!(
2635 merged.tool_overrides.len(),
2636 1,
2637 "tool_overrides must inherit from global when override omits them"
2638 );
2639 assert_eq!(merged.tool_overrides[0].pattern, "get_*");
2640 }
2641
2642 #[test]
2643 fn test_proxy_routing_merged_with_none_returns_clone() {
2644 let global = ProxyRoutingConfig {
2645 strategy: RoutingStrategy::LocalFirst,
2646 ..Default::default()
2647 };
2648 let merged = global.merged_with(None);
2649 assert_eq!(merged.strategy, RoutingStrategy::LocalFirst);
2650 }
2651
2652 #[test]
2653 fn test_proxy_secrets_default_cache_ttl() {
2654 let s = ProxySecretsConfig::default();
2655 assert_eq!(s.cache_ttl_secs, 300);
2656 assert!(s.is_default());
2657 }
2658
2659 #[test]
2660 fn test_proxy_telemetry_defaults() {
2661 let t = ProxyTelemetryConfig::default();
2662 assert!(t.enabled);
2663 assert_eq!(t.batch_size, 100);
2664 assert_eq!(t.batch_interval_secs, 30);
2665 assert!(t.endpoint.is_none());
2666 assert!(t.is_default());
2667 }
2668
2669 #[test]
2670 fn test_proxy_toml_parse_full() {
2671 let toml_str = r#"
2672 [proxy.routing]
2673 strategy = "local-first"
2674 fallback_on_error = false
2675
2676 [[proxy.routing.tool_overrides]]
2677 pattern = "create_*"
2678 strategy = "remote"
2679
2680 [proxy.secrets]
2681 cache_ttl_secs = 120
2682
2683 [proxy.telemetry]
2684 enabled = true
2685 batch_size = 50
2686 batch_interval_secs = 10
2687 endpoint = "https://telemetry.example.com/api/events"
2688 "#;
2689
2690 let config: Config = toml::from_str(toml_str).unwrap();
2691 assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2692 assert!(!config.proxy.routing.fallback_on_error);
2693 assert_eq!(config.proxy.routing.tool_overrides.len(), 1);
2694 assert_eq!(config.proxy.secrets.cache_ttl_secs, 120);
2695 assert_eq!(config.proxy.telemetry.batch_size, 50);
2696 assert_eq!(
2697 config.proxy.telemetry.endpoint.as_deref(),
2698 Some("https://telemetry.example.com/api/events")
2699 );
2700 }
2701
2702 #[test]
2703 fn test_proxy_mcp_server_per_server_routing_override() {
2704 let toml_str = r#"
2705 [[proxy_mcp_servers]]
2706 name = "cloud"
2707 url = "https://api.example.com/mcp"
2708
2709 [proxy_mcp_servers.routing]
2710 strategy = "local-first"
2711 "#;
2712
2713 let config: Config = toml::from_str(toml_str).unwrap();
2714 let server = &config.proxy_mcp_servers[0];
2715 let override_cfg = server.routing.as_ref().expect("override present");
2716 assert_eq!(override_cfg.strategy, Some(RoutingStrategy::LocalFirst));
2718 assert!(override_cfg.fallback_on_error.is_none());
2719 assert!(override_cfg.tool_overrides.is_none());
2720 }
2721
2722 #[test]
2727 fn test_set_get_proxy_routing_strategy_roundtrip() {
2728 let mut cfg = Config::default();
2729 cfg.set("proxy.routing.strategy", "local-first").unwrap();
2730 assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
2731 assert_eq!(
2732 cfg.get("proxy.routing.strategy").unwrap().as_deref(),
2733 Some("local-first")
2734 );
2735
2736 cfg.set("proxy.routing.strategy", "remote").unwrap();
2737 assert_eq!(
2738 cfg.get("proxy.routing.strategy").unwrap().as_deref(),
2739 Some("remote")
2740 );
2741 }
2742
2743 #[test]
2744 fn test_set_proxy_routing_strategy_rejects_garbage() {
2745 let mut cfg = Config::default();
2746 let err = cfg
2747 .set("proxy.routing.strategy", "teleport")
2748 .unwrap_err()
2749 .to_string();
2750 assert!(err.contains("Invalid routing strategy"));
2751 }
2752
2753 #[test]
2754 fn test_set_proxy_routing_booleans_accept_many_forms() {
2755 let mut cfg = Config::default();
2756 for truthy in ["true", "TRUE", "1", "yes", "on"] {
2757 cfg.set("proxy.routing.fallback_on_error", truthy).unwrap();
2758 assert!(cfg.proxy.routing.fallback_on_error);
2759 }
2760 for falsy in ["false", "0", "no", "off"] {
2761 cfg.set("proxy.routing.fallback_on_error", falsy).unwrap();
2762 assert!(!cfg.proxy.routing.fallback_on_error);
2763 }
2764 }
2765
2766 #[test]
2767 fn test_set_proxy_secrets_cache_ttl() {
2768 let mut cfg = Config::default();
2769 cfg.set("proxy.secrets.cache_ttl_secs", "120").unwrap();
2770 assert_eq!(cfg.proxy.secrets.cache_ttl_secs, 120);
2771 assert_eq!(
2772 cfg.get("proxy.secrets.cache_ttl_secs").unwrap().as_deref(),
2773 Some("120")
2774 );
2775
2776 assert!(cfg.set("proxy.secrets.cache_ttl_secs", "-5").is_err());
2777 }
2778
2779 #[test]
2780 fn test_set_proxy_telemetry_endpoint_and_clear() {
2781 let mut cfg = Config::default();
2782 cfg.set("proxy.telemetry.endpoint", "https://example.com/t")
2783 .unwrap();
2784 assert_eq!(
2785 cfg.proxy.telemetry.endpoint.as_deref(),
2786 Some("https://example.com/t")
2787 );
2788
2789 cfg.set("proxy.telemetry.endpoint", "").unwrap();
2791 assert!(cfg.proxy.telemetry.endpoint.is_none());
2792 }
2793
2794 #[test]
2795 fn test_set_proxy_telemetry_endpoint_rejects_garbage() {
2796 let mut cfg = Config::default();
2797 for bad in [
2798 "not-a-url",
2799 "ftp://host.example.com",
2800 "//example.com",
2801 "https://",
2802 "http:// space.example.com",
2803 "https://example.com/a b",
2805 "https://example.com/path?key=a b",
2806 "https://example.com/\tpath",
2807 "https://example.com/ ",
2808 ] {
2809 match cfg.set("proxy.telemetry.endpoint", bad) {
2810 Ok(()) => panic!("expected reject for {}", bad),
2811 Err(e) => assert!(
2812 e.to_string().contains("Invalid URL"),
2813 "bad={}, err={}",
2814 bad,
2815 e
2816 ),
2817 }
2818 }
2819 }
2820
2821 #[test]
2822 fn test_set_proxy_telemetry_endpoint_accepts_common_forms() {
2823 let mut cfg = Config::default();
2824 for good in [
2825 "https://app.example.com/api/telemetry/tool-invocations",
2826 "http://localhost:4335/api/telemetry/tool-invocations",
2827 "https://example.com",
2828 "http://10.0.0.1:8080/",
2829 ] {
2830 cfg.set("proxy.telemetry.endpoint", good)
2831 .unwrap_or_else(|e| panic!("expected accept for {}: {}", good, e));
2832 }
2833 }
2834
2835 #[test]
2840 fn test_validate_rejects_bad_endpoint_from_toml() {
2841 let toml_str = r#"
2844 [proxy.telemetry]
2845 endpoint = "not-a-url"
2846 "#;
2847 let config: Config = toml::from_str(toml_str).unwrap();
2848 let err = config
2849 .validate()
2850 .expect_err("expected validation to fail for 'not-a-url'");
2851 assert!(
2852 err.to_string().contains("Invalid URL"),
2853 "unexpected error: {}",
2854 err
2855 );
2856 }
2857
2858 #[test]
2859 fn test_validate_accepts_empty_endpoint_as_absent() {
2860 let config = Config::default();
2863 config.validate().expect("default config validates");
2864 }
2865
2866 #[test]
2867 fn test_sanitize_normalizes_empty_endpoint_to_none() {
2868 let mut config: Config = toml::from_str(
2872 r#"
2873[proxy.telemetry]
2874endpoint = ""
2875"#,
2876 )
2877 .unwrap();
2878 assert_eq!(config.proxy.telemetry.endpoint.as_deref(), Some(""));
2879 config.sanitize();
2880 assert!(config.proxy.telemetry.endpoint.is_none());
2881 config.validate().expect("sanitized config must validate");
2882 }
2883
2884 #[test]
2885 fn test_load_from_sanitizes_empty_endpoint() {
2886 use std::fs::write;
2887 let dir = tempfile::tempdir().unwrap();
2888 let path = dir.path().join("config.toml");
2889 write(
2890 &path,
2891 r#"
2892[proxy.telemetry]
2893endpoint = ""
2894"#,
2895 )
2896 .unwrap();
2897
2898 let cfg = Config::load_from(&path).expect("empty endpoint must be normalised on load");
2899 assert!(
2900 cfg.proxy.telemetry.endpoint.is_none(),
2901 "empty string must load as None, not Some(\"\")"
2902 );
2903 }
2904
2905 #[test]
2906 fn test_validate_rejects_naked_empty_string_endpoint() {
2907 let mut config = Config::default();
2910 config.proxy.telemetry.endpoint = Some(String::new());
2911 let err = config
2912 .validate()
2913 .expect_err("empty string must be rejected if caller skipped sanitize");
2914 assert!(
2915 err.to_string().contains("Invalid URL"),
2916 "unexpected error: {}",
2917 err
2918 );
2919 }
2920
2921 #[test]
2922 fn test_load_from_runs_validation() {
2923 use std::fs::write;
2924 let dir = tempfile::tempdir().unwrap();
2925 let path = dir.path().join("config.toml");
2926 write(
2927 &path,
2928 r#"
2929[proxy.telemetry]
2930endpoint = "ftp://wrong-scheme.example.com"
2931"#,
2932 )
2933 .unwrap();
2934
2935 let err = Config::load_from(&path).expect_err("must reject bad URL from file");
2936 assert!(
2937 err.to_string().contains("Invalid URL"),
2938 "unexpected error: {}",
2939 err
2940 );
2941 }
2942
2943 #[test]
2948 fn test_unknown_field_in_proxy_routing_rejected() {
2949 let toml_str = r#"
2950 [proxy.routing]
2951 strategy = "local-first"
2952 startegy = "typo"
2953 "#;
2954 let err = toml::from_str::<Config>(toml_str)
2955 .expect_err("expected parse error for typo 'startegy'");
2956 let msg = err.to_string();
2957 assert!(
2958 msg.contains("startegy") || msg.contains("unknown field"),
2959 "unexpected error: {}",
2960 msg
2961 );
2962 }
2963
2964 #[test]
2965 fn test_unknown_field_in_proxy_secrets_rejected() {
2966 let toml_str = r#"
2967 [proxy.secrets]
2968 cache_ttl_secs = 60
2969 chache_ttl_secs = 120
2970 "#;
2971 let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
2972 assert!(
2973 err.to_string().contains("chache_ttl_secs")
2974 || err.to_string().contains("unknown field")
2975 );
2976 }
2977
2978 #[test]
2979 fn test_unknown_field_in_proxy_telemetry_rejected() {
2980 let toml_str = r#"
2981 [proxy.telemetry]
2982 enabled = true
2983 endpooint = "https://example.com"
2984 "#;
2985 let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
2986 assert!(err.to_string().contains("endpooint") || err.to_string().contains("unknown field"));
2987 }
2988
2989 #[test]
2990 fn test_unknown_field_in_tool_override_rejected() {
2991 let toml_str = r#"
2992 [[proxy.routing.tool_overrides]]
2993 pattern = "get_*"
2994 strategy = "local"
2995 unknown = 1
2996 "#;
2997 let err = toml::from_str::<Config>(toml_str).expect_err("typo in rule must fail");
2998 assert!(err.to_string().contains("unknown"));
2999 }
3000
3001 #[test]
3002 fn test_unknown_top_level_proxy_section_rejected() {
3003 let toml_str = r#"
3005 [proxy.typo]
3006 foo = 1
3007 "#;
3008 let err = toml::from_str::<Config>(toml_str).expect_err("unknown section must fail");
3009 let msg = err.to_string();
3010 assert!(msg.contains("typo") || msg.contains("unknown field"));
3011 }
3012
3013 #[test]
3014 fn test_load_from_accepts_valid_proxy_config() {
3015 use std::fs::write;
3016 let dir = tempfile::tempdir().unwrap();
3017 let path = dir.path().join("config.toml");
3018 write(
3019 &path,
3020 r#"
3021[proxy.routing]
3022strategy = "local-first"
3023
3024[proxy.telemetry]
3025endpoint = "https://app.example.com/api/telemetry/tool-invocations"
3026"#,
3027 )
3028 .unwrap();
3029
3030 let cfg = Config::load_from(&path).expect("valid config must load");
3031 assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
3032 assert_eq!(
3033 cfg.proxy.telemetry.endpoint.as_deref(),
3034 Some("https://app.example.com/api/telemetry/tool-invocations")
3035 );
3036 }
3037
3038 #[test]
3039 fn test_set_proxy_telemetry_batch_fields() {
3040 let mut cfg = Config::default();
3041 cfg.set("proxy.telemetry.batch_size", "50").unwrap();
3042 cfg.set("proxy.telemetry.batch_interval_secs", "15")
3043 .unwrap();
3044 cfg.set("proxy.telemetry.offline_queue_max", "2000")
3045 .unwrap();
3046
3047 assert_eq!(cfg.proxy.telemetry.batch_size, 50);
3048 assert_eq!(cfg.proxy.telemetry.batch_interval_secs, 15);
3049 assert_eq!(cfg.proxy.telemetry.offline_queue_max, 2000);
3050 }
3051
3052 #[test]
3053 fn test_unknown_proxy_section_or_field_errors() {
3054 let mut cfg = Config::default();
3055 assert!(cfg.set("proxy.unknown.foo", "1").is_err());
3056 assert!(cfg.set("proxy.routing.unknown", "1").is_err());
3057 assert!(cfg.get("proxy.unknown.foo").is_err());
3058 assert!(cfg.get("proxy.routing.unknown").is_err());
3059 }
3060
3061 #[test]
3062 fn test_four_part_key_rejected() {
3063 let mut cfg = Config::default();
3064 assert!(cfg.set("proxy.routing.strategy.extra", "local").is_err());
3065 }
3066
3067 #[test]
3072 fn test_legacy_config_without_proxy_section_still_parses() {
3073 let toml_str = r#"
3075 [github]
3076 owner = "me"
3077 repo = "repo"
3078
3079 [[proxy_mcp_servers]]
3080 name = "cloud"
3081 url = "https://api.example.com/mcp"
3082 "#;
3083 let config: Config = toml::from_str(toml_str).unwrap();
3084 assert_eq!(config.github.unwrap().owner, "me");
3085 assert_eq!(config.proxy_mcp_servers.len(), 1);
3086 assert!(config.proxy.is_default());
3087 }
3088
3089 #[test]
3094 fn test_matches_glob_exact() {
3095 assert!(matches_glob("get_issues", "get_issues"));
3096 assert!(!matches_glob("get_issues", "get_issue"));
3097 assert!(!matches_glob("get_issues", "gets_issues"));
3098 }
3099
3100 #[test]
3101 fn test_matches_glob_star_alone() {
3102 assert!(matches_glob("*", ""));
3103 assert!(matches_glob("*", "anything"));
3104 assert!(matches_glob("*", "create_merge_request"));
3105 }
3106
3107 #[test]
3108 fn test_matches_glob_prefix() {
3109 assert!(matches_glob("get_*", "get_issues"));
3110 assert!(matches_glob("get_*", "get_"));
3111 assert!(!matches_glob("get_*", "create_issues"));
3112 }
3113
3114 #[test]
3115 fn test_matches_glob_suffix() {
3116 assert!(matches_glob("*_issue", "create_issue"));
3117 assert!(matches_glob("*_issue", "_issue"));
3118 assert!(!matches_glob("*_issue", "create_issues"));
3119 }
3120
3121 #[test]
3122 fn test_matches_glob_contains() {
3123 assert!(matches_glob("*issue*", "get_issues"));
3124 assert!(matches_glob("*issue*", "issue"));
3125 assert!(!matches_glob("*issue*", "merge_request"));
3126 }
3127
3128 #[test]
3129 fn test_matches_glob_multiple_wildcards() {
3130 assert!(matches_glob("get_*_by_*", "get_issue_by_id"));
3131 assert!(matches_glob("get_*_by_*", "get_user_by_email"));
3132 assert!(!matches_glob("get_*_by_*", "get_issue"));
3133 assert!(!matches_glob("get_*_by_*", "create_issue_by_id"));
3134 }
3135
3136 #[test]
3137 fn test_matches_glob_collapses_double_star() {
3138 assert!(matches_glob("get_**_issue", "get_new_issue"));
3139 }
3140
3141 #[test]
3146 fn test_builtin_tools_config_default_is_empty() {
3147 let config = BuiltinToolsConfig::default();
3148 assert!(config.is_empty());
3149 assert!(config.validate().is_ok());
3150 assert!(config.is_tool_allowed("get_issues"));
3151 }
3152
3153 #[test]
3154 fn test_builtin_tools_disabled_mode() {
3155 let config = BuiltinToolsConfig {
3156 disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
3157 enabled: vec![],
3158 };
3159 assert!(!config.is_empty());
3160 assert!(config.validate().is_ok());
3161 assert!(!config.is_tool_allowed("get_issues"));
3162 assert!(!config.is_tool_allowed("create_issue"));
3163 assert!(config.is_tool_allowed("get_merge_requests"));
3164 assert!(config.is_tool_allowed("list_contexts"));
3165 }
3166
3167 #[test]
3168 fn test_builtin_tools_enabled_mode() {
3169 let config = BuiltinToolsConfig {
3170 disabled: vec![],
3171 enabled: vec![
3172 "list_contexts".to_string(),
3173 "use_context".to_string(),
3174 "get_current_context".to_string(),
3175 ],
3176 };
3177 assert!(!config.is_empty());
3178 assert!(config.validate().is_ok());
3179 assert!(config.is_tool_allowed("list_contexts"));
3180 assert!(config.is_tool_allowed("use_context"));
3181 assert!(!config.is_tool_allowed("get_issues"));
3182 assert!(!config.is_tool_allowed("create_issue"));
3183 }
3184
3185 #[test]
3186 fn test_builtin_tools_mutually_exclusive_error() {
3187 let config = BuiltinToolsConfig {
3188 disabled: vec!["get_issues".to_string()],
3189 enabled: vec!["list_contexts".to_string()],
3190 };
3191 assert!(config.validate().is_err());
3192 let err = config.validate().unwrap_err().to_string();
3193 assert!(err.contains("mutually exclusive"));
3194 }
3195
3196 #[test]
3197 fn test_builtin_tools_toml_parsing_disabled() {
3198 let toml_str = r#"
3199 [builtin_tools]
3200 disabled = ["get_issues", "create_issue"]
3201 "#;
3202 let config: Config = toml::from_str(toml_str).unwrap();
3203 assert!(!config.builtin_tools.is_empty());
3204 assert_eq!(config.builtin_tools.disabled.len(), 2);
3205 assert!(config.builtin_tools.enabled.is_empty());
3206 }
3207
3208 #[test]
3209 fn test_builtin_tools_toml_parsing_enabled() {
3210 let toml_str = r#"
3211 [builtin_tools]
3212 enabled = ["list_contexts", "use_context", "get_current_context"]
3213 "#;
3214 let config: Config = toml::from_str(toml_str).unwrap();
3215 assert_eq!(config.builtin_tools.enabled.len(), 3);
3216 assert!(config.builtin_tools.disabled.is_empty());
3217 }
3218
3219 #[test]
3220 fn test_builtin_tools_not_serialized_when_empty() {
3221 let config = Config::default();
3222 let toml_str = toml::to_string_pretty(&config).unwrap();
3223 assert!(!toml_str.contains("builtin_tools"));
3224 }
3225
3226 #[test]
3227 fn test_builtin_tools_serialization_roundtrip() {
3228 let config = Config {
3229 builtin_tools: BuiltinToolsConfig {
3230 disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
3231 enabled: vec![],
3232 },
3233 ..Default::default()
3234 };
3235 let toml_str = toml::to_string_pretty(&config).unwrap();
3236 assert!(toml_str.contains("[builtin_tools]"));
3237 assert!(toml_str.contains("get_issues"));
3238
3239 let parsed: Config = toml::from_str(&toml_str).unwrap();
3240 assert_eq!(parsed.builtin_tools.disabled.len(), 2);
3241 }
3242
3243 #[test]
3244 fn test_builtin_tools_warn_unknown_with_unknown_names() {
3245 let known = &["get_issues", "create_issue"];
3246 let config = BuiltinToolsConfig {
3247 disabled: vec!["get_issues".to_string(), "nonexistent_tool".to_string()],
3248 enabled: vec![],
3249 };
3250 config.warn_unknown_tools(known);
3252 }
3253
3254 #[test]
3255 fn test_builtin_tools_warn_unknown_all_known() {
3256 let known = &["get_issues", "create_issue"];
3257 let config = BuiltinToolsConfig {
3258 disabled: vec!["get_issues".to_string()],
3259 enabled: vec![],
3260 };
3261 config.warn_unknown_tools(known);
3263 }
3264
3265 #[test]
3266 fn test_builtin_tools_warn_unknown_in_enabled_list() {
3267 let known = &["get_issues", "create_issue"];
3268 let config = BuiltinToolsConfig {
3269 disabled: vec![],
3270 enabled: vec!["get_issues".to_string(), "unknown_tool".to_string()],
3271 };
3272 config.warn_unknown_tools(known);
3274 }
3275
3276 #[test]
3277 fn test_builtin_tools_warn_unknown_empty_config() {
3278 let known = &["get_issues"];
3279 let config = BuiltinToolsConfig::default();
3280 config.warn_unknown_tools(known);
3282 }
3283}