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