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