Skip to main content

camel_config/
config.rs

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    /// Enable file-watcher hot-reload. Defaults to false.
18    /// Can be overridden per profile in Camel.toml or via `--watch` / `--no-watch` CLI flags.
19    // TODO(CONFIG-004): hot-reload watch plumbing not fully implemented yet.
20    #[serde(default)]
21    pub watch: bool,
22
23    /// Optional redb runtime journal configuration.
24    ///
25    /// When unset, runtime state is ephemeral (in-memory only).
26    #[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    // TODO(CONFIG-004): Hot-reload via file watcher not yet implemented.
40    // watch_debounce_ms is parsed but currently unused.
41    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    /// In-process scripting language limits (Rhai, Boa JS).
62    /// See [`LanguagesConfig`] for details.
63    #[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    /// Catch-all for extra keys injected by config sources (e.g. CAMEL_* env vars)
73    /// or unknown fields in TOML. Nested structs use `deny_unknown_fields` to
74    /// catch typos in sections like `[observability.health]`.
75    #[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/// Platform selection for leader election, readiness, and identity.
169///
170/// `[platform]` in Camel.toml. Defaults to noop (always leader, always ready).
171#[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/// Kubernetes platform configuration for `[platform]` in Camel.toml.
180#[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    /// Raw per-component config blocks, keyed by component name.
229    /// Each bundle is responsible for deserializing its own block.
230    #[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/// Protocol for OTLP export.
308#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
309#[serde(rename_all = "snake_case")]
310pub enum OtelProtocol {
311    #[default]
312    Grpc,
313    Http,
314}
315
316/// Sampling strategy.
317#[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/// OpenTelemetry configuration for `[observability.otel]` in Camel.toml.
327#[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    /// Maximum number of restart attempts. `None` means retry forever.
378    pub max_attempts: Option<u32>,
379
380    /// Delay before the first restart attempt in milliseconds.
381    #[serde(default = "default_initial_delay_ms")]
382    pub initial_delay_ms: u64,
383
384    /// Multiplier applied to the delay after each failed attempt.
385    #[serde(default = "default_backoff_multiplier")]
386    pub backoff_multiplier: f64,
387
388    /// Maximum delay cap between restart attempts in milliseconds.
389    #[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    /// Convert to camel_api::SupervisionConfig
406    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/// Durability mode for the redb journal. Mirrors `camel_core::JournalDurability`.
417///
418/// Defined here (in camel-config) for TOML deserialization. Mapped to the
419/// camel-core type in `context_ext.rs` via `From`. No circular dependency —
420/// camel-config already depends on camel-core.
421#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
422#[serde(rename_all = "snake_case")]
423pub enum JournalDurability {
424    /// fsync on every commit — protects against power loss (default).
425    #[default]
426    Immediate,
427    /// No fsync — suitable for dev/test.
428    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/// Configuration for the redb runtime event journal.
445#[derive(Debug, Clone, Deserialize, PartialEq)]
446#[serde(deny_unknown_fields)]
447pub struct JournalConfig {
448    /// Path to the `.db` file. Created if it does not exist.
449    pub path: std::path::PathBuf,
450
451    /// Durability mode. Default: `immediate`.
452    #[serde(default)]
453    pub durability: JournalDurability,
454
455    /// Trigger compaction after this many events. Default: 10_000.
456    #[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    /// WASM runtime limits for this bean. All `None` by default — runtime
486    /// defaults apply. See `WasmLimitsConfig`.
487    #[serde(default)]
488    pub limits: crate::wasm_limits::WasmLimitsConfig,
489}
490
491// ---------------------------------------------------------------------------
492// Security configuration (Keycloak etc.)
493// ---------------------------------------------------------------------------
494
495#[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// ---------------------------------------------------------------------------
745// Keycloak UMA (User-Managed Access) configuration
746// ---------------------------------------------------------------------------
747
748/// Cache configuration for permission evaluators.
749/// Default positive TTL is 30s (shorter than token introspection's 60s)
750/// because authorization decisions can change faster than identity claims.
751#[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    /// WASM runtime limits applied when `provider = "wasm"`. Ignored otherwise.
773    #[serde(default)]
774    pub limits: crate::wasm_limits::WasmLimitsConfig,
775}
776
777/// Configuration for a single WASM-based security policy, referenced by name from
778/// `[security.policies.wasm.<name>]` in Camel.toml.
779///
780/// ```toml
781/// [security.policies.wasm.corp-auth]
782/// path = "plugins/authz.wasm"
783/// [security.policies.wasm.corp-auth.limits]
784/// timeout-secs = 30
785/// [security.policies.wasm.corp-auth.config]
786/// ldap_url = "ldap://corp"
787/// ```
788#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
789#[serde(deny_unknown_fields)]
790pub struct WasmSecurityPolicyConfig {
791    /// Path to the .wasm file, relative to the project root or absolute.
792    pub path: String,
793    /// WASM runtime limits (timeout, memory, concurrency).
794    #[serde(default)]
795    pub limits: crate::wasm_limits::WasmLimitsConfig,
796    /// Key-value pairs passed to the guest's `init()` function.
797    #[serde(default)]
798    pub config: HashMap<String, String>,
799}
800
801/// Wrapper for `[security.policies]` sub-tables.
802#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
803#[serde(deny_unknown_fields)]
804pub struct WasmSecurityPoliciesConfig {
805    /// Named WASM security policies, keyed by registry name.
806    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/// Configuration for Keycloak UMA (User-Managed Access) authorization.
830///
831/// Inherits `server_url`, `realm`, `client_id`, `client_secret` from the parent
832/// [`KeycloakSecurityConfig`]; only provider selection and cache tuning live here.
833#[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
888/// Maximum size (in bytes) for config files read via `read_capped`.
889/// Prevents OOM from abnormally large config/route files.
890pub(crate) const MAX_CONFIG_FILE_SIZE: u64 = 16 * 1024 * 1024;
891
892/// Read a file with a size cap. Stats the file first, rejects if too large.
893pub(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
907/// Async variant of [`read_capped`] — uses `spawn_blocking` so the stat + read
908/// does not block the async executor. Config loading is startup-only and files
909/// are always under the cap in normal use, so the blocking cost is negligible.
910async 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
917/// Deep merge two TOML values
918/// Tables are merged recursively, with overlay values taking precedence
919pub(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                    // Both have this key - merge recursively
925                    merge_toml_values(base_value, value);
926                } else {
927                    // Only overlay has this key - insert it
928                    base_table.insert(key.clone(), value.clone());
929                }
930            }
931        }
932        // For non-table values, overlay replaces base entirely
933        (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        // Strip `include` before passing to inner builder (not a CamelConfig field)
1138        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    /// Validates and extracts the `include` field from a parsed TOML value.
1151    /// Returns an error if `include` is present but not an array of strings.
1152    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    /// Async version of [`Self::from_file`] — uses `tokio::fs` to avoid blocking the executor.
1184    pub async fn from_file_async(path: &str) -> Result<Self, ConfigError> {
1185        Self::from_file_async_with_profile(path, None).await
1186    }
1187
1188    /// Async version of [`Self::from_file_with_profile`] — uses `tokio::fs`.
1189    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    /// Async version of [`Self::from_file_with_env`] — uses `tokio::fs`.
1219    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    /// Async version of [`Self::from_file_with_profile_and_env`] — uses `tokio::fs`.
1224    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
1254/// Parse an env var value into a JSON value with type awareness.
1255/// Tries int, float, bool, then falls back to string.
1256fn 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
1268/// Allowlisted env vars that can override config fields (L-C2 security hardening).
1269///
1270/// Only these `CAMEL_*` vars are read as config overrides via the
1271/// `build_from_toml_value_inner` env-merging path. Non-allowlisted `CAMEL_*` vars
1272/// are silently ignored (NOT rejected). This is intentional: many `CAMEL_*` env
1273/// vars are component-specific (e.g. `CAMEL_JMS_BRIDGE_BINARY_PATH`,
1274/// `CAMEL_NATIVE_ISSUER_KEY_PEM`) or consumed directly by the caller
1275/// (`CAMEL_CONFIG_FILE`) or read directly in `build_from_toml_value_inner`
1276/// (`CAMEL_PROFILE`). The security property — that no security-sensitive field can
1277/// be overridden via env — is still achieved.
1278///
1279/// Two vars handled outside this allowlist:
1280/// - `CAMEL_CONFIG_FILE` — consumed by `from_env_or_default()` before config loading.
1281/// - `CAMEL_PROFILE` — read directly in `build_from_toml_value_inner` (needed before
1282///   the config source is built).
1283const 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
1294/// Core config builder. Accepts a pre-parsed (and `include`-stripped) `toml::Value`
1295/// so callers do not need to re-parse the content.
1296fn 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    // CAMEL_PROFILE is read directly (not through the allowlist) because it's
1303    // needed before the config source is built — the profile determines which
1304    // TOML sections to merge.
1305    let env_profile = env::var("CAMEL_PROFILE").ok();
1306    let profile = profile.or(env_profile.as_deref());
1307
1308    // Defensively strip `include` in case callers forgot — it is not a CamelConfig field.
1309    if let toml::Value::Table(ref mut table) = config_value {
1310        table.remove("include");
1311    }
1312
1313    // Detect whether the root file has profile sections (e.g. [default], [production]).
1314    // If it does, use strict profile handling (unknown profile → error).
1315    // If it doesn't (flat config), use lenient handling (keep as-is).
1316    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        // Flat config — no profile sections, keep as-is
1326        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        // L-C2: Only allowlisted env vars override config (security hardening).
1345        // Warn about non-allowlisted CAMEL_* vars (operator feedback for typos/stale vars).
1346        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        // Build a JSON object with nested structure for fields like supervision.*.
1352        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                // Handle nested keys (e.g., supervision_initial_delay_ms → supervision.initial_delay_ms)
1360                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
1444/// Apply profile-based TOML section merging in-place.
1445pub(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    // If no profile active and no [default] → keep as-is
1465    Ok(())
1466}
1467
1468/// Like `apply_profile` but lenient: if the included file has no profile sections,
1469/// keep it as-is rather than returning an error. Use for included files that may be
1470/// written as flat config without profile sections.
1471pub(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                // Has [default] but not this profile → use default
1485                *value = default_val;
1486            }
1487            (None, None) => {
1488                // No profile structure → use file as-is (flat config without profiles)
1489            }
1490        }
1491    } else if let Some(default_val) = value.get("default").cloned() {
1492        *value = default_val;
1493    }
1494    // If no profile active and no [default] → keep as-is
1495}
1496
1497/// Serializes tests that touch `CAMEL_TIMEOUT_MS` / `CAMEL_PROFILE` env vars.
1498///
1499/// `test_from_file_with_env_overrides_timeout` (in `profile_loading_tests`)
1500/// sets `CAMEL_TIMEOUT_MS=9999` and the async loader in `async_io_tests`
1501/// reads env via `config::Environment::with_prefix("CAMEL")` after an
1502/// `.await` point, so without serialization the two race and the async
1503/// test flakes ~1/5 runs in workspace mode.
1504#[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        // Serialize against async env-reading tests (see ENV_OVERRIDE_LOCK).
1866        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        // SAFETY: tests run in controlled process; we set and immediately restore env var.
1876        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        // SAFETY: restore process env for test isolation.
1885        unsafe {
1886            std::env::remove_var("CAMEL_TIMEOUT_MS");
1887        }
1888    }
1889
1890    #[test]
1891    fn env_override_allows_supervision_nested_field() {
1892        // Serialize against async env-reading tests (see ENV_OVERRIDE_LOCK).
1893        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        // SAFETY: tests run in controlled process; we set and immediately restore env vars.
1907        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        // SAFETY: restore process env for test isolation.
1919        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        // Serialize against async env-reading tests (see ENV_OVERRIDE_LOCK).
1928        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        // SAFETY: tests run in controlled process; we set and immediately restore env vars.
1938        unsafe {
1939            // Allowlisted — should override.
1940            std::env::set_var("CAMEL_DRAIN_TIMEOUT_MS", "999");
1941            // Non-allowlisted — should be silently ignored, not crash.
1942            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        // Allowlisted drain_timeout_ms should be overridden to 999.
1948        assert_eq!(cfg.drain_timeout_ms, 999);
1949        // Beans should remain at default (empty), unaffected by non-allowlisted CAMEL_BEANS_FOO.
1950        assert!(cfg.beans.is_empty());
1951
1952        // SAFETY: restore process env for test isolation.
1953        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        // Serialize against env-touching tests (see ENV_OVERRIDE_LOCK). Held
2967        // across the `.await` because `config::Environment::with_prefix(...)`
2968        // reads env vars deep inside `from_file_async_with_env`.
2969        let _guard = super::ENV_OVERRIDE_LOCK.lock().unwrap();
2970
2971        // SAFETY: clear potentially leaked env vars from other parallel tests;
2972        // this test asserts the file-only value (1000).
2973        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        // A single key with a very long string value — valid TOML, > 16 MiB
3123        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}