Skip to main content

camel_config/
config.rs

1use camel_core::TracerConfig;
2use config::{Config, ConfigError};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::env;
6use std::time::Duration;
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct CamelConfig {
10    #[serde(default)]
11    pub routes: Vec<String>,
12
13    /// Enable file-watcher hot-reload. Defaults to false.
14    /// Can be overridden per profile in Camel.toml or via `--watch` / `--no-watch` CLI flags.
15    #[serde(default)]
16    pub watch: bool,
17
18    /// Optional redb runtime journal configuration.
19    ///
20    /// When unset, runtime state is ephemeral (in-memory only).
21    #[serde(default)]
22    pub runtime_journal: Option<JournalConfig>,
23
24    #[serde(default = "default_log_level")]
25    pub log_level: String,
26
27    #[serde(default = "default_timeout_ms")]
28    pub timeout_ms: u64,
29
30    #[serde(default = "default_drain_timeout_ms")]
31    pub drain_timeout_ms: u64,
32
33    #[serde(default = "default_watch_debounce_ms")]
34    pub watch_debounce_ms: u64,
35
36    #[serde(default)]
37    pub components: ComponentsConfig,
38
39    #[serde(default)]
40    pub observability: ObservabilityConfig,
41
42    #[serde(default)]
43    pub supervision: Option<SupervisionCamelConfig>,
44
45    #[serde(default)]
46    pub platform: PlatformCamelConfig,
47
48    #[serde(default)]
49    pub stream_caching: StreamCachingConfig,
50
51    #[serde(default)]
52    pub beans: HashMap<String, BeanConfig>,
53}
54
55/// Platform selection for leader election, readiness, and identity.
56///
57/// `[platform]` in Camel.toml. Defaults to noop (always leader, always ready).
58#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
59#[serde(tag = "type", rename_all = "snake_case")]
60pub enum PlatformCamelConfig {
61    #[default]
62    Noop,
63    Kubernetes(KubernetesPlatformCamelConfig),
64}
65
66/// Kubernetes platform configuration for `[platform]` in Camel.toml.
67#[derive(Debug, Clone, Deserialize, PartialEq)]
68pub struct KubernetesPlatformCamelConfig {
69    #[serde(default)]
70    pub namespace: Option<String>,
71    #[serde(default = "default_lease_name_prefix")]
72    pub lease_name_prefix: String,
73    #[serde(default = "default_lease_duration_secs")]
74    pub lease_duration_secs: u64,
75    #[serde(default = "default_renew_deadline_secs")]
76    pub renew_deadline_secs: u64,
77    #[serde(default = "default_retry_period_secs")]
78    pub retry_period_secs: u64,
79    #[serde(default = "default_kubernetes_jitter_factor")]
80    pub jitter_factor: f64,
81}
82
83impl Default for KubernetesPlatformCamelConfig {
84    fn default() -> Self {
85        Self {
86            namespace: None,
87            lease_name_prefix: default_lease_name_prefix(),
88            lease_duration_secs: default_lease_duration_secs(),
89            renew_deadline_secs: default_renew_deadline_secs(),
90            retry_period_secs: default_retry_period_secs(),
91            jitter_factor: default_kubernetes_jitter_factor(),
92        }
93    }
94}
95
96fn default_lease_name_prefix() -> String {
97    "camel-".to_string()
98}
99fn default_lease_duration_secs() -> u64 {
100    15
101}
102fn default_renew_deadline_secs() -> u64 {
103    10
104}
105fn default_retry_period_secs() -> u64 {
106    2
107}
108fn default_kubernetes_jitter_factor() -> f64 {
109    0.2
110}
111
112#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
113pub struct ComponentsConfig {
114    /// Raw per-component config blocks, keyed by component name.
115    /// Each bundle is responsible for deserializing its own block.
116    #[serde(flatten)]
117    pub raw: HashMap<String, toml::Value>,
118}
119
120#[derive(Debug, Clone, Deserialize, PartialEq)]
121pub struct PrometheusCamelConfig {
122    #[serde(default)]
123    pub enabled: bool,
124    #[serde(default = "default_prometheus_host")]
125    pub host: String,
126    #[serde(default = "default_prometheus_port")]
127    pub port: u16,
128}
129
130impl Default for PrometheusCamelConfig {
131    fn default() -> Self {
132        Self {
133            enabled: false,
134            host: default_prometheus_host(),
135            port: default_prometheus_port(),
136        }
137    }
138}
139
140fn default_prometheus_host() -> String {
141    "0.0.0.0".to_string()
142}
143fn default_prometheus_port() -> u16 {
144    9090
145}
146
147#[derive(Debug, Clone, Deserialize, PartialEq)]
148pub struct HealthCamelConfig {
149    #[serde(default)]
150    pub enabled: bool,
151    #[serde(default = "default_health_host")]
152    pub host: String,
153    #[serde(default = "default_health_port")]
154    pub port: u16,
155}
156
157impl Default for HealthCamelConfig {
158    fn default() -> Self {
159        Self {
160            enabled: false,
161            host: default_health_host(),
162            port: default_health_port(),
163        }
164    }
165}
166
167fn default_health_host() -> String {
168    "0.0.0.0".to_string()
169}
170
171fn default_health_port() -> u16 {
172    8081
173}
174
175#[derive(Debug, Clone, Deserialize, Default)]
176pub struct ObservabilityConfig {
177    #[serde(default)]
178    pub tracer: TracerConfig,
179
180    #[serde(default)]
181    pub otel: Option<OtelCamelConfig>,
182
183    #[serde(default)]
184    pub prometheus: Option<PrometheusCamelConfig>,
185
186    #[serde(default)]
187    pub health: Option<HealthCamelConfig>,
188}
189
190/// Protocol for OTLP export.
191#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
192#[serde(rename_all = "snake_case")]
193pub enum OtelProtocol {
194    #[default]
195    Grpc,
196    Http,
197}
198
199/// Sampling strategy.
200#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
201#[serde(rename_all = "snake_case")]
202pub enum OtelSampler {
203    #[default]
204    AlwaysOn,
205    AlwaysOff,
206    Ratio,
207}
208
209/// OpenTelemetry configuration for `[observability.otel]` in Camel.toml.
210#[derive(Debug, Clone, Deserialize, Default)]
211pub struct OtelCamelConfig {
212    #[serde(default)]
213    pub enabled: bool,
214
215    #[serde(default = "default_otel_endpoint")]
216    pub endpoint: String,
217
218    #[serde(default = "default_otel_service_name")]
219    pub service_name: String,
220
221    #[serde(default = "default_otel_log_level")]
222    pub log_level: String,
223
224    #[serde(default)]
225    pub protocol: OtelProtocol,
226
227    #[serde(default)]
228    pub sampler: OtelSampler,
229
230    #[serde(default)]
231    pub sampler_ratio: Option<f64>,
232
233    #[serde(default = "default_otel_metrics_interval_ms")]
234    pub metrics_interval_ms: u64,
235
236    #[serde(default = "default_true")]
237    pub logs_enabled: bool,
238
239    #[serde(default)]
240    pub resource_attrs: HashMap<String, String>,
241}
242
243#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
244pub struct SupervisionCamelConfig {
245    /// Maximum number of restart attempts. `None` means retry forever.
246    pub max_attempts: Option<u32>,
247
248    /// Delay before the first restart attempt in milliseconds.
249    #[serde(default = "default_initial_delay_ms")]
250    pub initial_delay_ms: u64,
251
252    /// Multiplier applied to the delay after each failed attempt.
253    #[serde(default = "default_backoff_multiplier")]
254    pub backoff_multiplier: f64,
255
256    /// Maximum delay cap between restart attempts in milliseconds.
257    #[serde(default = "default_max_delay_ms")]
258    pub max_delay_ms: u64,
259}
260
261impl Default for SupervisionCamelConfig {
262    fn default() -> Self {
263        Self {
264            max_attempts: Some(5),
265            initial_delay_ms: 1000,
266            backoff_multiplier: 2.0,
267            max_delay_ms: 60000,
268        }
269    }
270}
271
272impl SupervisionCamelConfig {
273    /// Convert to camel_api::SupervisionConfig
274    pub fn into_supervision_config(self) -> camel_api::SupervisionConfig {
275        camel_api::SupervisionConfig {
276            max_attempts: self.max_attempts,
277            initial_delay: Duration::from_millis(self.initial_delay_ms),
278            backoff_multiplier: self.backoff_multiplier,
279            max_delay: Duration::from_millis(self.max_delay_ms),
280        }
281    }
282}
283
284/// Durability mode for the redb journal. Mirrors `camel_core::JournalDurability`.
285///
286/// Defined here (in camel-config) for TOML deserialization. Mapped to the
287/// camel-core type in `context_ext.rs` via `From`. No circular dependency —
288/// camel-config already depends on camel-core.
289#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
290#[serde(rename_all = "snake_case")]
291pub enum JournalDurability {
292    /// fsync on every commit — protects against power loss (default).
293    #[default]
294    Immediate,
295    /// No fsync — suitable for dev/test.
296    Eventual,
297}
298
299impl From<JournalDurability> for camel_core::JournalDurability {
300    fn from(d: JournalDurability) -> Self {
301        match d {
302            JournalDurability::Immediate => camel_core::JournalDurability::Immediate,
303            JournalDurability::Eventual => camel_core::JournalDurability::Eventual,
304        }
305    }
306}
307
308fn default_compaction_threshold_events() -> u64 {
309    10_000
310}
311
312/// Configuration for the redb runtime event journal.
313#[derive(Debug, Clone, Deserialize, PartialEq)]
314pub struct JournalConfig {
315    /// Path to the `.db` file. Created if it does not exist.
316    pub path: std::path::PathBuf,
317
318    /// Durability mode. Default: `immediate`.
319    #[serde(default)]
320    pub durability: JournalDurability,
321
322    /// Trigger compaction after this many events. Default: 10_000.
323    #[serde(default = "default_compaction_threshold_events")]
324    pub compaction_threshold_events: u64,
325}
326
327#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
328pub struct StreamCachingConfig {
329    #[serde(default = "default_stream_cache_threshold")]
330    pub threshold: usize,
331}
332
333fn default_stream_cache_threshold() -> usize {
334    camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLD
335}
336
337impl Default for StreamCachingConfig {
338    fn default() -> Self {
339        Self {
340            threshold: default_stream_cache_threshold(),
341        }
342    }
343}
344
345#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
346pub struct BeanConfig {
347    pub plugin: String,
348}
349
350impl From<&JournalConfig> for camel_core::RedbJournalOptions {
351    fn from(cfg: &JournalConfig) -> Self {
352        camel_core::RedbJournalOptions {
353            durability: cfg.durability.clone().into(),
354            compaction_threshold_events: cfg.compaction_threshold_events,
355        }
356    }
357}
358
359fn default_log_level() -> String {
360    "INFO".to_string()
361}
362fn default_timeout_ms() -> u64 {
363    5000
364}
365fn default_drain_timeout_ms() -> u64 {
366    10_000
367}
368fn default_watch_debounce_ms() -> u64 {
369    300
370}
371
372fn default_otel_endpoint() -> String {
373    "http://localhost:4317".to_string()
374}
375fn default_otel_service_name() -> String {
376    "rust-camel".to_string()
377}
378fn default_otel_log_level() -> String {
379    "info".to_string()
380}
381fn default_otel_metrics_interval_ms() -> u64 {
382    60000
383}
384fn default_true() -> bool {
385    true
386}
387
388fn default_initial_delay_ms() -> u64 {
389    1000
390}
391
392fn default_backoff_multiplier() -> f64 {
393    2.0
394}
395
396fn default_max_delay_ms() -> u64 {
397    60000
398}
399
400/// Deep merge two TOML values
401/// Tables are merged recursively, with overlay values taking precedence
402fn merge_toml_values(base: &mut toml::Value, overlay: &toml::Value) {
403    match (base, overlay) {
404        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
405            for (key, value) in overlay_table {
406                if let Some(base_value) = base_table.get_mut(key) {
407                    // Both have this key - merge recursively
408                    merge_toml_values(base_value, value);
409                } else {
410                    // Only overlay has this key - insert it
411                    base_table.insert(key.clone(), value.clone());
412                }
413            }
414        }
415        // For non-table values, overlay replaces base entirely
416        (base, overlay) => {
417            *base = overlay.clone();
418        }
419    }
420}
421
422impl CamelConfig {
423    pub fn from_file(path: &str) -> Result<Self, ConfigError> {
424        Self::from_file_with_profile(path, None)
425    }
426
427    pub fn from_file_with_env(path: &str) -> Result<Self, ConfigError> {
428        Self::from_file_with_profile_and_env(path, None)
429    }
430
431    pub fn from_file_with_profile(path: &str, profile: Option<&str>) -> Result<Self, ConfigError> {
432        // Get profile from parameter or environment variable
433        let env_profile = env::var("CAMEL_PROFILE").ok();
434        let profile = profile.or(env_profile.as_deref());
435
436        // Read the TOML file as a generic value for deep merging
437        let content = std::fs::read_to_string(path)
438            .map_err(|e| ConfigError::Message(format!("Failed to read config file: {}", e)))?;
439        let mut config_value: toml::Value = toml::from_str(&content)
440            .map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))?;
441
442        // If a profile is specified, merge it with default
443        if let Some(p) = profile {
444            // Extract default config as base
445            let default_value = config_value.get("default").cloned();
446
447            // Extract profile config
448            let profile_value = config_value.get(p).cloned();
449
450            if let (Some(mut base), Some(overlay)) = (default_value, profile_value) {
451                // Deep merge profile onto default
452                merge_toml_values(&mut base, &overlay);
453
454                // Replace the entire config with the merged result
455                config_value = base;
456            } else if let Some(profile_val) = config_value.get(p).cloned() {
457                // No default, just use profile
458                config_value = profile_val;
459            } else {
460                return Err(ConfigError::Message(format!("Unknown profile: {}", p)));
461            }
462        } else {
463            // No profile specified, use default section if it exists
464            if let Some(default_val) = config_value.get("default").cloned() {
465                config_value = default_val;
466            }
467        }
468
469        // Deserialize the merged config
470        let merged_toml = toml::to_string(&config_value).map_err(|e| {
471            ConfigError::Message(format!("Failed to serialize merged config: {}", e))
472        })?;
473
474        let config = Config::builder()
475            .add_source(config::File::from_str(
476                &merged_toml,
477                config::FileFormat::Toml,
478            ))
479            .build()?;
480
481        config.try_deserialize()
482    }
483
484    pub fn from_file_with_profile_and_env(
485        path: &str,
486        profile: Option<&str>,
487    ) -> Result<Self, ConfigError> {
488        // Get profile from parameter or environment variable
489        let env_profile = env::var("CAMEL_PROFILE").ok();
490        let profile = profile.or(env_profile.as_deref());
491
492        // Read the TOML file as a generic value for deep merging
493        let content = std::fs::read_to_string(path)
494            .map_err(|e| ConfigError::Message(format!("Failed to read config file: {}", e)))?;
495        let mut config_value: toml::Value = toml::from_str(&content)
496            .map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))?;
497
498        // If a profile is specified, merge it with default
499        if let Some(p) = profile {
500            // Extract default config as base
501            let default_value = config_value.get("default").cloned();
502
503            // Extract profile config
504            let profile_value = config_value.get(p).cloned();
505
506            if let (Some(mut base), Some(overlay)) = (default_value, profile_value) {
507                // Deep merge profile onto default
508                merge_toml_values(&mut base, &overlay);
509
510                // Replace the entire config with the merged result
511                config_value = base;
512            } else if let Some(profile_val) = config_value.get(p).cloned() {
513                // No default, just use profile
514                config_value = profile_val;
515            } else {
516                return Err(ConfigError::Message(format!("Unknown profile: {}", p)));
517            }
518        } else {
519            // No profile specified, use default section if it exists
520            if let Some(default_val) = config_value.get("default").cloned() {
521                config_value = default_val;
522            }
523        }
524
525        // Deserialize the merged config and apply environment variables
526        let merged_toml = toml::to_string(&config_value).map_err(|e| {
527            ConfigError::Message(format!("Failed to serialize merged config: {}", e))
528        })?;
529
530        let config = Config::builder()
531            .add_source(config::File::from_str(
532                &merged_toml,
533                config::FileFormat::Toml,
534            ))
535            .add_source(config::Environment::with_prefix("CAMEL").try_parsing(true))
536            .build()?;
537
538        config.try_deserialize()
539    }
540
541    pub fn from_env_or_default() -> Result<Self, ConfigError> {
542        let path = env::var("CAMEL_CONFIG_FILE").unwrap_or_else(|_| "Camel.toml".to_string());
543
544        Self::from_file(&path)
545    }
546}
547
548#[cfg(test)]
549mod camel_config_defaults_tests {
550    use super::*;
551
552    #[test]
553    fn watch_debounce_ms_default_is_300() {
554        let config: CamelConfig = toml::from_str("").unwrap();
555        assert_eq!(config.watch_debounce_ms, 300);
556    }
557
558    #[test]
559    fn watch_debounce_ms_custom_value() {
560        let config: CamelConfig = toml::from_str("watch_debounce_ms = 50").unwrap();
561        assert_eq!(config.watch_debounce_ms, 50);
562    }
563
564    #[test]
565    fn stream_caching_default_threshold_is_set() {
566        let config: CamelConfig = toml::from_str("").unwrap();
567        assert_eq!(
568            config.stream_caching.threshold,
569            camel_api::stream_cache::DEFAULT_STREAM_CACHE_THRESHOLD
570        );
571    }
572
573    #[test]
574    fn stream_caching_custom_threshold_value() {
575        let config: CamelConfig = toml::from_str("[stream_caching]\nthreshold = 1234").unwrap();
576        assert_eq!(config.stream_caching.threshold, 1234);
577    }
578}
579
580#[cfg(test)]
581mod components_config_tests {
582    use super::*;
583
584    #[test]
585    fn components_config_deserializes_raw_toml_block() {
586        let toml_str = r#"
587            [kafka]
588            brokers = ["localhost:9092"]
589
590            [redis]
591            host = "redis.local"
592        "#;
593        let cfg: ComponentsConfig = toml::from_str(toml_str).unwrap();
594        assert!(cfg.raw.contains_key("kafka"));
595        assert!(cfg.raw.contains_key("redis"));
596    }
597}
598
599#[cfg(test)]
600mod prometheus_config_tests {
601    use super::*;
602
603    fn parse(toml: &str) -> CamelConfig {
604        let cfg = config::Config::builder()
605            .add_source(config::File::from_str(toml, config::FileFormat::Toml))
606            .build()
607            .unwrap();
608        cfg.try_deserialize().unwrap()
609    }
610
611    #[test]
612    fn test_prometheus_absent_is_none() {
613        let cfg = parse("");
614        assert!(cfg.observability.prometheus.is_none());
615    }
616
617    #[test]
618    fn test_prometheus_defaults() {
619        let cfg = parse(
620            r#"
621[observability.prometheus]
622enabled = true
623"#,
624        );
625        let p = cfg.observability.prometheus.unwrap();
626        assert!(p.enabled);
627        assert_eq!(p.host, "0.0.0.0");
628        assert_eq!(p.port, 9090);
629    }
630
631    #[test]
632    fn test_prometheus_full() {
633        let cfg = parse(
634            r#"
635[observability.prometheus]
636enabled = true
637host = "127.0.0.1"
638port = 9091
639"#,
640        );
641        let p = cfg.observability.prometheus.unwrap();
642        assert_eq!(p.host, "127.0.0.1");
643        assert_eq!(p.port, 9091);
644    }
645
646    #[test]
647    fn test_health_config_defaults() {
648        let cfg = parse(
649            r#"
650[observability.health]
651enabled = true
652"#,
653        );
654        let h = cfg.observability.health.unwrap();
655        assert!(h.enabled);
656        assert_eq!(h.host, "0.0.0.0");
657        assert_eq!(h.port, 8081);
658    }
659
660    #[test]
661    fn test_health_config_custom_port() {
662        let cfg = parse(
663            r#"
664[observability.health]
665enabled = true
666port = 9091
667"#,
668        );
669        let h = cfg.observability.health.unwrap();
670        assert_eq!(h.port, 9091);
671        assert_eq!(h.host, "0.0.0.0");
672    }
673}
674
675#[cfg(test)]
676mod platform_config_tests {
677    use super::*;
678
679    fn parse(toml: &str) -> CamelConfig {
680        let cfg = config::Config::builder()
681            .add_source(config::File::from_str(toml, config::FileFormat::Toml))
682            .build()
683            .unwrap();
684        cfg.try_deserialize().unwrap()
685    }
686
687    #[test]
688    fn platform_default_is_noop() {
689        let cfg = parse("");
690        assert!(matches!(cfg.platform, PlatformCamelConfig::Noop));
691    }
692
693    #[test]
694    fn platform_parses_kubernetes_from_toml() {
695        let cfg = parse(
696            r#"
697[platform]
698type = "kubernetes"
699namespace = "team-a"
700lease_name_prefix = "camel-"
701lease_duration_secs = 15
702renew_deadline_secs = 10
703retry_period_secs = 2
704jitter_factor = 0.2
705"#,
706        );
707        match cfg.platform {
708            PlatformCamelConfig::Kubernetes(k8s) => {
709                assert_eq!(k8s.namespace.as_deref(), Some("team-a"));
710                assert_eq!(k8s.lease_name_prefix, "camel-");
711                assert_eq!(k8s.lease_duration_secs, 15);
712                assert_eq!(k8s.renew_deadline_secs, 10);
713                assert_eq!(k8s.retry_period_secs, 2);
714                assert!((k8s.jitter_factor - 0.2).abs() < f64::EPSILON);
715            }
716            other => panic!("expected Kubernetes, got {:?}", other),
717        }
718    }
719
720    #[test]
721    fn platform_kubernetes_defaults() {
722        let cfg = parse(
723            r#"
724[platform]
725type = "kubernetes"
726"#,
727        );
728        match cfg.platform {
729            PlatformCamelConfig::Kubernetes(k8s) => {
730                assert!(k8s.namespace.is_none());
731                assert_eq!(k8s.lease_name_prefix, "camel-");
732                assert_eq!(k8s.lease_duration_secs, 15);
733                assert_eq!(k8s.renew_deadline_secs, 10);
734                assert_eq!(k8s.retry_period_secs, 2);
735                assert!((k8s.jitter_factor - 0.2).abs() < f64::EPSILON);
736            }
737            other => panic!("expected Kubernetes, got {:?}", other),
738        }
739    }
740
741    #[test]
742    fn platform_parses_kubernetes_from_file_with_profile() {
743        use std::io::Write;
744        let mut f = tempfile::NamedTempFile::new().expect("temp file");
745        f.write_all(
746            br#"
747[default]
748[default.platform]
749type = "kubernetes"
750namespace = "production"
751
752[dev]
753[dev.platform]
754type = "noop"
755"#,
756        )
757        .expect("write config");
758
759        let cfg_prod =
760            CamelConfig::from_file_with_profile(f.path().to_str().unwrap(), Some("default"))
761                .expect("prod config");
762        assert!(matches!(
763            cfg_prod.platform,
764            PlatformCamelConfig::Kubernetes(_)
765        ));
766
767        let cfg_dev = CamelConfig::from_file_with_profile(f.path().to_str().unwrap(), Some("dev"))
768            .expect("dev config");
769        assert!(matches!(cfg_dev.platform, PlatformCamelConfig::Noop));
770    }
771}
772
773#[cfg(test)]
774mod profile_loading_tests {
775    use super::*;
776
777    fn write_temp_config(contents: &str) -> tempfile::NamedTempFile {
778        use std::io::Write;
779        let mut f = tempfile::NamedTempFile::new().expect("temp file");
780        f.write_all(contents.as_bytes()).expect("write config");
781        f
782    }
783
784    #[test]
785    fn test_merge_toml_values_merges_nested_tables() {
786        let mut base: toml::Value = toml::from_str(
787            r#"
788[components.http]
789connect_timeout_ms = 1000
790pool_max_idle_per_host = 50
791"#,
792        )
793        .unwrap();
794
795        let overlay: toml::Value = toml::from_str(
796            r#"
797[components.http]
798response_timeout_ms = 2000
799pool_max_idle_per_host = 99
800"#,
801        )
802        .unwrap();
803
804        merge_toml_values(&mut base, &overlay);
805
806        let http = base
807            .get("components")
808            .and_then(|v| v.get("http"))
809            .expect("merged http table");
810        assert_eq!(
811            http.get("connect_timeout_ms").and_then(|v| v.as_integer()),
812            Some(1000)
813        );
814        assert_eq!(
815            http.get("response_timeout_ms").and_then(|v| v.as_integer()),
816            Some(2000)
817        );
818        assert_eq!(
819            http.get("pool_max_idle_per_host")
820                .and_then(|v| v.as_integer()),
821            Some(99)
822        );
823    }
824
825    #[test]
826    fn test_from_file_with_profile_merges_default_and_profile() {
827        let file = write_temp_config(
828            r#"
829[default]
830watch = false
831[default.components.http]
832connect_timeout_ms = 1000
833pool_max_idle_per_host = 50
834
835[prod]
836watch = true
837[prod.components.http]
838pool_max_idle_per_host = 200
839"#,
840        );
841
842        let cfg = CamelConfig::from_file_with_profile(file.path().to_str().unwrap(), Some("prod"))
843            .expect("config should load");
844
845        assert!(cfg.watch);
846        let http = cfg.components.raw.get("http").expect("http config");
847        assert_eq!(
848            http.get("connect_timeout_ms").and_then(|v| v.as_integer()),
849            Some(1000)
850        );
851        assert_eq!(
852            http.get("pool_max_idle_per_host")
853                .and_then(|v| v.as_integer()),
854            Some(200)
855        );
856    }
857
858    #[test]
859    fn test_from_file_with_profile_uses_profile_when_no_default() {
860        let file = write_temp_config(
861            r#"
862[dev]
863watch = true
864timeout_ms = 777
865"#,
866        );
867
868        let cfg = CamelConfig::from_file_with_profile(file.path().to_str().unwrap(), Some("dev"))
869            .expect("config should load");
870        assert!(cfg.watch);
871        assert_eq!(cfg.timeout_ms, 777);
872    }
873
874    #[test]
875    fn test_from_file_with_profile_unknown_profile_returns_error() {
876        let file = write_temp_config(
877            r#"
878[default]
879watch = false
880"#,
881        );
882
883        let err = CamelConfig::from_file_with_profile(file.path().to_str().unwrap(), Some("qa"))
884            .expect_err("should fail");
885        assert!(err.to_string().contains("Unknown profile: qa"));
886    }
887
888    #[test]
889    fn test_from_file_without_profile_uses_default_section() {
890        let file = write_temp_config(
891            r#"
892[default]
893watch = true
894timeout_ms = 321
895"#,
896        );
897
898        let cfg =
899            CamelConfig::from_file(file.path().to_str().unwrap()).expect("config should load");
900        assert!(cfg.watch);
901        assert_eq!(cfg.timeout_ms, 321);
902    }
903
904    #[test]
905    fn test_from_file_with_env_overrides_timeout() {
906        let file = write_temp_config(
907            r#"
908[default]
909timeout_ms = 1000
910"#,
911        );
912
913        // SAFETY: tests run in controlled process; we set and immediately restore env var.
914        unsafe {
915            std::env::set_var("CAMEL_TIMEOUT_MS", "9999");
916        }
917
918        let cfg = CamelConfig::from_file_with_env(file.path().to_str().unwrap())
919            .expect("config should load with env override");
920        assert_eq!(cfg.timeout_ms, 9999);
921
922        // SAFETY: restore process env for test isolation.
923        unsafe {
924            std::env::remove_var("CAMEL_TIMEOUT_MS");
925        }
926    }
927}
928
929#[cfg(test)]
930mod additional_config_tests {
931    use super::*;
932
933    #[test]
934    fn journal_durability_converts_to_core_type() {
935        let immediate: camel_core::JournalDurability = JournalDurability::Immediate.into();
936        let eventual: camel_core::JournalDurability = JournalDurability::Eventual.into();
937        assert_eq!(immediate, camel_core::JournalDurability::Immediate);
938        assert_eq!(eventual, camel_core::JournalDurability::Eventual);
939    }
940
941    #[test]
942    fn supervision_into_supervision_config_converts_durations() {
943        let input = SupervisionCamelConfig {
944            max_attempts: Some(7),
945            initial_delay_ms: 123,
946            backoff_multiplier: 1.5,
947            max_delay_ms: 999,
948        };
949
950        let out = input.into_supervision_config();
951        assert_eq!(out.max_attempts, Some(7));
952        assert_eq!(out.initial_delay, Duration::from_millis(123));
953        assert_eq!(out.backoff_multiplier, 1.5);
954        assert_eq!(out.max_delay, Duration::from_millis(999));
955    }
956
957    #[test]
958    fn redb_journal_options_from_journal_config_copies_fields() {
959        let cfg = JournalConfig {
960            path: std::path::PathBuf::from("journal.db"),
961            durability: JournalDurability::Eventual,
962            compaction_threshold_events: 42,
963        };
964
965        let options: camel_core::RedbJournalOptions = (&cfg).into();
966        assert_eq!(options.durability, camel_core::JournalDurability::Eventual);
967        assert_eq!(options.compaction_threshold_events, 42);
968    }
969
970    #[test]
971    fn from_env_or_default_uses_camel_config_file_env() {
972        use std::io::Write;
973
974        let mut file = tempfile::NamedTempFile::new().unwrap();
975        file.write_all(
976            br#"
977watch = true
978timeout_ms = 111
979"#,
980        )
981        .unwrap();
982
983        unsafe {
984            std::env::set_var("CAMEL_CONFIG_FILE", file.path());
985        }
986        let cfg = CamelConfig::from_env_or_default().unwrap();
987        unsafe {
988            std::env::remove_var("CAMEL_CONFIG_FILE");
989        }
990
991        assert!(cfg.watch);
992        assert_eq!(cfg.timeout_ms, 111);
993    }
994
995    #[test]
996    fn from_file_with_profile_and_env_unknown_profile_errors() {
997        use std::io::Write;
998
999        let mut file = tempfile::NamedTempFile::new().unwrap();
1000        file.write_all(
1001            br#"
1002[default]
1003watch = false
1004"#,
1005        )
1006        .unwrap();
1007
1008        let err = CamelConfig::from_file_with_profile_and_env(
1009            file.path().to_str().unwrap(),
1010            Some("missing"),
1011        )
1012        .unwrap_err();
1013        assert!(err.to_string().contains("Unknown profile: missing"));
1014    }
1015}
1016
1017#[cfg(test)]
1018mod beans_config_tests {
1019    use super::*;
1020
1021    #[test]
1022    fn beans_default_empty() {
1023        let config: CamelConfig = toml::from_str("").unwrap();
1024        assert!(config.beans.is_empty());
1025    }
1026
1027    #[test]
1028    fn beans_parsed_from_config() {
1029        let toml_str = r#"
1030[beans.auth]
1031plugin = "my-auth"
1032
1033[beans.cache]
1034plugin = "my-cache"
1035"#;
1036        let config: CamelConfig = toml::from_str(toml_str).unwrap();
1037        assert_eq!(config.beans.len(), 2);
1038        assert_eq!(config.beans.get("auth").unwrap().plugin, "my-auth");
1039        assert_eq!(config.beans.get("cache").unwrap().plugin, "my-cache");
1040    }
1041}