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