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