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