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