1use crate::PropertiesResolver;
2use camel_api::CamelError;
3use camel_api::datasource::DatasourceConfig;
4use camel_core::TracerConfig;
5use config::{Config, ConfigError};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::env;
9use std::fmt;
10use std::time::Duration;
11
12#[derive(Debug, Clone, Deserialize)]
13pub struct CamelConfig {
14 #[serde(default)]
15 pub routes: Vec<String>,
16
17 #[serde(default)]
21 pub watch: bool,
22
23 #[serde(default)]
27 pub runtime_journal: Option<JournalConfig>,
28
29 #[serde(default = "default_log_level")]
30 pub log_level: String,
31
32 #[serde(default = "default_timeout_ms")]
33 pub timeout_ms: u64,
34
35 #[serde(default = "default_drain_timeout_ms")]
36 pub drain_timeout_ms: u64,
37
38 #[serde(default = "default_watch_debounce_ms")]
39 pub watch_debounce_ms: u64,
42
43 #[serde(default)]
44 pub components: ComponentsConfig,
45
46 #[serde(default)]
47 pub observability: ObservabilityConfig,
48
49 #[serde(default)]
50 pub supervision: Option<SupervisionCamelConfig>,
51
52 #[serde(default)]
53 pub platform: PlatformCamelConfig,
54
55 #[serde(default)]
56 pub stream_caching: StreamCachingConfig,
57
58 #[serde(default)]
59 pub beans: HashMap<String, BeanConfig>,
60
61 #[serde(default)]
64 pub languages: crate::LanguagesConfig,
65
66 #[serde(default)]
67 pub security: SecurityConfig,
68
69 #[serde(default)]
70 pub datasources: HashMap<String, DatasourceConfig>,
71
72 #[serde(flatten)]
76 pub _extra: HashMap<String, toml::Value>,
77}
78
79#[derive(Debug, Default, Clone)]
80pub struct CamelConfigBuilder {
81 pub routes: Option<Vec<String>>,
82 pub watch: Option<bool>,
83 pub log_level: Option<String>,
84 pub timeout_ms: Option<u64>,
85 pub drain_timeout_ms: Option<u64>,
86 pub watch_debounce_ms: Option<u64>,
87}
88
89impl CamelConfigBuilder {
90 pub fn routes(mut self, v: Vec<String>) -> Self {
91 self.routes = Some(v);
92 self
93 }
94
95 pub fn watch(mut self, v: bool) -> Self {
96 self.watch = Some(v);
97 self
98 }
99
100 pub fn log_level(mut self, v: impl Into<String>) -> Self {
101 self.log_level = Some(v.into());
102 self
103 }
104
105 pub fn timeout_ms(mut self, v: u64) -> Self {
106 self.timeout_ms = Some(v);
107 self
108 }
109
110 pub fn drain_timeout_ms(mut self, v: u64) -> Self {
111 self.drain_timeout_ms = Some(v);
112 self
113 }
114
115 pub fn watch_debounce_ms(mut self, v: u64) -> Self {
116 self.watch_debounce_ms = Some(v);
117 self
118 }
119
120 pub fn build(self) -> CamelConfig {
121 let defaults = CamelConfig::default();
122 CamelConfig {
123 routes: self.routes.unwrap_or(defaults.routes),
124 watch: self.watch.unwrap_or(defaults.watch),
125 runtime_journal: defaults.runtime_journal,
126 log_level: self.log_level.unwrap_or(defaults.log_level),
127 timeout_ms: self.timeout_ms.unwrap_or(defaults.timeout_ms),
128 drain_timeout_ms: self.drain_timeout_ms.unwrap_or(defaults.drain_timeout_ms),
129 watch_debounce_ms: self.watch_debounce_ms.unwrap_or(defaults.watch_debounce_ms),
130 components: defaults.components,
131 observability: defaults.observability,
132 supervision: defaults.supervision,
133 platform: defaults.platform,
134 stream_caching: defaults.stream_caching,
135 beans: defaults.beans,
136 languages: defaults.languages,
137 security: defaults.security,
138 datasources: defaults.datasources,
139 _extra: defaults._extra,
140 }
141 }
142}
143
144impl Default for CamelConfig {
145 fn default() -> Self {
146 Self {
147 routes: Vec::new(),
148 watch: false,
149 runtime_journal: None,
150 log_level: default_log_level(),
151 timeout_ms: default_timeout_ms(),
152 drain_timeout_ms: default_drain_timeout_ms(),
153 watch_debounce_ms: default_watch_debounce_ms(),
154 components: ComponentsConfig::default(),
155 observability: ObservabilityConfig::default(),
156 supervision: None,
157 platform: PlatformCamelConfig::default(),
158 stream_caching: StreamCachingConfig::default(),
159 beans: HashMap::new(),
160 languages: crate::LanguagesConfig::default(),
161 security: SecurityConfig::default(),
162 datasources: HashMap::new(),
163 _extra: HashMap::new(),
164 }
165 }
166}
167
168#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
172#[serde(tag = "type", rename_all = "snake_case")]
173pub enum PlatformCamelConfig {
174 #[default]
175 Noop,
176 Kubernetes(KubernetesPlatformCamelConfig),
177}
178
179#[derive(Debug, Clone, Deserialize, PartialEq)]
181#[serde(deny_unknown_fields)]
182pub struct KubernetesPlatformCamelConfig {
183 #[serde(default)]
184 pub namespace: Option<String>,
185 #[serde(default = "default_lease_name_prefix")]
186 pub lease_name_prefix: String,
187 #[serde(default = "default_lease_duration_secs")]
188 pub lease_duration_secs: u64,
189 #[serde(default = "default_renew_deadline_secs")]
190 pub renew_deadline_secs: u64,
191 #[serde(default = "default_retry_period_secs")]
192 pub retry_period_secs: u64,
193 #[serde(default = "default_kubernetes_jitter_factor")]
194 pub jitter_factor: f64,
195}
196
197impl Default for KubernetesPlatformCamelConfig {
198 fn default() -> Self {
199 Self {
200 namespace: None,
201 lease_name_prefix: default_lease_name_prefix(),
202 lease_duration_secs: default_lease_duration_secs(),
203 renew_deadline_secs: default_renew_deadline_secs(),
204 retry_period_secs: default_retry_period_secs(),
205 jitter_factor: default_kubernetes_jitter_factor(),
206 }
207 }
208}
209
210fn default_lease_name_prefix() -> String {
211 "camel-".to_string()
212}
213fn default_lease_duration_secs() -> u64 {
214 15
215}
216fn default_renew_deadline_secs() -> u64 {
217 10
218}
219fn default_retry_period_secs() -> u64 {
220 2
221}
222fn default_kubernetes_jitter_factor() -> f64 {
223 0.2
224}
225
226#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
227pub struct ComponentsConfig {
228 #[serde(flatten)]
231 pub raw: HashMap<String, toml::Value>,
232}
233
234#[derive(Debug, Clone, Deserialize, PartialEq)]
235#[serde(deny_unknown_fields)]
236pub struct PrometheusCamelConfig {
237 #[serde(default)]
238 pub enabled: bool,
239 #[serde(default = "default_prometheus_host")]
240 pub host: String,
241 #[serde(default = "default_prometheus_port")]
242 pub port: u16,
243}
244
245impl Default for PrometheusCamelConfig {
246 fn default() -> Self {
247 Self {
248 enabled: false,
249 host: default_prometheus_host(),
250 port: default_prometheus_port(),
251 }
252 }
253}
254
255fn default_prometheus_host() -> String {
256 "0.0.0.0".to_string()
257}
258fn default_prometheus_port() -> u16 {
259 9090
260}
261
262#[derive(Debug, Clone, Deserialize, PartialEq)]
263#[serde(deny_unknown_fields)]
264pub struct HealthCamelConfig {
265 #[serde(default)]
266 pub enabled: bool,
267 #[serde(default = "default_health_host")]
268 pub host: String,
269 #[serde(default = "default_health_port")]
270 pub port: u16,
271 #[serde(default = "default_health_handler_timeout_ms")]
274 pub handler_timeout_ms: u64,
275 #[serde(default)]
281 pub forced_ttl_ms: Option<u64>,
282}
283
284impl Default for HealthCamelConfig {
285 fn default() -> Self {
286 Self {
287 enabled: false,
288 host: default_health_host(),
289 port: default_health_port(),
290 handler_timeout_ms: default_health_handler_timeout_ms(),
291 forced_ttl_ms: None,
292 }
293 }
294}
295
296fn default_health_host() -> String {
297 "0.0.0.0".to_string()
298}
299
300fn default_health_port() -> u16 {
301 8081
302}
303
304fn default_health_handler_timeout_ms() -> u64 {
305 6000
306}
307
308#[derive(Debug, Clone, Deserialize, Default)]
309#[serde(deny_unknown_fields)]
310pub struct ObservabilityConfig {
311 #[serde(default)]
312 pub tracer: TracerConfig,
313
314 #[serde(default)]
315 pub otel: Option<OtelCamelConfig>,
316
317 #[serde(default)]
318 pub prometheus: Option<PrometheusCamelConfig>,
319
320 #[serde(default)]
321 pub health: Option<HealthCamelConfig>,
322}
323
324#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
326#[serde(rename_all = "snake_case")]
327pub enum OtelProtocol {
328 #[default]
329 Grpc,
330 Http,
331}
332
333#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
335#[serde(rename_all = "snake_case")]
336pub enum OtelSampler {
337 #[default]
338 AlwaysOn,
339 AlwaysOff,
340 Ratio,
341}
342
343#[derive(Debug, Clone, Deserialize)]
345#[serde(deny_unknown_fields)]
346pub struct OtelCamelConfig {
347 #[serde(default)]
348 pub enabled: bool,
349
350 #[serde(default = "default_otel_endpoint")]
351 pub endpoint: String,
352
353 #[serde(default = "default_otel_service_name")]
354 pub service_name: String,
355
356 #[serde(default)]
357 pub protocol: OtelProtocol,
358
359 #[serde(default)]
360 pub sampler: OtelSampler,
361
362 #[serde(default)]
363 pub sampler_ratio: Option<f64>,
364
365 #[serde(default = "default_otel_metrics_interval_ms")]
366 pub metrics_interval_ms: u64,
367
368 #[serde(default = "default_true")]
369 pub logs_enabled: bool,
370
371 #[serde(default)]
372 pub resource_attrs: HashMap<String, String>,
373}
374
375impl Default for OtelCamelConfig {
376 fn default() -> Self {
377 Self {
378 enabled: false,
379 endpoint: default_otel_endpoint(),
380 service_name: default_otel_service_name(),
381 protocol: OtelProtocol::default(),
382 sampler: OtelSampler::default(),
383 sampler_ratio: None,
384 metrics_interval_ms: default_otel_metrics_interval_ms(),
385 logs_enabled: true,
386 resource_attrs: HashMap::new(),
387 }
388 }
389}
390
391#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
392#[serde(deny_unknown_fields)]
393pub struct SupervisionCamelConfig {
394 pub max_attempts: Option<u32>,
396
397 #[serde(default = "default_initial_delay_ms")]
399 pub initial_delay_ms: u64,
400
401 #[serde(default = "default_backoff_multiplier")]
403 pub backoff_multiplier: f64,
404
405 #[serde(default = "default_max_delay_ms")]
407 pub max_delay_ms: u64,
408}
409
410impl Default for SupervisionCamelConfig {
411 fn default() -> Self {
412 Self {
413 max_attempts: Some(5),
414 initial_delay_ms: 1000,
415 backoff_multiplier: 2.0,
416 max_delay_ms: 60000,
417 }
418 }
419}
420
421impl SupervisionCamelConfig {
422 pub fn into_supervision_config(self) -> camel_api::SupervisionConfig {
424 camel_api::SupervisionConfig {
425 max_attempts: self.max_attempts,
426 initial_delay: Duration::from_millis(self.initial_delay_ms),
427 backoff_multiplier: self.backoff_multiplier,
428 max_delay: Duration::from_millis(self.max_delay_ms),
429 }
430 }
431}
432
433#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
439#[serde(rename_all = "snake_case")]
440pub enum JournalDurability {
441 #[default]
443 Immediate,
444 Eventual,
446}
447
448impl From<JournalDurability> for camel_core::JournalDurability {
449 fn from(d: JournalDurability) -> Self {
450 match d {
451 JournalDurability::Immediate => camel_core::JournalDurability::Immediate,
452 JournalDurability::Eventual => camel_core::JournalDurability::Eventual,
453 }
454 }
455}
456
457fn default_compaction_threshold_events() -> u64 {
458 10_000
459}
460
461#[derive(Debug, Clone, Deserialize, PartialEq)]
463#[serde(deny_unknown_fields)]
464pub struct JournalConfig {
465 pub path: std::path::PathBuf,
467
468 #[serde(default)]
470 pub durability: JournalDurability,
471
472 #[serde(default = "default_compaction_threshold_events")]
474 pub compaction_threshold_events: u64,
475}
476
477#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
478#[serde(deny_unknown_fields)]
479pub struct StreamCachingConfig {
480 #[serde(default = "default_stream_cache_threshold")]
481 pub threshold: usize,
482}
483
484fn default_stream_cache_threshold() -> usize {
485 camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLD
486}
487
488impl Default for StreamCachingConfig {
489 fn default() -> Self {
490 Self {
491 threshold: default_stream_cache_threshold(),
492 }
493 }
494}
495
496#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
497#[serde(deny_unknown_fields)]
498pub struct BeanConfig {
499 pub plugin: String,
500 #[serde(default)]
501 pub config: HashMap<String, String>,
502 #[serde(default)]
505 pub limits: crate::wasm_limits::WasmLimitsConfig,
506}
507
508#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
513#[serde(deny_unknown_fields)]
514pub struct SecurityConfig {
515 #[serde(default)]
516 pub oidc: Option<OidcSecurityConfig>,
517 #[serde(default)]
518 pub native: Option<NativeAuthConfig>,
519 #[serde(default)]
520 pub keycloak: Option<KeycloakSecurityConfig>,
521 #[serde(default)]
522 pub permissions: Option<HashMap<String, PermissionProviderConfig>>,
523 #[serde(default)]
524 pub policies: Option<WasmSecurityPoliciesConfig>,
525}
526
527#[derive(Clone, Deserialize, Serialize, PartialEq)]
528#[serde(deny_unknown_fields)]
529pub struct OidcSecurityConfig {
530 pub issuer: String,
531 #[serde(default)]
532 pub jwks_uri: Option<String>,
533 #[serde(default)]
534 pub audience: Vec<String>,
535 #[serde(default)]
536 pub client_id: Option<String>,
537 #[serde(skip_serializing)]
538 #[serde(default)]
539 pub client_secret: Option<String>,
540 #[serde(default)]
541 pub token_endpoint: Option<String>,
542 #[serde(default)]
543 pub introspection_endpoint: Option<String>,
544}
545
546impl fmt::Debug for OidcSecurityConfig {
547 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548 f.debug_struct("OidcSecurityConfig")
549 .field("issuer", &self.issuer)
550 .field("jwks_uri", &self.jwks_uri)
551 .field("audience", &self.audience)
552 .field("client_id", &self.client_id)
553 .field(
554 "client_secret",
555 &self.client_secret.as_ref().map(|_| "[REDACTED]"),
556 )
557 .field("token_endpoint", &self.token_endpoint)
558 .field("introspection_endpoint", &self.introspection_endpoint)
559 .finish()
560 }
561}
562
563#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
564#[non_exhaustive]
565#[serde(deny_unknown_fields)]
566pub struct NativeIssuerConfig {
567 pub issuer: String,
568 #[serde(default)]
569 pub audience: Vec<String>,
570 #[serde(default = "default_token_ttl")]
571 pub token_ttl_secs: u64,
572 pub signing_key_env: String,
573}
574
575fn default_token_ttl() -> u64 {
576 900
577}
578
579#[derive(Clone, Deserialize, Serialize, PartialEq)]
580#[non_exhaustive]
581#[serde(deny_unknown_fields)]
582pub struct NativeM2mClientConfig {
583 pub client_id: String,
584 pub client_secret_env: String,
585 #[serde(default)]
586 pub roles: Vec<String>,
587 #[serde(default)]
588 pub scopes: Vec<String>,
589}
590
591impl fmt::Debug for NativeM2mClientConfig {
592 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
593 f.debug_struct("NativeM2mClientConfig")
594 .field("client_id", &self.client_id)
595 .field("client_secret_env", &"[REDACTED]")
596 .field("roles", &self.roles)
597 .field("scopes", &self.scopes)
598 .finish()
599 }
600}
601
602#[derive(Clone, Deserialize, Serialize, PartialEq)]
603#[serde(deny_unknown_fields)]
604pub struct NativeAuthConfig {
605 pub subject: String,
606 #[serde(default)]
607 pub issuer: Option<String>,
608 #[serde(default)]
609 pub bearer_token: Option<String>,
610 #[serde(default)]
611 pub api_key: Option<String>,
612 #[serde(default)]
613 pub roles: Vec<String>,
614 #[serde(default)]
615 pub scopes: Vec<String>,
616 #[serde(default)]
617 pub token_issuer: Option<NativeIssuerConfig>,
618 #[serde(default)]
619 pub clients: Vec<NativeM2mClientConfig>,
620}
621
622impl fmt::Debug for NativeAuthConfig {
623 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
624 f.debug_struct("NativeAuthConfig")
625 .field("subject", &self.subject)
626 .field("issuer", &self.issuer)
627 .field(
628 "bearer_token",
629 &self.bearer_token.as_ref().map(|_| "[REDACTED]"),
630 )
631 .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
632 .field("roles", &self.roles)
633 .field("scopes", &self.scopes)
634 .field("token_issuer", &self.token_issuer)
635 .field("clients", &self.clients)
636 .finish()
637 }
638}
639
640#[derive(Clone, Deserialize, Serialize, PartialEq)]
641#[serde(deny_unknown_fields)]
642pub struct KeycloakSecurityConfig {
643 pub server_url: String,
644 pub realm: String,
645 pub client_id: String,
646 #[serde(skip_serializing)]
647 pub client_secret: String,
648 #[serde(default)]
649 pub validation: KeycloakValidationConfig,
650 #[serde(default)]
651 pub jwks: KeycloakJwksConfig,
652 #[serde(default)]
653 pub introspection: KeycloakIntrospectionConfig,
654 #[serde(default)]
655 pub uma: Option<KeycloakUmaConfig>,
656 #[serde(default)]
659 pub allow_internal: bool,
660}
661
662impl fmt::Debug for KeycloakSecurityConfig {
663 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
664 f.debug_struct("KeycloakSecurityConfig")
665 .field("server_url", &self.server_url)
666 .field("realm", &self.realm)
667 .field("client_id", &self.client_id)
668 .field("client_secret", &"[REDACTED]")
669 .field("validation", &self.validation)
670 .field("jwks", &self.jwks)
671 .field("introspection", &self.introspection)
672 .field("uma", &self.uma)
673 .field("allow_internal", &self.allow_internal)
674 .finish()
675 }
676}
677
678#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
679#[serde(deny_unknown_fields)]
680pub struct KeycloakValidationConfig {
681 #[serde(default = "default_validation_method")]
682 pub method: String,
683 #[serde(default)]
684 pub audience: Vec<String>,
685 #[serde(default = "default_clock_skew")]
686 pub clock_skew_secs: u64,
687}
688
689impl Default for KeycloakValidationConfig {
690 fn default() -> Self {
691 Self {
692 method: default_validation_method(),
693 audience: Vec::new(),
694 clock_skew_secs: default_clock_skew(),
695 }
696 }
697}
698
699fn default_validation_method() -> String {
700 "local".into()
701}
702
703fn default_clock_skew() -> u64 {
704 30
705}
706
707#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
708#[serde(deny_unknown_fields)]
709pub struct KeycloakJwksConfig {
710 #[serde(default = "default_cache_ttl")]
711 pub cache_ttl_secs: u64,
712 #[serde(default = "default_refresh_skew")]
713 pub refresh_skew_secs: u64,
714}
715
716impl Default for KeycloakJwksConfig {
717 fn default() -> Self {
718 Self {
719 cache_ttl_secs: default_cache_ttl(),
720 refresh_skew_secs: default_refresh_skew(),
721 }
722 }
723}
724
725fn default_cache_ttl() -> u64 {
726 3600
727}
728
729fn default_refresh_skew() -> u64 {
730 60
731}
732
733#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
734#[serde(deny_unknown_fields)]
735pub struct KeycloakIntrospectionConfig {
736 #[serde(default = "default_introspection_max_entries")]
737 pub max_entries: usize,
738 #[serde(default = "default_introspection_ttl")]
739 pub default_ttl_secs: u64,
740 #[serde(default = "default_introspection_negative_ttl")]
741 pub negative_ttl_secs: u64,
742}
743
744impl Default for KeycloakIntrospectionConfig {
745 fn default() -> Self {
746 Self {
747 max_entries: default_introspection_max_entries(),
748 default_ttl_secs: default_introspection_ttl(),
749 negative_ttl_secs: default_introspection_negative_ttl(),
750 }
751 }
752}
753
754fn default_introspection_max_entries() -> usize {
755 10_000
756}
757
758fn default_introspection_ttl() -> u64 {
759 60
760}
761
762fn default_introspection_negative_ttl() -> u64 {
763 5
764}
765
766#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
774#[serde(deny_unknown_fields)]
775pub struct PermissionCacheConfig {
776 #[serde(default = "default_positive_ttl_secs")]
777 pub positive_ttl_secs: u64,
778 #[serde(default = "default_negative_ttl_secs")]
779 pub negative_ttl_secs: u64,
780 #[serde(default = "default_max_entries")]
781 pub max_entries: usize,
782}
783
784#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
785#[serde(deny_unknown_fields)]
786pub struct PermissionProviderConfig {
787 pub provider: String,
788 #[serde(default)]
789 pub path: Option<String>,
790 #[serde(default)]
791 pub config: Option<HashMap<String, String>>,
792 #[serde(default)]
793 pub cache: PermissionCacheConfig,
794 #[serde(default)]
796 pub limits: crate::wasm_limits::WasmLimitsConfig,
797}
798
799#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
811#[serde(deny_unknown_fields)]
812pub struct WasmSecurityPolicyConfig {
813 pub path: String,
815 #[serde(default)]
817 pub limits: crate::wasm_limits::WasmLimitsConfig,
818 #[serde(default)]
820 pub config: HashMap<String, String>,
821}
822
823#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
825#[serde(deny_unknown_fields)]
826pub struct WasmSecurityPoliciesConfig {
827 pub wasm: HashMap<String, WasmSecurityPolicyConfig>,
829}
830
831fn default_positive_ttl_secs() -> u64 {
832 30
833}
834fn default_negative_ttl_secs() -> u64 {
835 5
836}
837fn default_max_entries() -> usize {
838 10_000
839}
840
841impl Default for PermissionCacheConfig {
842 fn default() -> Self {
843 Self {
844 positive_ttl_secs: default_positive_ttl_secs(),
845 negative_ttl_secs: default_negative_ttl_secs(),
846 max_entries: default_max_entries(),
847 }
848 }
849}
850
851#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
856#[serde(deny_unknown_fields)]
857pub struct KeycloakUmaConfig {
858 pub provider: String,
859 #[serde(default)]
860 pub cache: PermissionCacheConfig,
861}
862
863impl From<&JournalConfig> for camel_core::RedbJournalOptions {
864 fn from(cfg: &JournalConfig) -> Self {
865 camel_core::RedbJournalOptions {
866 durability: cfg.durability.clone().into(),
867 compaction_threshold_events: cfg.compaction_threshold_events,
868 }
869 }
870}
871
872fn default_log_level() -> String {
873 "INFO".to_string()
874}
875fn default_timeout_ms() -> u64 {
876 5000
877}
878fn default_drain_timeout_ms() -> u64 {
879 10_000
880}
881fn default_watch_debounce_ms() -> u64 {
882 300
883}
884
885fn default_otel_endpoint() -> String {
886 "http://localhost:4317".to_string()
887}
888fn default_otel_service_name() -> String {
889 "rust-camel".to_string()
890}
891fn default_otel_metrics_interval_ms() -> u64 {
892 60000
893}
894fn default_true() -> bool {
895 true
896}
897
898fn default_initial_delay_ms() -> u64 {
899 1000
900}
901
902fn default_backoff_multiplier() -> f64 {
903 2.0
904}
905
906fn default_max_delay_ms() -> u64 {
907 60000
908}
909
910pub(crate) const MAX_CONFIG_FILE_SIZE: u64 = 16 * 1024 * 1024;
913
914pub(crate) fn read_capped(path: &str, max_bytes: u64) -> Result<String, ConfigError> {
916 let metadata = std::fs::metadata(path)
917 .map_err(|e| ConfigError::Message(format!("Cannot stat `{path}`: {e}")))?;
918 if metadata.len() > max_bytes {
919 return Err(ConfigError::Message(format!(
920 "Config file `{path}` is {} bytes, exceeds max {} bytes",
921 metadata.len(),
922 max_bytes
923 )));
924 }
925 std::fs::read_to_string(path)
926 .map_err(|e| ConfigError::Message(format!("Cannot read `{path}`: {e}")))
927}
928
929async fn read_capped_async(path: &str, max_bytes: u64) -> Result<String, ConfigError> {
933 let path = path.to_owned();
934 tokio::task::spawn_blocking(move || read_capped(&path, max_bytes))
935 .await
936 .map_err(|e| ConfigError::Message(format!("spawn_blocking join error: {e}")))?
937}
938
939pub(crate) fn merge_toml_values(base: &mut toml::Value, overlay: &toml::Value) {
942 match (base, overlay) {
943 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
944 for (key, value) in overlay_table {
945 if let Some(base_value) = base_table.get_mut(key) {
946 merge_toml_values(base_value, value);
948 } else {
949 base_table.insert(key.clone(), value.clone());
951 }
952 }
953 }
954 (base, overlay) => {
956 *base = overlay.clone();
957 }
958 }
959}
960
961impl CamelConfig {
962 fn resolve_placeholders(&mut self) {
963 let resolver = PropertiesResolver::new();
964
965 for route in &mut self.routes {
966 if let Ok(resolved) = resolver.resolve(route) {
967 *route = resolved;
968 } else {
969 tracing::warn!("Failed to resolve placeholder in routes entry; keeping original");
970 }
971 }
972
973 if let Ok(resolved) = resolver.resolve(&self.log_level) {
974 self.log_level = resolved;
975 } else {
976 tracing::warn!("Failed to resolve placeholder in log_level; keeping original");
977 }
978
979 if let Some(otel) = self.observability.otel.as_mut() {
980 resolve_string_in_place(&resolver, &mut otel.endpoint, "observability.otel.endpoint");
981 resolve_string_in_place(
982 &resolver,
983 &mut otel.service_name,
984 "observability.otel.service_name",
985 );
986 for (k, v) in &mut otel.resource_attrs {
987 let field = format!("observability.otel.resource_attrs.{k}");
988 resolve_string_in_place(&resolver, v, &field);
989 }
990 }
991
992 if let Some(prom) = self.observability.prometheus.as_mut() {
993 resolve_string_in_place(&resolver, &mut prom.host, "observability.prometheus.host");
994 }
995
996 if let Some(health) = self.observability.health.as_mut() {
997 resolve_string_in_place(&resolver, &mut health.host, "observability.health.host");
998 }
999
1000 if let PlatformCamelConfig::Kubernetes(k8s) = &mut self.platform {
1001 if let Some(namespace) = k8s.namespace.as_mut() {
1002 resolve_string_in_place(&resolver, namespace, "platform.namespace");
1003 }
1004 resolve_string_in_place(
1005 &resolver,
1006 &mut k8s.lease_name_prefix,
1007 "platform.lease_name_prefix",
1008 );
1009 }
1010
1011 for (component_name, value) in &mut self.components.raw {
1012 resolve_toml_value_placeholders(
1013 &resolver,
1014 value,
1015 &format!("components.{component_name}"),
1016 );
1017 }
1018
1019 for bean in self.beans.values_mut() {
1020 resolve_string_in_place(&resolver, &mut bean.plugin, "beans.*.plugin");
1021 let resolved: HashMap<String, String> = bean
1022 .config
1023 .drain()
1024 .map(|(k, v)| match resolver.resolve(&v) {
1025 Ok(resolved) => (k, resolved),
1026 Err(err) => {
1027 tracing::warn!(key = %k, error = %err, "Failed to resolve bean config placeholder; keeping original");
1028 (k, v)
1029 }
1030 })
1031 .collect();
1032 bean.config = resolved;
1033 }
1034 }
1035
1036 pub fn validate(&self) -> Result<(), CamelError> {
1037 if self.timeout_ms == 0 {
1038 return Err(CamelError::Config("timeout_ms must be > 0".to_string()));
1039 }
1040 if self.drain_timeout_ms == 0 {
1041 return Err(CamelError::Config(
1042 "drain_timeout_ms must be > 0".to_string(),
1043 ));
1044 }
1045 if self.watch_debounce_ms == 0 {
1046 return Err(CamelError::Config(
1047 "watch_debounce_ms must be > 0".to_string(),
1048 ));
1049 }
1050 if let Some(ref journal) = self.runtime_journal {
1051 if journal.path.as_os_str().is_empty() {
1052 return Err(CamelError::Config(
1053 "runtime_journal.path must not be empty".to_string(),
1054 ));
1055 }
1056 if journal.compaction_threshold_events == 0 {
1057 return Err(CamelError::Config(
1058 "runtime_journal.compaction_threshold_events must be > 0".to_string(),
1059 ));
1060 }
1061 }
1062 for (name, bean) in &self.beans {
1063 if bean.plugin.trim().is_empty() {
1064 return Err(CamelError::Config(format!(
1065 "bean '{}' must have a non-empty plugin",
1066 name
1067 )));
1068 }
1069 }
1070 if let Some(ref sup) = self.supervision {
1071 if sup.initial_delay_ms == 0 {
1072 return Err(CamelError::Config(
1073 "supervision.initial_delay_ms must be > 0".to_string(),
1074 ));
1075 }
1076 if sup.max_delay_ms == 0 {
1077 return Err(CamelError::Config(
1078 "supervision.max_delay_ms must be > 0".to_string(),
1079 ));
1080 }
1081 if sup.backoff_multiplier < 1.0 {
1082 return Err(CamelError::Config(
1083 "supervision.backoff_multiplier must be >= 1.0".to_string(),
1084 ));
1085 }
1086 }
1087 if let Some(ref otel) = self.observability.otel
1088 && otel.metrics_interval_ms == 0
1089 {
1090 return Err(CamelError::Config(
1091 "observability.otel.metrics_interval_ms must be > 0".to_string(),
1092 ));
1093 }
1094 if let PlatformCamelConfig::Kubernetes(ref k8s) = self.platform {
1095 if k8s.lease_duration_secs == 0 {
1096 return Err(CamelError::Config(
1097 "platform.lease_duration_secs must be > 0".to_string(),
1098 ));
1099 }
1100 if k8s.renew_deadline_secs == 0 {
1101 return Err(CamelError::Config(
1102 "platform.renew_deadline_secs must be > 0".to_string(),
1103 ));
1104 }
1105 if k8s.retry_period_secs == 0 {
1106 return Err(CamelError::Config(
1107 "platform.retry_period_secs must be > 0".to_string(),
1108 ));
1109 }
1110 if k8s.jitter_factor < 0.0 || k8s.jitter_factor > 1.0 {
1111 return Err(CamelError::Config(
1112 "platform.jitter_factor must be between 0.0 and 1.0".to_string(),
1113 ));
1114 }
1115 }
1116 for (name, ds) in &self.datasources {
1117 if let Err(e) = ds.validate() {
1118 return Err(CamelError::Config(format!("datasource '{}': {}", name, e)));
1119 }
1120 }
1121 Ok(())
1122 }
1123
1124 pub fn from_file(path: &str) -> Result<Self, ConfigError> {
1125 Self::from_file_with_profile(path, None)
1126 }
1127
1128 pub fn from_file_with_env(path: &str) -> Result<Self, ConfigError> {
1129 Self::from_file_with_profile_and_env(path, None)
1130 }
1131
1132 pub fn from_file_with_profile(path: &str, profile: Option<&str>) -> Result<Self, ConfigError> {
1133 Self::load_from_file_inner(path, profile, false)
1134 }
1135
1136 pub fn from_file_with_profile_and_env(
1137 path: &str,
1138 profile: Option<&str>,
1139 ) -> Result<Self, ConfigError> {
1140 Self::load_from_file_inner(path, profile, true)
1141 }
1142
1143 fn load_from_file_inner(
1144 path: &str,
1145 profile: Option<&str>,
1146 merge_env: bool,
1147 ) -> Result<Self, ConfigError> {
1148 let content = read_capped(path, MAX_CONFIG_FILE_SIZE)?;
1149
1150 let base_dir = std::path::Path::new(path)
1151 .parent()
1152 .unwrap_or(std::path::Path::new("."));
1153
1154 let mut root_value: toml::Value = toml::from_str(&content)
1155 .map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))?;
1156
1157 let includes = Self::extract_includes(&root_value)?;
1158
1159 if let toml::Value::Table(ref mut table) = root_value {
1161 table.remove("include");
1162 }
1163
1164 let env_profile = std::env::var("CAMEL_PROFILE").ok();
1165 let effective_profile = profile.or(env_profile.as_deref());
1166
1167 let pre_sources = crate::include::load_includes(base_dir, &includes, effective_profile)?;
1168
1169 build_from_toml_value_inner(root_value, profile, merge_env, pre_sources)
1170 }
1171
1172 fn extract_includes(raw_value: &toml::Value) -> Result<Vec<String>, ConfigError> {
1175 match raw_value.get("include") {
1176 None => Ok(vec![]),
1177 Some(toml::Value::Array(arr)) => {
1178 let mut paths = Vec::with_capacity(arr.len());
1179 for (i, item) in arr.iter().enumerate() {
1180 match item.as_str() {
1181 Some(s) => paths.push(s.to_string()),
1182 None => {
1183 return Err(ConfigError::Message(format!(
1184 "include[{}] must be a string, got: {}",
1185 i, item
1186 )));
1187 }
1188 }
1189 }
1190 Ok(paths)
1191 }
1192 Some(other) => Err(ConfigError::Message(format!(
1193 "'include' must be an array of strings, got: {}",
1194 other.type_str()
1195 ))),
1196 }
1197 }
1198
1199 pub fn from_env_or_default() -> Result<Self, ConfigError> {
1200 let path = env::var("CAMEL_CONFIG_FILE").unwrap_or_else(|_| "Camel.toml".to_string());
1201
1202 Self::from_file(&path)
1203 }
1204
1205 pub async fn from_file_async(path: &str) -> Result<Self, ConfigError> {
1207 Self::from_file_async_with_profile(path, None).await
1208 }
1209
1210 pub async fn from_file_async_with_profile(
1212 path: &str,
1213 profile: Option<&str>,
1214 ) -> Result<Self, ConfigError> {
1215 let content = read_capped_async(path, MAX_CONFIG_FILE_SIZE).await?;
1216
1217 let base_dir_owned = std::path::Path::new(path)
1218 .parent()
1219 .unwrap_or(std::path::Path::new("."))
1220 .to_path_buf();
1221
1222 let mut root_value: toml::Value = toml::from_str(&content)
1223 .map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))?;
1224
1225 let includes = Self::extract_includes(&root_value)?;
1226
1227 if let toml::Value::Table(ref mut table) = root_value {
1228 table.remove("include");
1229 }
1230
1231 let env_profile = std::env::var("CAMEL_PROFILE").ok();
1232 let effective_profile = profile.or(env_profile.as_deref());
1233
1234 let pre_sources =
1235 crate::include::load_includes(&base_dir_owned, &includes, effective_profile)?;
1236
1237 build_from_toml_value_inner(root_value, profile, false, pre_sources)
1238 }
1239
1240 pub async fn from_file_async_with_env(path: &str) -> Result<Self, ConfigError> {
1242 Self::from_file_async_with_profile_and_env(path, None).await
1243 }
1244
1245 pub async fn from_file_async_with_profile_and_env(
1247 path: &str,
1248 profile: Option<&str>,
1249 ) -> Result<Self, ConfigError> {
1250 let content = read_capped_async(path, MAX_CONFIG_FILE_SIZE).await?;
1251
1252 let base_dir_owned = std::path::Path::new(path)
1253 .parent()
1254 .unwrap_or(std::path::Path::new("."))
1255 .to_path_buf();
1256
1257 let mut root_value: toml::Value = toml::from_str(&content)
1258 .map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))?;
1259
1260 let includes = Self::extract_includes(&root_value)?;
1261
1262 if let toml::Value::Table(ref mut table) = root_value {
1263 table.remove("include");
1264 }
1265
1266 let env_profile = std::env::var("CAMEL_PROFILE").ok();
1267 let effective_profile = profile.or(env_profile.as_deref());
1268
1269 let pre_sources =
1270 crate::include::load_includes(&base_dir_owned, &includes, effective_profile)?;
1271
1272 build_from_toml_value_inner(root_value, profile, true, pre_sources)
1273 }
1274}
1275
1276fn parse_env_value(val: &str) -> serde_json::Value {
1279 if let Ok(n) = val.parse::<i64>() {
1280 serde_json::Value::from(n)
1281 } else if let Ok(f) = val.parse::<f64>() {
1282 serde_json::Value::from(f)
1283 } else if let Ok(b) = val.parse::<bool>() {
1284 serde_json::Value::from(b)
1285 } else {
1286 serde_json::Value::from(val)
1287 }
1288}
1289
1290const ALLOWED_ENV_OVERRIDES: &[&str] = &[
1306 "CAMEL_TIMEOUT_MS",
1307 "CAMEL_DRAIN_TIMEOUT_MS",
1308 "CAMEL_WATCH",
1309 "CAMEL_WATCH_DEBOUNCE_MS",
1310 "CAMEL_LOG_LEVEL",
1311 "CAMEL_RUNTIME_JOURNAL_PATH",
1312 "CAMEL_SUPERVISION_INITIAL_DELAY_MS",
1313 "CAMEL_SUPERVISION_MAX_ATTEMPTS",
1314];
1315
1316fn build_from_toml_value_inner(
1319 mut config_value: toml::Value,
1320 profile: Option<&str>,
1321 merge_env: bool,
1322 pre_sources: Vec<String>,
1323) -> Result<CamelConfig, ConfigError> {
1324 let env_profile = env::var("CAMEL_PROFILE").ok();
1328 let profile = profile.or(env_profile.as_deref());
1329
1330 if let toml::Value::Table(ref mut table) = config_value {
1332 table.remove("include");
1333 }
1334
1335 let has_profile_structure = if let toml::Value::Table(ref table) = config_value {
1339 table.contains_key("default") || profile.is_some_and(|p| table.contains_key(p))
1340 } else {
1341 false
1342 };
1343
1344 if has_profile_structure {
1345 apply_profile(&mut config_value, profile)?;
1346 } else {
1347 apply_profile_lenient(&mut config_value, profile);
1349 }
1350
1351 let merged_toml = toml::to_string(&config_value)
1352 .map_err(|e| ConfigError::Message(format!("Failed to serialize merged config: {}", e)))?;
1353
1354 let mut builder = Config::builder();
1355 for source_toml in pre_sources {
1356 builder = builder.add_source(config::File::from_str(
1357 &source_toml,
1358 config::FileFormat::Toml,
1359 ));
1360 }
1361 builder = builder.add_source(config::File::from_str(
1362 &merged_toml,
1363 config::FileFormat::Toml,
1364 ));
1365 if merge_env {
1366 for (key, _) in std::env::vars().filter(|(k, _)| {
1369 k.starts_with("CAMEL_") && !ALLOWED_ENV_OVERRIDES.contains(&k.as_str())
1370 }) {
1371 tracing::warn!(var = %key, "CAMEL_* env var not in config override allowlist; ignored");
1372 }
1373 let mut env_map = serde_json::Map::new();
1375 for var in ALLOWED_ENV_OVERRIDES {
1376 if let Ok(val) = env::var(var) {
1377 let Some(key) = var.strip_prefix("CAMEL_") else {
1378 continue;
1379 };
1380 let key = key.to_lowercase();
1381 let parsed = parse_env_value(&val);
1383 if let Some(nested_key) = key.strip_prefix("supervision_") {
1384 let sup = env_map
1385 .entry("supervision".to_string())
1386 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1387 if let serde_json::Value::Object(sup_map) = sup {
1388 sup_map.insert(nested_key.to_string(), parsed);
1389 }
1390 } else if let Some(nested_key) = key.strip_prefix("runtime_journal_") {
1391 let journal = env_map
1392 .entry("runtime_journal".to_string())
1393 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1394 if let serde_json::Value::Object(journal_map) = journal {
1395 journal_map.insert(nested_key.to_string(), parsed);
1396 }
1397 } else {
1398 env_map.insert(key, parsed);
1399 }
1400 }
1401 }
1402 if !env_map.is_empty() {
1403 let json = serde_json::to_string(&serde_json::Value::Object(env_map)).map_err(|e| {
1404 ConfigError::Message(format!("Failed to serialize env overrides: {}", e))
1405 })?;
1406 builder = builder.add_source(config::File::from_str(&json, config::FileFormat::Json));
1407 }
1408 }
1409 let config = builder.build()?;
1410
1411 let mut config: CamelConfig = config.try_deserialize()?;
1412 config.resolve_placeholders();
1413 config
1414 .validate()
1415 .map_err(|e| ConfigError::Message(e.to_string()))?;
1416 Ok(config)
1417}
1418
1419fn resolve_string_in_place(resolver: &PropertiesResolver, value: &mut String, field: &str) {
1420 match resolver.resolve(value) {
1421 Ok(resolved) => *value = resolved,
1422 Err(_err) => {
1423 tracing::warn!(
1424 field = field,
1425 "Failed to resolve placeholder; keeping original (error detail omitted)"
1426 );
1427 }
1428 }
1429}
1430
1431#[cfg(test)]
1432mod resolve_string_in_place_tests {
1433 use super::*;
1434
1435 #[test]
1436 fn resolve_string_in_place_keeps_value_unchanged_on_failure() {
1437 let resolver = PropertiesResolver::new();
1438 let original = "{{env:NONEXISTENT_SECRET_TOKEN}}".to_string();
1439 let mut value = original.clone();
1440 resolve_string_in_place(&resolver, &mut value, "test_field");
1441 assert_eq!(
1442 value, original,
1443 "value must be unchanged on resolution failure"
1444 );
1445 }
1446}
1447
1448fn resolve_toml_value_placeholders(
1449 resolver: &PropertiesResolver,
1450 value: &mut toml::Value,
1451 path: &str,
1452) {
1453 match value {
1454 toml::Value::String(s) => resolve_string_in_place(resolver, s, path),
1455 toml::Value::Array(arr) => {
1456 for (idx, item) in arr.iter_mut().enumerate() {
1457 resolve_toml_value_placeholders(resolver, item, &format!("{path}[{idx}]"));
1458 }
1459 }
1460 toml::Value::Table(table) => {
1461 for (k, v) in table.iter_mut() {
1462 resolve_toml_value_placeholders(resolver, v, &format!("{path}.{k}"));
1463 }
1464 }
1465 _ => {}
1466 }
1467}
1468
1469pub(crate) fn apply_profile(
1471 config_value: &mut toml::Value,
1472 profile: Option<&str>,
1473) -> Result<(), ConfigError> {
1474 if let Some(p) = profile {
1475 let default_value = config_value.get("default").cloned();
1476 let profile_value = config_value.get(p).cloned();
1477
1478 if let (Some(mut base), Some(overlay)) = (default_value, profile_value) {
1479 merge_toml_values(&mut base, &overlay);
1480 *config_value = base;
1481 } else if let Some(profile_val) = config_value.get(p).cloned() {
1482 *config_value = profile_val;
1483 } else {
1484 return Err(ConfigError::Message(format!("Unknown profile: {}", p)));
1485 }
1486 } else if let Some(default_val) = config_value.get("default").cloned() {
1487 *config_value = default_val;
1488 }
1489 Ok(())
1491}
1492
1493pub(crate) fn apply_profile_lenient(value: &mut toml::Value, profile: Option<&str>) {
1497 if let Some(p) = profile {
1498 let default_value = value.get("default").cloned();
1499 let profile_value = value.get(p).cloned();
1500 match (default_value, profile_value) {
1501 (Some(mut base), Some(overlay)) => {
1502 merge_toml_values(&mut base, &overlay);
1503 *value = base;
1504 }
1505 (None, Some(profile_val)) => {
1506 *value = profile_val;
1507 }
1508 (Some(default_val), None) => {
1509 *value = default_val;
1511 }
1512 (None, None) => {
1513 }
1515 }
1516 } else if let Some(default_val) = value.get("default").cloned() {
1517 *value = default_val;
1518 }
1519 }
1521
1522#[cfg(test)]
1530static ENV_OVERRIDE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1531
1532#[cfg(test)]
1533mod camel_config_defaults_tests {
1534 use super::*;
1535
1536 #[test]
1537 fn watch_debounce_ms_default_is_300() {
1538 let config: CamelConfig = toml::from_str("").unwrap();
1539 assert_eq!(config.watch_debounce_ms, 300);
1540 }
1541
1542 #[test]
1543 fn watch_debounce_ms_custom_value() {
1544 let config: CamelConfig = toml::from_str("watch_debounce_ms = 50").unwrap();
1545 assert_eq!(config.watch_debounce_ms, 50);
1546 }
1547
1548 #[test]
1549 fn stream_caching_default_threshold_is_set() {
1550 let config: CamelConfig = toml::from_str("").unwrap();
1551 assert_eq!(
1552 config.stream_caching.threshold,
1553 camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLD
1554 );
1555 }
1556
1557 #[test]
1558 fn stream_caching_custom_threshold_value() {
1559 let config: CamelConfig = toml::from_str("[stream_caching]\nthreshold = 1234").unwrap();
1560 assert_eq!(config.stream_caching.threshold, 1234);
1561 }
1562}
1563
1564#[cfg(test)]
1565mod components_config_tests {
1566 use super::*;
1567
1568 #[test]
1569 fn components_config_deserializes_raw_toml_block() {
1570 let toml_str = r#"
1571 [kafka]
1572 brokers = ["localhost:9092"]
1573
1574 [redis]
1575 host = "redis.local"
1576 "#;
1577 let cfg: ComponentsConfig = toml::from_str(toml_str).unwrap();
1578 assert!(cfg.raw.contains_key("kafka"));
1579 assert!(cfg.raw.contains_key("redis"));
1580 }
1581}
1582
1583#[cfg(test)]
1584mod prometheus_config_tests {
1585 use super::*;
1586
1587 fn parse(toml: &str) -> CamelConfig {
1588 let cfg = config::Config::builder()
1589 .add_source(config::File::from_str(toml, config::FileFormat::Toml))
1590 .build()
1591 .unwrap();
1592 cfg.try_deserialize().unwrap()
1593 }
1594
1595 #[test]
1596 fn test_prometheus_absent_is_none() {
1597 let cfg = parse("");
1598 assert!(cfg.observability.prometheus.is_none());
1599 }
1600
1601 #[test]
1602 fn test_prometheus_defaults() {
1603 let cfg = parse(
1604 r#"
1605[observability.prometheus]
1606enabled = true
1607"#,
1608 );
1609 let p = cfg.observability.prometheus.unwrap();
1610 assert!(p.enabled);
1611 assert_eq!(p.host, "0.0.0.0");
1612 assert_eq!(p.port, 9090);
1613 }
1614
1615 #[test]
1616 fn test_prometheus_full() {
1617 let cfg = parse(
1618 r#"
1619[observability.prometheus]
1620enabled = true
1621host = "127.0.0.1"
1622port = 9091
1623"#,
1624 );
1625 let p = cfg.observability.prometheus.unwrap();
1626 assert_eq!(p.host, "127.0.0.1");
1627 assert_eq!(p.port, 9091);
1628 }
1629
1630 #[test]
1631 fn test_health_config_defaults() {
1632 let cfg = parse(
1633 r#"
1634[observability.health]
1635enabled = true
1636"#,
1637 );
1638 let h = cfg.observability.health.unwrap();
1639 assert!(h.enabled);
1640 assert_eq!(h.host, "0.0.0.0");
1641 assert_eq!(h.port, 8081);
1642 }
1643
1644 #[test]
1645 fn test_health_config_custom_port() {
1646 let cfg = parse(
1647 r#"
1648[observability.health]
1649enabled = true
1650port = 9091
1651"#,
1652 );
1653 let h = cfg.observability.health.unwrap();
1654 assert_eq!(h.port, 9091);
1655 assert_eq!(h.host, "0.0.0.0");
1656 }
1657}
1658
1659#[cfg(test)]
1660mod platform_config_tests {
1661 use super::*;
1662
1663 fn parse(toml: &str) -> CamelConfig {
1664 let cfg = config::Config::builder()
1665 .add_source(config::File::from_str(toml, config::FileFormat::Toml))
1666 .build()
1667 .unwrap();
1668 cfg.try_deserialize().unwrap()
1669 }
1670
1671 #[test]
1672 fn platform_default_is_noop() {
1673 let cfg = parse("");
1674 assert!(matches!(cfg.platform, PlatformCamelConfig::Noop));
1675 }
1676
1677 #[test]
1678 fn platform_parses_kubernetes_from_toml() {
1679 let cfg = parse(
1680 r#"
1681[platform]
1682type = "kubernetes"
1683namespace = "team-a"
1684lease_name_prefix = "camel-"
1685lease_duration_secs = 15
1686renew_deadline_secs = 10
1687retry_period_secs = 2
1688jitter_factor = 0.2
1689"#,
1690 );
1691 match cfg.platform {
1692 PlatformCamelConfig::Kubernetes(k8s) => {
1693 assert_eq!(k8s.namespace.as_deref(), Some("team-a"));
1694 assert_eq!(k8s.lease_name_prefix, "camel-");
1695 assert_eq!(k8s.lease_duration_secs, 15);
1696 assert_eq!(k8s.renew_deadline_secs, 10);
1697 assert_eq!(k8s.retry_period_secs, 2);
1698 assert!((k8s.jitter_factor - 0.2).abs() < f64::EPSILON);
1699 }
1700 other => panic!("expected Kubernetes, got {:?}", other),
1701 }
1702 }
1703
1704 #[test]
1705 fn platform_kubernetes_defaults() {
1706 let cfg = parse(
1707 r#"
1708[platform]
1709type = "kubernetes"
1710"#,
1711 );
1712 match cfg.platform {
1713 PlatformCamelConfig::Kubernetes(k8s) => {
1714 assert!(k8s.namespace.is_none());
1715 assert_eq!(k8s.lease_name_prefix, "camel-");
1716 assert_eq!(k8s.lease_duration_secs, 15);
1717 assert_eq!(k8s.renew_deadline_secs, 10);
1718 assert_eq!(k8s.retry_period_secs, 2);
1719 assert!((k8s.jitter_factor - 0.2).abs() < f64::EPSILON);
1720 }
1721 other => panic!("expected Kubernetes, got {:?}", other),
1722 }
1723 }
1724
1725 #[test]
1726 fn platform_parses_kubernetes_from_file_with_profile() {
1727 use std::io::Write;
1728 let mut f = tempfile::NamedTempFile::new().expect("temp file");
1729 f.write_all(
1730 br#"
1731[default]
1732[default.platform]
1733type = "kubernetes"
1734namespace = "production"
1735
1736[dev]
1737[dev.platform]
1738type = "noop"
1739"#,
1740 )
1741 .expect("write config");
1742
1743 let cfg_prod =
1744 CamelConfig::from_file_with_profile(f.path().to_str().unwrap(), Some("default"))
1745 .expect("prod config");
1746 assert!(matches!(
1747 cfg_prod.platform,
1748 PlatformCamelConfig::Kubernetes(_)
1749 ));
1750
1751 let cfg_dev = CamelConfig::from_file_with_profile(f.path().to_str().unwrap(), Some("dev"))
1752 .expect("dev config");
1753 assert!(matches!(cfg_dev.platform, PlatformCamelConfig::Noop));
1754 }
1755}
1756
1757#[cfg(test)]
1758mod profile_loading_tests {
1759 use super::*;
1760
1761 fn write_temp_config(contents: &str) -> tempfile::NamedTempFile {
1762 use std::io::Write;
1763 let mut f = tempfile::NamedTempFile::new().expect("temp file");
1764 f.write_all(contents.as_bytes()).expect("write config");
1765 f
1766 }
1767
1768 #[test]
1769 fn test_merge_toml_values_merges_nested_tables() {
1770 let mut base: toml::Value = toml::from_str(
1771 r#"
1772[components.http]
1773connect_timeout_ms = 1000
1774pool_max_idle_per_host = 50
1775"#,
1776 )
1777 .unwrap();
1778
1779 let overlay: toml::Value = toml::from_str(
1780 r#"
1781[components.http]
1782response_timeout_ms = 2000
1783pool_max_idle_per_host = 99
1784"#,
1785 )
1786 .unwrap();
1787
1788 merge_toml_values(&mut base, &overlay);
1789
1790 let http = base
1791 .get("components")
1792 .and_then(|v| v.get("http"))
1793 .expect("merged http table");
1794 assert_eq!(
1795 http.get("connect_timeout_ms").and_then(|v| v.as_integer()),
1796 Some(1000)
1797 );
1798 assert_eq!(
1799 http.get("response_timeout_ms").and_then(|v| v.as_integer()),
1800 Some(2000)
1801 );
1802 assert_eq!(
1803 http.get("pool_max_idle_per_host")
1804 .and_then(|v| v.as_integer()),
1805 Some(99)
1806 );
1807 }
1808
1809 #[test]
1810 fn test_from_file_with_profile_merges_default_and_profile() {
1811 let file = write_temp_config(
1812 r#"
1813[default]
1814watch = false
1815[default.components.http]
1816connect_timeout_ms = 1000
1817pool_max_idle_per_host = 50
1818
1819[prod]
1820watch = true
1821[prod.components.http]
1822pool_max_idle_per_host = 200
1823"#,
1824 );
1825
1826 let cfg = CamelConfig::from_file_with_profile(file.path().to_str().unwrap(), Some("prod"))
1827 .expect("config should load");
1828
1829 assert!(cfg.watch);
1830 let http = cfg.components.raw.get("http").expect("http config");
1831 assert_eq!(
1832 http.get("connect_timeout_ms").and_then(|v| v.as_integer()),
1833 Some(1000)
1834 );
1835 assert_eq!(
1836 http.get("pool_max_idle_per_host")
1837 .and_then(|v| v.as_integer()),
1838 Some(200)
1839 );
1840 }
1841
1842 #[test]
1843 fn test_from_file_with_profile_uses_profile_when_no_default() {
1844 let file = write_temp_config(
1845 r#"
1846[dev]
1847watch = true
1848timeout_ms = 777
1849"#,
1850 );
1851
1852 let cfg = CamelConfig::from_file_with_profile(file.path().to_str().unwrap(), Some("dev"))
1853 .expect("config should load");
1854 assert!(cfg.watch);
1855 assert_eq!(cfg.timeout_ms, 777);
1856 }
1857
1858 #[test]
1859 fn test_from_file_with_profile_unknown_profile_returns_error() {
1860 let file = write_temp_config(
1861 r#"
1862[default]
1863watch = false
1864"#,
1865 );
1866
1867 let err = CamelConfig::from_file_with_profile(file.path().to_str().unwrap(), Some("qa"))
1868 .expect_err("should fail");
1869 assert!(err.to_string().contains("Unknown profile: qa"));
1870 }
1871
1872 #[test]
1873 fn test_from_file_without_profile_uses_default_section() {
1874 let file = write_temp_config(
1875 r#"
1876[default]
1877watch = true
1878timeout_ms = 321
1879"#,
1880 );
1881
1882 let cfg =
1883 CamelConfig::from_file(file.path().to_str().unwrap()).expect("config should load");
1884 assert!(cfg.watch);
1885 assert_eq!(cfg.timeout_ms, 321);
1886 }
1887
1888 #[test]
1889 fn test_from_file_with_env_overrides_timeout() {
1890 let _guard = super::ENV_OVERRIDE_LOCK.lock().unwrap();
1892
1893 let file = write_temp_config(
1894 r#"
1895[default]
1896timeout_ms = 1000
1897"#,
1898 );
1899
1900 unsafe {
1902 std::env::set_var("CAMEL_TIMEOUT_MS", "9999");
1903 }
1904
1905 let cfg = CamelConfig::from_file_with_env(file.path().to_str().unwrap())
1906 .expect("config should load with env override");
1907 assert_eq!(cfg.timeout_ms, 9999);
1908
1909 unsafe {
1911 std::env::remove_var("CAMEL_TIMEOUT_MS");
1912 }
1913 }
1914
1915 #[test]
1916 fn env_override_allows_supervision_nested_field() {
1917 let _guard = super::ENV_OVERRIDE_LOCK.lock().unwrap();
1919
1920 let file = write_temp_config(
1921 r#"
1922[default]
1923timeout_ms = 1000
1924
1925[default.supervision]
1926initial_delay_ms = 500
1927max_attempts = 3
1928"#,
1929 );
1930
1931 unsafe {
1933 std::env::set_var("CAMEL_SUPERVISION_INITIAL_DELAY_MS", "2000");
1934 std::env::set_var("CAMEL_SUPERVISION_MAX_ATTEMPTS", "10");
1935 }
1936
1937 let cfg = CamelConfig::from_file_with_env(file.path().to_str().unwrap())
1938 .expect("config should load with env override");
1939 let sup = cfg.supervision.expect("supervision should be present");
1940 assert_eq!(sup.initial_delay_ms, 2000);
1941 assert_eq!(sup.max_attempts, Some(10));
1942
1943 unsafe {
1945 std::env::remove_var("CAMEL_SUPERVISION_INITIAL_DELAY_MS");
1946 std::env::remove_var("CAMEL_SUPERVISION_MAX_ATTEMPTS");
1947 }
1948 }
1949
1950 #[test]
1951 fn env_allowlist_accepts_allowlisted_ignores_non_allowlisted() {
1952 let _guard = super::ENV_OVERRIDE_LOCK.lock().unwrap();
1954
1955 let file = write_temp_config(
1956 r#"
1957[default]
1958drain_timeout_ms = 5000
1959"#,
1960 );
1961
1962 unsafe {
1964 std::env::set_var("CAMEL_DRAIN_TIMEOUT_MS", "999");
1966 std::env::set_var("CAMEL_BEANS_FOO", "bar");
1968 }
1969
1970 let cfg = CamelConfig::from_file_with_env(file.path().to_str().unwrap())
1971 .expect("config should load with allowlisted env var and ignore non-allowlisted");
1972 assert_eq!(cfg.drain_timeout_ms, 999);
1974 assert!(cfg.beans.is_empty());
1976
1977 unsafe {
1979 std::env::remove_var("CAMEL_DRAIN_TIMEOUT_MS");
1980 std::env::remove_var("CAMEL_BEANS_FOO");
1981 }
1982 }
1983
1984 #[test]
1985 fn test_from_file_resolves_placeholders_in_components_and_beans() {
1986 let file = write_temp_config(
1987 r#"
1988[default]
1989routes = ["{{env:RUST_CAMEL_TEST_ROUTE:routes/default.yaml}}"]
1990
1991[default.components.http]
1992base_url = "{{env:RUST_CAMEL_TEST_BASE_URL:http://localhost:8080}}"
1993
1994[default.beans.auth]
1995plugin = "{{env:RUST_CAMEL_TEST_PLUGIN:test-auth}}"
1996
1997[default.beans.auth.config]
1998token = "{{env:RUST_CAMEL_TEST_TOKEN:abc123}}"
1999"#,
2000 );
2001
2002 let cfg =
2003 CamelConfig::from_file(file.path().to_str().unwrap()).expect("config should load");
2004
2005 assert_eq!(cfg.routes, vec!["routes/default.yaml"]);
2006 let http = cfg.components.raw.get("http").expect("http config");
2007 assert_eq!(
2008 http.get("base_url").and_then(|v| v.as_str()),
2009 Some("http://localhost:8080")
2010 );
2011 let bean = cfg.beans.get("auth").expect("bean auth");
2012 assert_eq!(bean.plugin, "test-auth");
2013 assert_eq!(bean.config.get("token").map(String::as_str), Some("abc123"));
2014 }
2015
2016 #[test]
2017 fn test_from_file_unresolved_placeholder_keeps_original_string() {
2018 let file = write_temp_config(
2019 r#"
2020[default]
2021[default.components.redis]
2022url = "redis://{{MISSING_PLACEHOLDER}}"
2023"#,
2024 );
2025
2026 let cfg =
2027 CamelConfig::from_file(file.path().to_str().unwrap()).expect("config should load");
2028 let redis = cfg.components.raw.get("redis").expect("redis config");
2029 assert_eq!(
2030 redis.get("url").and_then(|v| v.as_str()),
2031 Some("redis://{{MISSING_PLACEHOLDER}}")
2032 );
2033 }
2034}
2035
2036#[cfg(test)]
2037mod additional_config_tests {
2038 use super::*;
2039
2040 #[test]
2041 fn journal_durability_converts_to_core_type() {
2042 let immediate: camel_core::JournalDurability = JournalDurability::Immediate.into();
2043 let eventual: camel_core::JournalDurability = JournalDurability::Eventual.into();
2044 assert_eq!(immediate, camel_core::JournalDurability::Immediate);
2045 assert_eq!(eventual, camel_core::JournalDurability::Eventual);
2046 }
2047
2048 #[test]
2049 fn supervision_into_supervision_config_converts_durations() {
2050 let input = SupervisionCamelConfig {
2051 max_attempts: Some(7),
2052 initial_delay_ms: 123,
2053 backoff_multiplier: 1.5,
2054 max_delay_ms: 999,
2055 };
2056
2057 let out = input.into_supervision_config();
2058 assert_eq!(out.max_attempts, Some(7));
2059 assert_eq!(out.initial_delay, Duration::from_millis(123));
2060 assert_eq!(out.backoff_multiplier, 1.5);
2061 assert_eq!(out.max_delay, Duration::from_millis(999));
2062 }
2063
2064 #[test]
2065 fn redb_journal_options_from_journal_config_copies_fields() {
2066 let cfg = JournalConfig {
2067 path: std::path::PathBuf::from("journal.db"),
2068 durability: JournalDurability::Eventual,
2069 compaction_threshold_events: 42,
2070 };
2071
2072 let options: camel_core::RedbJournalOptions = (&cfg).into();
2073 assert_eq!(options.durability, camel_core::JournalDurability::Eventual);
2074 assert_eq!(options.compaction_threshold_events, 42);
2075 }
2076
2077 #[test]
2078 fn from_env_or_default_uses_camel_config_file_env() {
2079 use std::io::Write;
2080
2081 let mut file = tempfile::NamedTempFile::new().unwrap();
2082 file.write_all(
2083 br#"
2084watch = true
2085timeout_ms = 111
2086"#,
2087 )
2088 .unwrap();
2089
2090 unsafe {
2091 std::env::set_var("CAMEL_CONFIG_FILE", file.path());
2092 }
2093 let cfg = CamelConfig::from_env_or_default().unwrap();
2094 unsafe {
2095 std::env::remove_var("CAMEL_CONFIG_FILE");
2096 }
2097
2098 assert!(cfg.watch);
2099 assert_eq!(cfg.timeout_ms, 111);
2100 }
2101
2102 #[test]
2103 fn from_file_with_profile_and_env_unknown_profile_errors() {
2104 use std::io::Write;
2105
2106 let mut file = tempfile::NamedTempFile::new().unwrap();
2107 file.write_all(
2108 br#"
2109[default]
2110watch = false
2111"#,
2112 )
2113 .unwrap();
2114
2115 let err = CamelConfig::from_file_with_profile_and_env(
2116 file.path().to_str().unwrap(),
2117 Some("missing"),
2118 )
2119 .unwrap_err();
2120 assert!(err.to_string().contains("Unknown profile: missing"));
2121 }
2122}
2123
2124#[cfg(test)]
2125mod beans_config_tests {
2126 use super::*;
2127
2128 #[test]
2129 fn beans_default_empty() {
2130 let config: CamelConfig = toml::from_str("").unwrap();
2131 assert!(config.beans.is_empty());
2132 }
2133
2134 #[test]
2135 fn beans_parsed_from_config() {
2136 let toml_str = r#"
2137[beans.auth]
2138plugin = "my-auth"
2139
2140[beans.cache]
2141plugin = "my-cache"
2142"#;
2143 let config: CamelConfig = toml::from_str(toml_str).unwrap();
2144 assert_eq!(config.beans.len(), 2);
2145 assert_eq!(config.beans.get("auth").unwrap().plugin, "my-auth");
2146 assert_eq!(config.beans.get("cache").unwrap().plugin, "my-cache");
2147 }
2148
2149 #[test]
2150 fn beans_config_parsed_from_toml() {
2151 let toml_str = r#"
2152[beans.auth]
2153plugin = "my-auth"
2154[beans.auth.config]
2155api_key = "${API_KEY}"
2156base_url = "https://api.example.com"
2157"#;
2158 let config: CamelConfig = toml::from_str(toml_str).unwrap();
2159 assert_eq!(config.beans.len(), 1);
2160 let auth = config.beans.get("auth").unwrap();
2161 assert_eq!(auth.plugin, "my-auth");
2162 assert_eq!(auth.config.get("api_key").unwrap(), "${API_KEY}");
2163 assert_eq!(
2164 auth.config.get("base_url").unwrap(),
2165 "https://api.example.com"
2166 );
2167 }
2168
2169 #[test]
2170 fn beans_config_defaults_to_empty_map() {
2171 let toml_str = r#"
2172[beans.auth]
2173plugin = "my-auth"
2174"#;
2175 let config: CamelConfig = toml::from_str(toml_str).unwrap();
2176 assert!(config.beans.get("auth").unwrap().config.is_empty());
2177 }
2178
2179 #[test]
2180 fn beans_config_with_profiles_merges() {
2181 let toml_str = r#"
2182[default.beans.auth]
2183plugin = "my-auth"
2184[default.beans.auth.config]
2185base_url = "https://dev.example.com"
2186
2187[production.beans.auth.config]
2188base_url = "https://prod.example.com"
2189"#;
2190 let config_value: toml::Value = toml::from_str(toml_str).unwrap();
2191 let default_val = config_value.get("default").cloned().unwrap();
2192 let prod_overlay = config_value.get("production").cloned().unwrap();
2193 let mut merged = default_val;
2194 super::merge_toml_values(&mut merged, &prod_overlay);
2195 let config: CamelConfig = merged.try_into().unwrap();
2196 let auth = config.beans.get("auth").unwrap();
2197 assert_eq!(
2198 auth.config.get("base_url").unwrap(),
2199 "https://prod.example.com"
2200 );
2201 }
2202}
2203
2204#[cfg(test)]
2205mod config_validation_tests {
2206 use super::*;
2207
2208 #[test]
2209 fn test_config_zero_timeout_rejected() {
2210 let config = CamelConfig {
2211 timeout_ms: 0,
2212 ..CamelConfig::default()
2213 };
2214 assert!(config.validate().is_err());
2215 }
2216
2217 #[test]
2218 fn test_config_zero_drain_timeout_rejected() {
2219 let config = CamelConfig {
2220 drain_timeout_ms: 0,
2221 ..CamelConfig::default()
2222 };
2223 assert!(config.validate().is_err());
2224 }
2225
2226 #[test]
2227 fn test_config_empty_journal_path_rejected() {
2228 let config = CamelConfig {
2229 runtime_journal: Some(JournalConfig {
2230 path: std::path::PathBuf::from(""),
2231 durability: JournalDurability::default(),
2232 compaction_threshold_events: 10_000,
2233 }),
2234 ..CamelConfig::default()
2235 };
2236 assert!(config.validate().is_err());
2237 }
2238
2239 #[test]
2240 fn test_config_empty_bean_plugin_rejected() {
2241 let mut beans = HashMap::new();
2242 beans.insert(
2243 "my-bean".to_string(),
2244 BeanConfig {
2245 plugin: "".to_string(),
2246 config: HashMap::new(),
2247 limits: Default::default(),
2248 },
2249 );
2250 let config = CamelConfig {
2251 beans,
2252 ..CamelConfig::default()
2253 };
2254 assert!(config.validate().is_err());
2255 }
2256
2257 #[test]
2258 fn test_config_whitespace_bean_plugin_rejected() {
2259 let mut beans = HashMap::new();
2260 beans.insert(
2261 "my-bean".to_string(),
2262 BeanConfig {
2263 plugin: " ".to_string(),
2264 config: HashMap::new(),
2265 limits: Default::default(),
2266 },
2267 );
2268 let config = CamelConfig {
2269 beans,
2270 ..CamelConfig::default()
2271 };
2272 assert!(config.validate().is_err());
2273 }
2274
2275 #[test]
2276 fn test_config_valid_defaults_pass() {
2277 let config = CamelConfig::default();
2278 assert!(config.validate().is_ok());
2279 }
2280
2281 #[test]
2282 fn test_config_zero_watch_debounce_rejected() {
2283 let config = CamelConfig {
2284 watch_debounce_ms: 0,
2285 ..CamelConfig::default()
2286 };
2287 assert!(config.validate().is_err());
2288 }
2289
2290 #[test]
2291 fn test_config_zero_journal_compaction_threshold_rejected() {
2292 let config = CamelConfig {
2293 runtime_journal: Some(JournalConfig {
2294 path: std::path::PathBuf::from("/tmp/test.db"),
2295 durability: JournalDurability::default(),
2296 compaction_threshold_events: 0,
2297 }),
2298 ..CamelConfig::default()
2299 };
2300 assert!(config.validate().is_err());
2301 }
2302
2303 #[test]
2304 fn test_config_zero_supervision_initial_delay_rejected() {
2305 let config = CamelConfig {
2306 supervision: Some(SupervisionCamelConfig {
2307 max_attempts: Some(5),
2308 initial_delay_ms: 0,
2309 backoff_multiplier: 2.0,
2310 max_delay_ms: 60000,
2311 }),
2312 ..CamelConfig::default()
2313 };
2314 assert!(config.validate().is_err());
2315 }
2316
2317 #[test]
2318 fn test_config_zero_supervision_max_delay_rejected() {
2319 let config = CamelConfig {
2320 supervision: Some(SupervisionCamelConfig {
2321 max_attempts: Some(5),
2322 initial_delay_ms: 1000,
2323 backoff_multiplier: 2.0,
2324 max_delay_ms: 0,
2325 }),
2326 ..CamelConfig::default()
2327 };
2328 assert!(config.validate().is_err());
2329 }
2330
2331 #[test]
2332 fn test_config_supervision_backoff_below_one_rejected() {
2333 let config = CamelConfig {
2334 supervision: Some(SupervisionCamelConfig {
2335 max_attempts: Some(5),
2336 initial_delay_ms: 1000,
2337 backoff_multiplier: 0.5,
2338 max_delay_ms: 60000,
2339 }),
2340 ..CamelConfig::default()
2341 };
2342 assert!(config.validate().is_err());
2343 }
2344
2345 #[test]
2346 fn test_config_zero_otel_metrics_interval_rejected() {
2347 let otel = OtelCamelConfig {
2348 metrics_interval_ms: 0,
2349 ..Default::default()
2350 };
2351 let config = CamelConfig {
2352 observability: ObservabilityConfig {
2353 otel: Some(otel),
2354 ..Default::default()
2355 },
2356 ..CamelConfig::default()
2357 };
2358 assert!(config.validate().is_err());
2359 }
2360
2361 #[test]
2362 fn test_config_kubernetes_zero_lease_duration_rejected() {
2363 let config = CamelConfig {
2364 platform: PlatformCamelConfig::Kubernetes(KubernetesPlatformCamelConfig {
2365 namespace: None,
2366 lease_name_prefix: "camel-".to_string(),
2367 lease_duration_secs: 0,
2368 renew_deadline_secs: 10,
2369 retry_period_secs: 2,
2370 jitter_factor: 0.2,
2371 }),
2372 ..CamelConfig::default()
2373 };
2374 assert!(config.validate().is_err());
2375 }
2376
2377 #[test]
2378 fn test_config_kubernetes_zero_renew_deadline_rejected() {
2379 let config = CamelConfig {
2380 platform: PlatformCamelConfig::Kubernetes(KubernetesPlatformCamelConfig {
2381 namespace: None,
2382 lease_name_prefix: "camel-".to_string(),
2383 lease_duration_secs: 15,
2384 renew_deadline_secs: 0,
2385 retry_period_secs: 2,
2386 jitter_factor: 0.2,
2387 }),
2388 ..CamelConfig::default()
2389 };
2390 assert!(config.validate().is_err());
2391 }
2392
2393 #[test]
2394 fn test_config_kubernetes_zero_retry_period_rejected() {
2395 let config = CamelConfig {
2396 platform: PlatformCamelConfig::Kubernetes(KubernetesPlatformCamelConfig {
2397 namespace: None,
2398 lease_name_prefix: "camel-".to_string(),
2399 lease_duration_secs: 15,
2400 renew_deadline_secs: 10,
2401 retry_period_secs: 0,
2402 jitter_factor: 0.2,
2403 }),
2404 ..CamelConfig::default()
2405 };
2406 assert!(config.validate().is_err());
2407 }
2408
2409 #[test]
2410 fn test_config_kubernetes_jitter_out_of_range_rejected() {
2411 let config = CamelConfig {
2412 platform: PlatformCamelConfig::Kubernetes(KubernetesPlatformCamelConfig {
2413 namespace: None,
2414 lease_name_prefix: "camel-".to_string(),
2415 lease_duration_secs: 15,
2416 renew_deadline_secs: 10,
2417 retry_period_secs: 2,
2418 jitter_factor: 1.5,
2419 }),
2420 ..CamelConfig::default()
2421 };
2422 assert!(config.validate().is_err());
2423 }
2424
2425 #[test]
2426 fn test_config_kubernetes_negative_jitter_rejected() {
2427 let config = CamelConfig {
2428 platform: PlatformCamelConfig::Kubernetes(KubernetesPlatformCamelConfig {
2429 namespace: None,
2430 lease_name_prefix: "camel-".to_string(),
2431 lease_duration_secs: 15,
2432 renew_deadline_secs: 10,
2433 retry_period_secs: 2,
2434 jitter_factor: -0.1,
2435 }),
2436 ..CamelConfig::default()
2437 };
2438 assert!(config.validate().is_err());
2439 }
2440
2441 #[test]
2442 fn test_config_valid_kubernetes_passes() {
2443 let config = CamelConfig {
2444 platform: PlatformCamelConfig::Kubernetes(KubernetesPlatformCamelConfig {
2445 namespace: Some("default".to_string()),
2446 lease_name_prefix: "camel-".to_string(),
2447 lease_duration_secs: 15,
2448 renew_deadline_secs: 10,
2449 retry_period_secs: 2,
2450 jitter_factor: 0.2,
2451 }),
2452 ..CamelConfig::default()
2453 };
2454 assert!(config.validate().is_ok());
2455 }
2456
2457 #[test]
2458 fn test_config_valid_supervision_passes() {
2459 let config = CamelConfig {
2460 supervision: Some(SupervisionCamelConfig {
2461 max_attempts: Some(5),
2462 initial_delay_ms: 1000,
2463 backoff_multiplier: 2.0,
2464 max_delay_ms: 60000,
2465 }),
2466 ..CamelConfig::default()
2467 };
2468 assert!(config.validate().is_ok());
2469 }
2470
2471 #[test]
2472 fn test_config_valid_journal_passes() {
2473 let config = CamelConfig {
2474 runtime_journal: Some(JournalConfig {
2475 path: std::path::PathBuf::from("/tmp/test.db"),
2476 durability: JournalDurability::default(),
2477 compaction_threshold_events: 10_000,
2478 }),
2479 ..CamelConfig::default()
2480 };
2481 assert!(config.validate().is_ok());
2482 }
2483
2484 #[test]
2485 fn bean_config_deserialises_with_limits() {
2486 let toml_str = r#"
2487 plugin = "my-plugin"
2488 [limits]
2489 timeout-secs = 600
2490 max-memory = 4294967296
2491 "#;
2492 let cfg: BeanConfig = toml::from_str(toml_str).expect("deserialize");
2493 assert_eq!(cfg.plugin, "my-plugin");
2494 assert_eq!(cfg.limits.timeout_secs, Some(600));
2495 assert_eq!(cfg.limits.max_memory, Some(4_294_967_296));
2496 assert_eq!(cfg.limits.max_concurrent_calls, None);
2497 }
2498
2499 #[test]
2500 fn bean_config_defaults_limits_to_none() {
2501 let toml_str = r#"
2502 plugin = "my-plugin"
2503 "#;
2504 let cfg: BeanConfig = toml::from_str(toml_str).expect("deserialize");
2505 assert_eq!(cfg.limits, crate::wasm_limits::WasmLimitsConfig::default());
2506 }
2507
2508 #[test]
2509 fn test_config_invalid_datasource_rejected() {
2510 let mut datasources = HashMap::new();
2511 datasources.insert(
2512 "bad".to_string(),
2513 DatasourceConfig {
2514 db_url: " ".into(),
2515 provider: None,
2516 max_connections: None,
2517 min_connections: None,
2518 idle_timeout_secs: None,
2519 max_lifetime_secs: None,
2520 ssl_mode: None,
2521 ssl_root_cert: None,
2522 ssl_cert: None,
2523 ssl_key: None,
2524 extra: std::collections::HashMap::new(),
2525 },
2526 );
2527 let config = CamelConfig {
2528 datasources,
2529 ..CamelConfig::default()
2530 };
2531 assert!(config.validate().is_err());
2532 }
2533
2534 #[test]
2535 fn test_config_valid_datasources_pass() {
2536 let mut datasources = HashMap::new();
2537 datasources.insert(
2538 "orders".to_string(),
2539 DatasourceConfig {
2540 db_url: "postgres://localhost/orders".into(),
2541 provider: None,
2542 max_connections: Some(20),
2543 min_connections: None,
2544 idle_timeout_secs: None,
2545 max_lifetime_secs: None,
2546 ssl_mode: None,
2547 ssl_root_cert: None,
2548 ssl_cert: None,
2549 ssl_key: None,
2550 extra: std::collections::HashMap::new(),
2551 },
2552 );
2553 let config = CamelConfig {
2554 datasources,
2555 ..CamelConfig::default()
2556 };
2557 assert!(config.validate().is_ok());
2558 }
2559}
2560
2561#[cfg(test)]
2562mod security_config_tests {
2563 use super::*;
2564
2565 #[test]
2566 fn parse_security_config() {
2567 let toml_str = r#"
2568[security.keycloak]
2569server_url = "http://localhost:8080"
2570realm = "test-realm"
2571client_id = "my-client"
2572client_secret = "my-secret"
2573
2574[security.keycloak.validation]
2575method = "local"
2576audience = ["my-api"]
2577clock_skew_secs = 30
2578
2579[security.keycloak.jwks]
2580cache_ttl_secs = 3600
2581refresh_skew_secs = 60
2582"#;
2583 let config: CamelConfig = toml::from_str(toml_str).unwrap();
2584 let kc = config.security.keycloak.unwrap();
2585 assert_eq!(kc.server_url, "http://localhost:8080");
2586 assert_eq!(kc.realm, "test-realm");
2587 assert_eq!(kc.client_id, "my-client");
2588 assert_eq!(kc.validation.method, "local");
2589 assert_eq!(kc.validation.audience, vec!["my-api"]);
2590 }
2591
2592 #[test]
2593 fn security_config_defaults_when_absent() {
2594 let config: CamelConfig = toml::from_str("").unwrap();
2595 assert!(config.security.oidc.is_none());
2596 assert!(config.security.native.is_none());
2597 assert!(config.security.keycloak.is_none());
2598 assert!(config.security.permissions.is_none());
2599 assert!(config.security.policies.is_none());
2600 }
2601
2602 #[test]
2603 fn parse_security_oidc_and_native_config() {
2604 let toml_str = r#"
2605[security.oidc]
2606issuer = "https://issuer.example.com/realms/test"
2607jwks_uri = "https://issuer.example.com/realms/test/protocol/openid-connect/certs"
2608audience = ["api", "backend"]
2609client_id = "svc-client"
2610client_secret = "svc-secret"
2611token_endpoint = "https://issuer.example.com/realms/test/protocol/openid-connect/token"
2612
2613[security.native]
2614subject = "native-user"
2615issuer = "native"
2616bearer_token = "token-123"
2617api_key = "key-123"
2618roles = ["admin"]
2619scopes = ["read", "write"]
2620"#;
2621 let config: CamelConfig = toml::from_str(toml_str).unwrap();
2622
2623 let oidc = config.security.oidc.unwrap();
2624 assert_eq!(oidc.issuer, "https://issuer.example.com/realms/test");
2625 assert_eq!(oidc.audience, vec!["api", "backend"]);
2626 assert_eq!(oidc.client_id.as_deref(), Some("svc-client"));
2627 assert_eq!(oidc.client_secret.as_deref(), Some("svc-secret"));
2628
2629 let native = config.security.native.unwrap();
2630 assert_eq!(native.subject, "native-user");
2631 assert_eq!(native.issuer.as_deref(), Some("native"));
2632 assert_eq!(native.roles, vec!["admin"]);
2633 assert_eq!(native.scopes, vec!["read", "write"]);
2634 }
2635
2636 #[test]
2637 fn native_auth_debug_redacts_secrets() {
2638 let native = NativeAuthConfig {
2639 subject: "native-user".into(),
2640 issuer: Some("native".into()),
2641 bearer_token: Some("super-secret-token".into()),
2642 api_key: Some("super-secret-key".into()),
2643 roles: vec!["admin".into()],
2644 scopes: vec!["read".into()],
2645 token_issuer: None,
2646 clients: Vec::new(),
2647 };
2648
2649 let debug = format!("{native:?}");
2650 assert!(debug.contains("[REDACTED]"));
2651 assert!(!debug.contains("super-secret-token"));
2652 assert!(!debug.contains("super-secret-key"));
2653 }
2654
2655 #[test]
2656 fn parse_native_issuer_and_clients() {
2657 let toml = r#"
2658[security]
2659[security.native]
2660subject = "m2m-system"
2661[security.native.token_issuer]
2662issuer = "https://orders.local"
2663audience = ["orders-api"]
2664token_ttl_secs = 900
2665signing_key_env = "CAMEL_NATIVE_ISSUER_KEY_PEM"
2666
2667[[security.native.clients]]
2668client_id = "billing-worker"
2669client_secret_env = "BILLING_CLIENT_SECRET"
2670roles = ["billing"]
2671scopes = ["orders:read", "orders:write"]
2672
2673[[security.native.clients]]
2674client_id = "reporting-worker"
2675client_secret_env = "REPORTING_CLIENT_SECRET"
2676roles = ["reporting"]
2677scopes = ["orders:read"]
2678"#;
2679 let config: CamelConfig = toml::from_str(toml).unwrap();
2680 let native = config.security.native.as_ref().unwrap();
2681 let issuer = native.token_issuer.as_ref().unwrap();
2682 assert_eq!(issuer.issuer, "https://orders.local");
2683 assert_eq!(issuer.audience, vec!["orders-api"]);
2684 assert_eq!(issuer.token_ttl_secs, 900);
2685 assert_eq!(issuer.signing_key_env, "CAMEL_NATIVE_ISSUER_KEY_PEM");
2686 assert_eq!(native.clients.len(), 2);
2687 assert_eq!(native.clients[0].client_id, "billing-worker");
2688 assert_eq!(
2689 native.clients[0].scopes,
2690 vec!["orders:read", "orders:write"]
2691 );
2692 }
2693
2694 #[test]
2695 fn parse_native_issuer_defaults() {
2696 let toml = r#"
2697[security]
2698[security.native]
2699subject = "m2m-system"
2700[security.native.token_issuer]
2701issuer = "https://test.local"
2702signing_key_env = "KEY"
2703"#;
2704 let config: CamelConfig = toml::from_str(toml).unwrap();
2705 let issuer = config.security.native.unwrap().token_issuer.unwrap();
2706 assert_eq!(issuer.token_ttl_secs, 900);
2707 assert!(issuer.audience.is_empty());
2708 }
2709
2710 #[test]
2711 fn parse_native_debug_redacts_client_secret_env() {
2712 let toml = r#"
2713[security]
2714[security.native]
2715subject = "m2m-system"
2716[[security.native.clients]]
2717client_id = "test-worker"
2718client_secret_env = "MY_SECRET"
2719"#;
2720 let config: CamelConfig = toml::from_str(toml).unwrap();
2721 let debug = format!("{:?}", config.security.native.unwrap());
2722 assert!(!debug.contains("MY_SECRET"));
2723 assert!(debug.contains("[REDACTED]"));
2724 }
2725
2726 #[test]
2727 fn keycloak_validation_defaults() {
2728 let defaults = KeycloakValidationConfig::default();
2729 assert_eq!(defaults.method, "local");
2730 assert!(defaults.audience.is_empty());
2731 assert_eq!(defaults.clock_skew_secs, 30);
2732 }
2733
2734 #[test]
2735 fn keycloak_jwks_defaults() {
2736 let defaults = KeycloakJwksConfig::default();
2737 assert_eq!(defaults.cache_ttl_secs, 3600);
2738 assert_eq!(defaults.refresh_skew_secs, 60);
2739 }
2740
2741 #[test]
2742 fn keycloak_introspection_defaults() {
2743 let defaults = KeycloakIntrospectionConfig::default();
2744 assert_eq!(defaults.max_entries, 10_000);
2745 assert_eq!(defaults.default_ttl_secs, 60);
2746 assert_eq!(defaults.negative_ttl_secs, 5);
2747 }
2748
2749 #[test]
2750 fn keycloak_introspection_config_parses_from_toml() {
2751 let toml = r#"
2752 server_url = "https://kc.example.com"
2753 realm = "test"
2754 client_id = "my-client"
2755 client_secret = "secret"
2756
2757 [introspection]
2758 max_entries = 5000
2759 default_ttl_secs = 120
2760 negative_ttl_secs = 10
2761 "#;
2762 let config: KeycloakSecurityConfig = toml::from_str(toml).unwrap();
2763 assert_eq!(config.introspection.max_entries, 5000);
2764 assert_eq!(config.introspection.default_ttl_secs, 120);
2765 assert_eq!(config.introspection.negative_ttl_secs, 10);
2766 }
2767
2768 #[test]
2769 fn keycloak_introspection_config_uses_defaults_when_omitted() {
2770 let toml = r#"
2771 server_url = "https://kc.example.com"
2772 realm = "test"
2773 client_id = "my-client"
2774 client_secret = "secret"
2775 "#;
2776 let config: KeycloakSecurityConfig = toml::from_str(toml).unwrap();
2777 assert_eq!(config.introspection.max_entries, 10_000);
2778 assert_eq!(config.introspection.default_ttl_secs, 60);
2779 assert_eq!(config.introspection.negative_ttl_secs, 5);
2780 }
2781
2782 #[test]
2783 fn parse_security_permission_wasm_full_config() {
2784 let toml = r#"
2785[security.permissions.invoice-policy]
2786provider = "wasm"
2787path = "./policies/invoice-policy.wasm"
2788
2789[security.permissions.invoice-policy.config]
2790tenant_header = "CamelTenantId"
2791mode = "enforce"
2792
2793[security.permissions.invoice-policy.cache]
2794positive_ttl_secs = 60
2795negative_ttl_secs = 10
2796max_entries = 5000
2797"#;
2798
2799 let config: CamelConfig = toml::from_str(toml).unwrap();
2800 let permissions = config.security.permissions.unwrap();
2801 let policy = permissions.get("invoice-policy").unwrap();
2802
2803 assert_eq!(policy.provider, "wasm");
2804 assert_eq!(
2805 policy.path.as_deref(),
2806 Some("./policies/invoice-policy.wasm")
2807 );
2808 let cfg = policy.config.as_ref().unwrap();
2809 assert_eq!(cfg.get("tenant_header").unwrap(), "CamelTenantId");
2810 assert_eq!(cfg.get("mode").unwrap(), "enforce");
2811 assert_eq!(policy.cache.positive_ttl_secs, 60);
2812 assert_eq!(policy.cache.negative_ttl_secs, 10);
2813 assert_eq!(policy.cache.max_entries, 5000);
2814 }
2815
2816 #[test]
2817 fn parse_security_permission_minimal_provider_uses_cache_defaults() {
2818 let toml = r#"
2819[security.permissions.invoice-policy]
2820provider = "wasm"
2821"#;
2822
2823 let config: CamelConfig = toml::from_str(toml).unwrap();
2824 let permissions = config.security.permissions.unwrap();
2825 let policy = permissions.get("invoice-policy").unwrap();
2826
2827 assert_eq!(policy.provider, "wasm");
2828 assert_eq!(policy.path, None);
2829 assert_eq!(policy.config, None);
2830 assert_eq!(policy.cache.positive_ttl_secs, 30);
2831 assert_eq!(policy.cache.negative_ttl_secs, 5);
2832 assert_eq!(policy.cache.max_entries, 10_000);
2833 }
2834
2835 #[test]
2836 fn security_permissions_absent_by_default() {
2837 let config = SecurityConfig::default();
2838 assert!(config.permissions.is_none());
2839 }
2840
2841 #[test]
2842 fn parse_security_policies_wasm_full_config() {
2843 let toml = r#"
2844[security.policies.wasm.corp-auth]
2845path = "plugins/authz.wasm"
2846
2847[security.policies.wasm.corp-auth.limits]
2848timeout-secs = 30
2849max-memory = 52428800
2850
2851[security.policies.wasm.corp-auth.config]
2852ldap_url = "ldap://corp"
2853retry_count = "3"
2854"#;
2855 let config: CamelConfig = toml::from_str(toml).unwrap();
2856 let policies = config.security.policies.unwrap();
2857 let policy = policies.wasm.get("corp-auth").unwrap();
2858 assert_eq!(policy.path, "plugins/authz.wasm");
2859 assert_eq!(policy.limits.timeout_secs, Some(30));
2860 assert_eq!(policy.limits.max_memory, Some(52_428_800));
2861 assert_eq!(policy.config.get("ldap_url").unwrap(), "ldap://corp");
2862 assert_eq!(policy.config.get("retry_count").unwrap(), "3");
2863 }
2864
2865 #[test]
2866 fn parse_security_policies_wasm_minimal_config() {
2867 let toml = r#"
2868[security.policies.wasm.corp-auth]
2869path = "plugins/authz.wasm"
2870"#;
2871 let config: CamelConfig = toml::from_str(toml).unwrap();
2872 let policies = config.security.policies.unwrap();
2873 let policy = policies.wasm.get("corp-auth").unwrap();
2874 assert_eq!(policy.path, "plugins/authz.wasm");
2875 assert_eq!(
2876 policy.limits,
2877 crate::wasm_limits::WasmLimitsConfig::default()
2878 );
2879 assert!(policy.config.is_empty());
2880 }
2881
2882 #[test]
2883 fn parse_security_policies_wasm_deny_unknown_fields() {
2884 let toml = r#"
2885[security.policies.wasm.corp-auth]
2886path = "plugins/authz.wasm"
2887unknown_key = "rejected"
2888"#;
2889 let result: Result<CamelConfig, _> = toml::from_str(toml);
2890 assert!(
2891 result.is_err(),
2892 "deny_unknown_fields must reject unknown keys"
2893 );
2894 }
2895}
2896
2897#[cfg(test)]
2898mod config_builder_tests {
2899 use super::*;
2900
2901 #[test]
2902 fn test_config_builder_sets_application_name() {
2903 let cfg = CamelConfigBuilder::default().log_level("debug").build();
2904 assert_eq!(cfg.log_level, "debug");
2905 }
2906
2907 #[test]
2908 fn test_config_builder_default() {
2909 let built = CamelConfigBuilder::default().build();
2910 let default_cfg = CamelConfig::default();
2911 assert_eq!(built.routes, default_cfg.routes);
2912 assert_eq!(built.watch, default_cfg.watch);
2913 assert_eq!(built.log_level, default_cfg.log_level);
2914 assert_eq!(built.timeout_ms, default_cfg.timeout_ms);
2915 assert_eq!(built.drain_timeout_ms, default_cfg.drain_timeout_ms);
2916 assert_eq!(built.watch_debounce_ms, default_cfg.watch_debounce_ms);
2917 }
2918}
2919
2920#[cfg(test)]
2921mod async_io_tests {
2922 use super::*;
2923 use std::io::Write;
2924 use std::time::Duration;
2925
2926 #[tokio::test]
2927 async fn test_from_file_async_completes_without_blocking_executor() {
2928 let mut f = tempfile::NamedTempFile::new().expect("temp file");
2929 write!(
2930 f,
2931 r#"
2932[default]
2933watch = true
2934timeout_ms = 42
2935"#
2936 )
2937 .expect("write config");
2938
2939 let path = f.path().to_str().unwrap().to_string();
2940 let result = tokio::time::timeout(
2941 Duration::from_millis(500),
2942 CamelConfig::from_file_async(&path),
2943 )
2944 .await;
2945
2946 assert!(
2947 result.is_ok(),
2948 "from_file_async should not block the executor"
2949 );
2950 let config = result.unwrap().expect("config should parse");
2951 assert!(config.watch);
2952 assert_eq!(config.timeout_ms, 42);
2953 }
2954
2955 #[tokio::test]
2956 async fn test_from_file_async_with_profile_completes() {
2957 let mut f = tempfile::NamedTempFile::new().expect("temp file");
2958 write!(
2959 f,
2960 r#"
2961[default]
2962watch = false
2963timeout_ms = 1000
2964
2965[prod]
2966watch = true
2967timeout_ms = 99
2968"#
2969 )
2970 .expect("write config");
2971
2972 let path = f.path().to_str().unwrap().to_string();
2973 let result = tokio::time::timeout(
2974 Duration::from_millis(500),
2975 CamelConfig::from_file_async_with_profile(&path, Some("prod")),
2976 )
2977 .await;
2978
2979 assert!(
2980 result.is_ok(),
2981 "from_file_async_with_profile should not block"
2982 );
2983 let config = result.unwrap().expect("config should parse");
2984 assert!(config.watch);
2985 assert_eq!(config.timeout_ms, 99);
2986 }
2987
2988 #[tokio::test]
2989 #[allow(clippy::await_holding_lock)]
2990 async fn test_from_file_async_with_env_completes() {
2991 let _guard = super::ENV_OVERRIDE_LOCK.lock().unwrap();
2995
2996 unsafe {
2999 std::env::remove_var("CAMEL_TIMEOUT_MS");
3000 std::env::remove_var("CAMEL_PROFILE");
3001 }
3002
3003 let mut f = tempfile::NamedTempFile::new().expect("temp file");
3004 write!(
3005 f,
3006 r#"
3007[default]
3008timeout_ms = 1000
3009"#
3010 )
3011 .expect("write config");
3012
3013 let path = f.path().to_str().unwrap().to_string();
3014 let result = tokio::time::timeout(
3015 Duration::from_millis(500),
3016 CamelConfig::from_file_async_with_env(&path),
3017 )
3018 .await;
3019
3020 assert!(result.is_ok(), "from_file_async_with_env should not block");
3021 let config = result.unwrap().expect("config should parse");
3022 assert_eq!(config.timeout_ms, 1000);
3023 }
3024}
3025
3026#[cfg(test)]
3027mod permission_provider_config_tests {
3028 use super::*;
3029
3030 #[test]
3031 fn permission_provider_config_deserialises_with_limits() {
3032 let toml_str = r#"
3033 provider = "wasm"
3034 path = "plugins/authz.wasm"
3035 [limits]
3036 timeout-secs = 5
3037 max-memory = 10485760
3038 "#;
3039 let cfg: PermissionProviderConfig = toml::from_str(toml_str).expect("deserialize");
3040 assert_eq!(cfg.provider, "wasm");
3041 assert_eq!(cfg.path.as_deref(), Some("plugins/authz.wasm"));
3042 assert_eq!(cfg.limits.timeout_secs, Some(5));
3043 assert_eq!(cfg.limits.max_memory, Some(10_485_760));
3044 }
3045
3046 #[test]
3047 fn permission_provider_config_defaults_limits_to_none() {
3048 let toml_str = r#"
3049 provider = "keycloak"
3050 "#;
3051 let cfg: PermissionProviderConfig = toml::from_str(toml_str).expect("deserialize");
3052 assert_eq!(cfg.limits, crate::wasm_limits::WasmLimitsConfig::default());
3053 }
3054}
3055
3056#[cfg(test)]
3057mod languages_config_integration_tests {
3058 use super::*;
3059
3060 fn parse(toml: &str) -> CamelConfig {
3061 let cfg = config::Config::builder()
3062 .add_source(config::File::from_str(toml, config::FileFormat::Toml))
3063 .build()
3064 .unwrap();
3065 cfg.try_deserialize().unwrap()
3066 }
3067
3068 #[test]
3069 fn languages_defaults_when_absent() {
3070 let cfg = parse("");
3071 assert_eq!(cfg.languages, crate::LanguagesConfig::default());
3072 }
3073
3074 #[test]
3075 fn languages_parses_rhai_limits() {
3076 let cfg = parse(
3077 r#"
3078 [languages.rhai.limits]
3079 max-operations = 500000
3080 execution-timeout-ms = 5000
3081 "#,
3082 );
3083 assert_eq!(cfg.languages.rhai.limits.max_operations, Some(500_000));
3084 assert_eq!(cfg.languages.rhai.limits.execution_timeout_ms, Some(5000));
3085 assert_eq!(cfg.languages.rhai.limits.max_string_size, None);
3086 }
3087
3088 #[test]
3089 fn languages_parses_js_limits() {
3090 let cfg = parse(
3091 r#"
3092 [languages.js.limits]
3093 execution-timeout-ms = 3000
3094 max-loop-iterations = 1000000
3095 "#,
3096 );
3097 assert_eq!(cfg.languages.js.limits.execution_timeout_ms, Some(3000));
3098 assert_eq!(cfg.languages.js.limits.max_loop_iterations, Some(1_000_000));
3099 assert_eq!(cfg.languages.js.limits.max_recursion_depth, None);
3100 }
3101
3102 #[test]
3103 fn languages_parses_both_engines() {
3104 let cfg = parse(
3105 r#"
3106 [languages.rhai.limits]
3107 max-operations = 100000
3108 [languages.js.limits]
3109 execution-timeout-ms = 5000
3110 "#,
3111 );
3112 assert_eq!(cfg.languages.rhai.limits.max_operations, Some(100_000));
3113 assert_eq!(cfg.languages.js.limits.execution_timeout_ms, Some(5000));
3114 }
3115
3116 #[test]
3117 fn languages_through_camel_config_default() {
3118 let cfg: CamelConfig = toml::from_str("").unwrap();
3119 assert_eq!(cfg.languages, crate::LanguagesConfig::default());
3120 }
3121
3122 #[test]
3123 fn languages_through_camel_config_from_str() {
3124 let toml_str = r#"
3125 timeout_ms = 5000
3126
3127 [languages.rhai.limits]
3128 max-operations = 500000
3129
3130 [languages.js.limits]
3131 execution-timeout-ms = 3000
3132 "#;
3133 let cfg: CamelConfig = toml::from_str(toml_str).expect("deserialize");
3134 assert_eq!(cfg.timeout_ms, 5000);
3135 assert_eq!(cfg.languages.rhai.limits.max_operations, Some(500_000));
3136 assert_eq!(cfg.languages.js.limits.execution_timeout_ms, Some(3000));
3137 }
3138}
3139
3140#[cfg(test)]
3141mod oversized_file_tests {
3142 use super::*;
3143 use std::io::Write;
3144
3145 #[test]
3146 fn from_file_rejects_oversized_config() {
3147 let val = "a".repeat(17 * 1024 * 1024);
3149 let big_content = format!("x = \"{val}\"\n");
3150 assert!(
3151 big_content.len() > 16 * 1024 * 1024,
3152 "test content must exceed 16 MiB (was {} bytes)",
3153 big_content.len()
3154 );
3155
3156 let mut f = tempfile::NamedTempFile::new().expect("temp file");
3157 f.write_all(big_content.as_bytes()).expect("write");
3158 f.flush().expect("flush");
3159 let result =
3160 CamelConfig::from_file_with_profile(f.path().to_str().unwrap(), Some("default"));
3161 assert!(result.is_err(), "oversized config file must be rejected");
3162 }
3163}