1#![deny(warnings)]
3#![deny(missing_docs)]
4
5use std::sync::{Arc, RwLock};
6use std::{borrow::Cow, collections::HashSet};
7
8pub use figment::value;
9use figment::{
10 error::Kind,
11 providers::{Env, Serialized},
12 Figment, Provider,
13};
14use saluki_error::GenericError;
15use serde::Deserialize;
16use snafu::Snafu;
17use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
18use tracing::{debug, error};
19
20pub mod duration_string;
21pub mod dynamic;
22mod provider;
23pub mod space_separated;
24
25pub use self::duration_string::{parse_duration, DurationString, ParseDurationError};
26pub use self::dynamic::FieldUpdateWatcher;
27use self::dynamic::{ConfigChangeEvent, ConfigUpdate};
28use self::provider::ResolvedProvider;
29pub use self::space_separated::{deserialize_opt_space_separated_or_seq, deserialize_space_separated_or_seq};
30
31#[derive(Clone)]
32struct ArcProvider(Arc<dyn Provider + Send + Sync>);
33
34impl Provider for ArcProvider {
35 fn metadata(&self) -> figment::Metadata {
36 self.0.metadata()
37 }
38
39 fn data(&self) -> Result<figment::value::Map<figment::Profile, figment::value::Dict>, figment::Error> {
40 self.0.data()
41 }
42}
43
44enum ProviderSource {
45 Static(ArcProvider),
46 Dynamic(Option<mpsc::Receiver<ConfigUpdate>>),
47}
48
49impl Clone for ProviderSource {
50 fn clone(&self) -> Self {
51 match self {
52 Self::Static(p) => Self::Static(p.clone()),
53 Self::Dynamic(_) => Self::Dynamic(None),
54 }
55 }
56}
57
58#[derive(Debug, Snafu)]
60#[snafu(context(suffix(false)))]
61pub enum ConfigurationError {
62 #[snafu(display("Environment variable prefix must not be empty."))]
64 EmptyPrefix,
65
66 #[snafu(display("Missing field '{}' in configuration. {}", field, help_text))]
68 MissingField {
69 help_text: String,
74
75 field: Cow<'static, str>,
77 },
78
79 #[snafu(display(
81 "Expected value for field '{}' to be '{}', got '{}' instead.",
82 field,
83 expected_ty,
84 actual_ty
85 ))]
86 InvalidFieldType {
87 field: String,
91
92 expected_ty: String,
94
95 actual_ty: String,
97 },
98
99 #[snafu(transparent)]
101 Generic {
102 source: GenericError,
104 },
105}
106
107impl From<figment::Error> for ConfigurationError {
108 fn from(e: figment::Error) -> Self {
109 match e.kind {
110 Kind::InvalidType(actual_ty, expected_ty) => Self::InvalidFieldType {
111 field: e.path.join("."),
112 expected_ty,
113 actual_ty: actual_ty.to_string(),
114 },
115 _ => Self::Generic { source: e.into() },
116 }
117 }
118}
119
120#[derive(Clone, Debug, Eq, Hash, PartialEq)]
121enum LookupSource {
122 Environment { prefix: String },
124}
125
126impl LookupSource {
127 fn transform_key(&self, key: &str) -> String {
128 match self {
129 LookupSource::Environment { prefix } => format!("{}{}", prefix, key.replace('.', "_").to_uppercase()),
132 }
133 }
134}
135
136#[derive(Clone, Default)]
152pub struct ConfigurationLoader {
153 key_aliases: &'static [(&'static str, &'static str)],
154 lookup_sources: HashSet<LookupSource>,
155 provider_sources: Vec<ProviderSource>,
156}
157
158impl ConfigurationLoader {
159 pub fn with_key_aliases(mut self, aliases: &'static [(&'static str, &'static str)]) -> Self {
168 self.key_aliases = aliases;
169 self
170 }
171
172 pub fn add_providers<P, I>(mut self, providers: I) -> Self
182 where
183 P: Provider + Send + Sync + 'static,
184 I: IntoIterator<Item = P>,
185 {
186 for p in providers {
187 self.provider_sources
188 .push(ProviderSource::Static(ArcProvider(Arc::new(p))));
189 }
190 self
191 }
192
193 pub fn from_yaml<P>(mut self, path: P) -> Result<Self, ConfigurationError>
199 where
200 P: AsRef<std::path::Path>,
201 {
202 let resolved_provider = ResolvedProvider::from_yaml(&path, self.key_aliases)?;
203 self.provider_sources
204 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
205 Ok(self)
206 }
207
208 pub fn try_from_yaml<P>(mut self, path: P) -> Self
212 where
213 P: AsRef<std::path::Path>,
214 {
215 match ResolvedProvider::from_yaml(&path, self.key_aliases) {
216 Ok(resolved_provider) => {
217 self.provider_sources
218 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
219 }
220 Err(e) => {
221 println!(
222 "Unable to read YAML configuration file '{}': {}. Ignoring.",
223 path.as_ref().to_string_lossy(),
224 e
225 );
226 }
227 }
228 self
229 }
230
231 pub fn from_json<P>(mut self, path: P) -> Result<Self, ConfigurationError>
237 where
238 P: AsRef<std::path::Path>,
239 {
240 let resolved_provider = ResolvedProvider::from_json(&path, self.key_aliases)?;
241 self.provider_sources
242 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
243 Ok(self)
244 }
245
246 pub fn try_from_json<P>(mut self, path: P) -> Self
250 where
251 P: AsRef<std::path::Path>,
252 {
253 match ResolvedProvider::from_json(&path, self.key_aliases) {
254 Ok(resolved_provider) => {
255 self.provider_sources
256 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
257 }
258 Err(e) => {
259 println!(
260 "Unable to read JSON configuration file '{}': {}. Ignoring.",
261 path.as_ref().to_string_lossy(),
262 e
263 );
264 }
265 }
266 self
267 }
268
269 pub fn from_environment(mut self, prefix: &'static str) -> Result<Self, ConfigurationError> {
282 if prefix.is_empty() {
283 return Err(ConfigurationError::EmptyPrefix);
284 }
285
286 let prefix = if prefix.ends_with('_') {
287 prefix.to_string()
288 } else {
289 format!("{}_", prefix)
290 };
291
292 let env = Env::prefixed(&prefix).split("__");
294 let values = env.data().unwrap();
295 if let Some(default_dict) = values.get(&figment::Profile::Default) {
296 self.provider_sources
297 .push(ProviderSource::Static(ArcProvider(Arc::new(Serialized::defaults(
298 default_dict.clone(),
299 )))));
300 self.lookup_sources.insert(LookupSource::Environment { prefix });
301 }
302 Ok(self)
303 }
304
305 pub fn with_dynamic_configuration(mut self, receiver: mpsc::Receiver<ConfigUpdate>) -> Self {
309 self.provider_sources.push(ProviderSource::Dynamic(Some(receiver)));
310 self
311 }
312
313 pub fn into_typed<'a, T>(self) -> Result<T, ConfigurationError>
319 where
320 T: Deserialize<'a>,
321 {
322 let figment = build_figment_from_sources(&self.provider_sources);
323 figment.extract().map_err(Into::into)
324 }
325
326 pub fn bootstrap_generic(&self) -> GenericConfiguration {
331 let figment = build_figment_from_sources(&self.provider_sources);
332
333 GenericConfiguration {
334 inner: Arc::new(Inner {
335 figment: RwLock::new(figment),
336 lookup_sources: self.lookup_sources.clone(),
337 event_sender: None,
338 ready_signal: Mutex::new(None),
339 }),
340 }
341 }
342
343 pub async fn into_generic(mut self) -> Result<GenericConfiguration, ConfigurationError> {
345 let has_dynamic_provider = self
346 .provider_sources
347 .iter()
348 .any(|s| matches!(s, ProviderSource::Dynamic(_)));
349
350 if has_dynamic_provider {
351 let mut receiver_opt = None;
352 for source in self.provider_sources.iter_mut() {
353 if let ProviderSource::Dynamic(ref mut receiver) = source {
354 receiver_opt = receiver.take();
355 break;
356 }
357 }
358 let receiver = receiver_opt.expect("Dynamic receiver should exist but was not found");
359
360 let figment = build_figment_from_sources(&self.provider_sources);
362
363 let (event_sender, _) = broadcast::channel(100);
364 let (ready_sender, ready_receiver) = oneshot::channel();
365
366 let generic_config = GenericConfiguration {
367 inner: Arc::new(Inner {
368 figment: RwLock::new(figment),
369 lookup_sources: self.lookup_sources,
370 event_sender: Some(event_sender.clone()),
371 ready_signal: Mutex::new(Some(ready_receiver)),
372 }),
373 };
374
375 tokio::spawn(run_dynamic_config_updater(
377 generic_config.inner.clone(),
378 receiver,
379 self.provider_sources,
380 event_sender,
381 ready_sender,
382 ));
383
384 Ok(generic_config)
385 } else {
386 let figment = build_figment_from_sources(&self.provider_sources);
388
389 Ok(GenericConfiguration {
390 inner: Arc::new(Inner {
391 figment: RwLock::new(figment),
392 lookup_sources: self.lookup_sources,
393 event_sender: None,
394 ready_signal: Mutex::new(None),
395 }),
396 })
397 }
398 }
399
400 #[cfg(any(test, feature = "test-util"))]
411 pub async fn for_tests(
412 file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
413 enable_dynamic_configuration: bool,
414 ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>) {
415 Self::for_tests_with_provider_factory(file_values, env_vars, enable_dynamic_configuration, &[], |_| {
416 Serialized::defaults(serde_json::json!({}))
417 })
418 .await
419 }
420
421 #[cfg(any(test, feature = "test-util"))]
431 pub async fn for_tests_with_provider_factory<P, F>(
432 file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
433 enable_dynamic_configuration: bool, key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
434 ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>)
435 where
436 P: Provider + Send + Sync + 'static,
437 F: FnOnce(Vec<(String, String)>) -> P,
438 {
439 let json_file = tempfile::NamedTempFile::new().expect("should not fail to create temp file.");
440 let path = &json_file.path();
441 let json_to_write = file_values.unwrap_or(serde_json::json!({}));
442 serde_json::to_writer(&json_file, &json_to_write).expect("should not fail to write to temp file.");
443
444 let mut loader = ConfigurationLoader::default()
445 .with_key_aliases(key_aliases)
446 .try_from_json(path);
447 let mut maybe_sender = None;
448 if enable_dynamic_configuration {
449 let (sender, receiver) = tokio::sync::mpsc::channel(1);
450 loader = loader.with_dynamic_configuration(receiver);
451 maybe_sender = Some(sender);
452 }
453
454 let guard = test_env_lock();
458
459 if let Some(pairs) = env_vars.as_ref() {
460 for (k, v) in pairs.iter() {
461 std::env::set_var(k, v);
465 std::env::set_var(format!("TEST_{}", k), v);
466 }
467 }
468
469 let provider_env_vars = env_vars.unwrap_or_default().to_vec();
471 let loader = loader.add_providers([provider_factory(provider_env_vars)]);
472
473 let loader = loader
475 .from_environment("TEST")
476 .expect("should not fail to add environment provider");
477
478 if let Some(pairs) = env_vars.as_ref() {
480 for (k, _) in pairs.iter() {
481 std::env::remove_var(k);
482 std::env::remove_var(format!("TEST_{}", k));
483 }
484 }
485
486 drop(guard);
487
488 let cfg = loader
489 .into_generic()
490 .await
491 .expect("should not fail to build generic configuration");
492
493 (cfg, maybe_sender)
494 }
495}
496
497#[cfg(any(test, feature = "test-util"))]
513pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
514 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
515 ENV_MUTEX.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
516}
517
518#[cfg(any(test, feature = "test-util"))]
530pub async fn config_from(file_values: serde_json::Value) -> GenericConfiguration {
531 let (config, _) = ConfigurationLoader::for_tests(Some(file_values), None, false).await;
532 config
533}
534
535fn build_figment_from_sources(sources: &[ProviderSource]) -> Figment {
536 sources.iter().fold(Figment::new(), |figment, source| match source {
537 ProviderSource::Static(p) => figment.admerge(p.clone()),
538 ProviderSource::Dynamic(_) => figment,
540 })
541}
542
543pub fn upsert(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
547 if !root.is_object() {
548 *root = serde_json::Value::Object(serde_json::Map::new());
549 }
550
551 let mut current = root;
552 let mut segments = key.split('.').peekable();
554
555 while let Some(seg) = segments.next() {
556 let is_leaf = segments.peek().is_none();
557
558 if !current.is_object() {
560 *current = serde_json::Value::Object(serde_json::Map::new());
561 }
562 let node = current.as_object_mut().expect("current node should be an object");
563
564 if is_leaf {
565 node.insert(seg.to_string(), value);
566 break;
567 } else {
568 let should_create_node = match node.get(seg) {
570 Some(v) => !v.is_object(),
571 None => true,
572 };
573 if should_create_node {
575 node.insert(seg.to_string(), serde_json::Value::Object(serde_json::Map::new()));
576 }
577
578 current = node.get_mut(seg).expect("should not fail to get nested object");
580 }
581 }
582}
583
584async fn run_dynamic_config_updater(
585 inner: Arc<Inner>, mut receiver: mpsc::Receiver<ConfigUpdate>, provider_sources: Vec<ProviderSource>,
586 sender: broadcast::Sender<ConfigChangeEvent>, ready_sender: oneshot::Sender<()>,
587) {
588 let initial_update = match receiver.recv().await {
590 Some(update) => update,
591 None => {
592 debug!("Dynamic configuration channel closed before initial snapshot.");
594 return;
595 }
596 };
597
598 let mut dynamic_state = match initial_update {
599 ConfigUpdate::Snapshot(state) => state,
600 ConfigUpdate::Partial { .. } => {
601 error!("First dynamic config message was not a snapshot. Updater may be in an inconsistent state.");
603 serde_json::Value::Null
604 }
605 };
606
607 let new_figment = provider_sources
609 .iter()
610 .fold(Figment::new(), |figment, source| match source {
611 ProviderSource::Static(p) => figment.admerge(p.clone()),
612 ProviderSource::Dynamic(_) => {
613 figment.admerge(figment::providers::Serialized::defaults(dynamic_state.clone()))
614 }
615 });
616
617 {
619 let mut figment_guard = inner.figment.write().unwrap();
620 *figment_guard = new_figment.clone();
621 }
622
623 if ready_sender.send(()).is_err() {
625 debug!("Configuration readiness receiver dropped. Updater task shutting down.");
626 return;
627 }
628
629 let mut current_config: figment::value::Value = new_figment.extract().unwrap();
631
632 loop {
634 let update = match receiver.recv().await {
635 Some(update) => update,
636 None => {
637 debug!("Dynamic configuration update channel closed. Updater task shutting down.");
639 return;
640 }
641 };
642
643 match update {
645 ConfigUpdate::Snapshot(new_state) => {
646 debug!("Received configuration snapshot update.");
647 dynamic_state = new_state;
648 }
649 ConfigUpdate::Partial { key, value } => {
650 debug!(%key, "Received partial configuration update.");
651 if dynamic_state.is_null() {
652 dynamic_state = serde_json::Value::Object(serde_json::Map::new());
653 }
654 if dynamic_state.is_object() {
655 upsert(&mut dynamic_state, &key, value);
656 } else {
657 error!(
658 "Received partial update but current dynamic state is not an object. This should not happen."
659 );
660 }
661 }
662 }
663
664 let new_figment = provider_sources
666 .iter()
667 .fold(Figment::new(), |figment, source| match source {
668 ProviderSource::Static(p) => figment.admerge(p.clone()),
669 ProviderSource::Dynamic(_) => {
670 figment.admerge(figment::providers::Serialized::defaults(dynamic_state.clone()))
671 }
672 });
673
674 let new_config: figment::value::Value = new_figment.clone().extract().unwrap();
675
676 if current_config != new_config {
677 let changes = dynamic::diff_config(¤t_config, &new_config);
678
679 {
680 let mut figment_guard = inner.figment.write().unwrap_or_else(|e| {
681 error!("Failed to acquire write lock for dynamic configuration: {}", e);
682 e.into_inner()
683 });
684 *figment_guard = new_figment;
685 }
686
687 for change in changes {
688 let _ = sender.send(change);
692 }
693
694 current_config = new_config;
696 }
697 }
698}
699
700#[derive(Debug)]
701struct Inner {
702 figment: RwLock<Figment>,
703 lookup_sources: HashSet<LookupSource>,
704 event_sender: Option<broadcast::Sender<ConfigChangeEvent>>,
705 ready_signal: Mutex<Option<oneshot::Receiver<()>>>,
706}
707
708#[derive(Clone, Debug)]
730pub struct GenericConfiguration {
731 inner: Arc<Inner>,
732}
733
734impl GenericConfiguration {
735 pub async fn ready(&self) {
742 let mut maybe_ready_rx = self.inner.ready_signal.lock().await;
745 if let Some(ready_rx) = maybe_ready_rx.take() {
746 saluki_antithesis::reachable!("config readiness wait entered");
751
752 let ready_result = ready_rx.await;
753
754 if ready_result.is_err() {
755 saluki_antithesis::unreachable!(
756 "config readiness sender dropped before signalling — updater task may have panicked"
757 );
758 error!("Failed to receive configuration readiness signal; updater task may have panicked.");
759 } else {
760 saluki_antithesis::sometimes!(true, "config readiness signal received");
761 }
762 }
763 }
764
765 fn get<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
766 where
767 T: Deserialize<'a>,
768 {
769 let figment_guard = self.inner.figment.read().unwrap();
770 match figment_guard.extract_inner(key) {
771 Ok(value) => Ok(value),
772 Err(e) => {
773 if matches!(e.kind, figment::error::Kind::MissingField(_)) {
774 let fallback_key = key.replace('.', "_");
779 figment_guard
780 .extract_inner(&fallback_key)
781 .map_err(|fallback_e| from_figment_error(&self.inner.lookup_sources, fallback_e))
782 } else {
783 Err(e.into())
784 }
785 }
786 }
787 }
788
789 pub fn get_typed<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
798 where
799 T: Deserialize<'a>,
800 {
801 self.get(key)
802 }
803
804 pub fn get_typed_or_default<'a, T>(&self, key: &str) -> T
811 where
812 T: Default + Deserialize<'a>,
813 {
814 self.get(key).unwrap_or_default()
815 }
816
817 pub fn try_get_typed<'a, T>(&self, key: &str) -> Result<Option<T>, ConfigurationError>
828 where
829 T: Deserialize<'a>,
830 {
831 match self.get(key) {
832 Ok(value) => Ok(Some(value)),
833 Err(ConfigurationError::MissingField { .. }) => Ok(None),
834 Err(e) => Err(e),
835 }
836 }
837
838 pub fn as_typed<'a, T>(&self) -> Result<T, ConfigurationError>
844 where
845 T: Deserialize<'a>,
846 {
847 self.inner
848 .figment
849 .read()
850 .unwrap()
851 .extract()
852 .map_err(|e| from_figment_error(&self.inner.lookup_sources, e))
853 }
854
855 pub fn subscribe_for_updates(&self) -> Option<broadcast::Receiver<dynamic::ConfigChangeEvent>> {
857 self.inner.event_sender.as_ref().map(|s| s.subscribe())
858 }
859
860 pub fn flattened_keys(&self) -> Result<Vec<(String, serde_json::Value)>, ConfigurationError> {
870 let root: serde_json::Value = self.as_typed()?;
871 let mut out = Vec::new();
872 flatten_value(&root, &mut String::new(), &mut out);
873 Ok(out)
874 }
875
876 pub fn watch_for_updates(&self, key: &str) -> FieldUpdateWatcher {
881 FieldUpdateWatcher {
882 key: key.to_string(),
883 rx: self.subscribe_for_updates(),
884 }
885 }
886}
887
888fn flatten_value(value: &serde_json::Value, prefix: &mut String, out: &mut Vec<(String, serde_json::Value)>) {
890 if let serde_json::Value::Object(map) = value {
891 for (key, child) in map {
892 let prev_len = prefix.len();
893 if !prefix.is_empty() {
894 prefix.push('.');
895 }
896 prefix.push_str(key);
897 flatten_value(child, prefix, out);
898 prefix.truncate(prev_len);
899 }
900 } else {
901 out.push((prefix.clone(), value.clone()));
902 }
903}
904
905fn from_figment_error(lookup_sources: &HashSet<LookupSource>, e: figment::Error) -> ConfigurationError {
906 match e.kind {
907 Kind::MissingField(field) => {
908 let mut valid_keys = lookup_sources
909 .iter()
910 .map(|source| source.transform_key(&field))
911 .collect::<Vec<_>>();
912
913 valid_keys.insert(0, field.to_string());
915
916 let help_text = format!("Try setting `{}`.", valid_keys.join("` or `"));
917
918 ConfigurationError::MissingField { help_text, field }
919 }
920 Kind::InvalidType(actual_ty, expected_ty) => ConfigurationError::InvalidFieldType {
921 field: e.path.join("."),
922 expected_ty,
923 actual_ty: actual_ty.to_string(),
924 },
925 _ => ConfigurationError::Generic { source: e.into() },
926 }
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932
933 #[tokio::test]
934 async fn static_configuration() {
935 let (cfg, _) = ConfigurationLoader::for_tests(
936 Some(serde_json::json!({
937 "foo": "bar",
938 "baz": 5,
939 "foobar": { "a": false, "b": "c" }
940 })),
941 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
942 false,
943 )
944 .await;
945 cfg.ready().await;
946
947 assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
948 assert_eq!(cfg.get_typed::<i32>("baz").unwrap(), 5);
949 assert!(!cfg.get_typed::<bool>("foobar.a").unwrap());
950 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
951 assert!(matches!(
952 cfg.get::<String>("nonexistentKey"),
953 Err(ConfigurationError::MissingField { .. })
954 ));
955 }
956
957 #[tokio::test]
958 async fn dynamic_configuration() {
959 let (cfg, sender) = ConfigurationLoader::for_tests(
960 Some(serde_json::json!({
961 "foo": "bar",
962 "baz": 5,
963 "foobar": { "a": false, "b": "c" }
964 })),
965 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
966 true,
967 )
968 .await;
969 let sender = sender.expect("sender should exist");
970 sender
971 .send(ConfigUpdate::Snapshot(serde_json::json!({
972 "new": "from_snapshot",
973 })))
974 .await
975 .unwrap();
976
977 cfg.ready().await;
978
979 assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
981
982 assert_eq!(cfg.get_typed::<String>("new").unwrap(), "from_snapshot");
984
985 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
986
987 sender
988 .send(ConfigUpdate::Partial {
989 key: "new_key".to_string(),
990 value: "from dynamic update".to_string().into(),
991 })
992 .await
993 .unwrap();
994
995 tokio::time::timeout(std::time::Duration::from_secs(2), async {
996 loop {
997 match rx.recv().await {
998 Ok(ev) if ev.key == "new_key" => break ev,
999 Err(e) => panic!("updates channel closed: {e}"),
1000 Ok(_) => continue,
1001 }
1002 }
1003 })
1004 .await
1005 .expect("timed out waiting for new_key update");
1006
1007 assert_eq!(cfg.get_typed::<String>("new_key").unwrap(), "from dynamic update");
1008
1009 sender
1011 .send(ConfigUpdate::Partial {
1012 key: "foobar.a".to_string(),
1013 value: serde_json::json!(true),
1014 })
1015 .await
1016 .unwrap();
1017
1018 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1019 loop {
1020 match rx.recv().await {
1021 Ok(ev) if ev.key == "foobar.a" => break ev,
1022 Err(e) => panic!("updates channel closed: {e}"),
1023 Ok(_) => continue,
1024 }
1025 }
1026 })
1027 .await
1028 .expect("timed out waiting for foobar.a update");
1029
1030 assert!(cfg.get_typed::<bool>("foobar.a").unwrap());
1031 assert_eq!(cfg.get_typed::<String>("foobar.b").unwrap(), "c");
1032 }
1033
1034 #[test]
1035 fn update_events_are_sent_after_the_figment_map_is_updated() {
1036 fn recv_with_timeout(
1037 rx: &mut tokio::sync::broadcast::Receiver<ConfigChangeEvent>, timeout: std::time::Duration,
1038 ) -> Option<ConfigChangeEvent> {
1039 let deadline = std::time::Instant::now() + timeout;
1040 loop {
1041 match rx.try_recv() {
1042 Ok(event) => return Some(event),
1043 Err(tokio::sync::broadcast::error::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
1044 std::thread::sleep(std::time::Duration::from_millis(1));
1045 }
1046 Err(tokio::sync::broadcast::error::TryRecvError::Empty) => return None,
1047 Err(e) => panic!("updates channel failed: {e}"),
1048 }
1049 }
1050 }
1051
1052 let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
1053 let (cfg, sender) = runtime.block_on(async {
1054 let (cfg, sender) = ConfigurationLoader::for_tests(None, None, true).await;
1055 let sender = sender.expect("sender should exist");
1056 sender
1057 .send(ConfigUpdate::Snapshot(serde_json::json!({ "observed": "old" })))
1058 .await
1059 .unwrap();
1060 cfg.ready().await;
1061 (cfg, sender)
1062 });
1063
1064 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1065 let (started_tx, started_rx) = std::sync::mpsc::channel();
1066 let runtime_thread = std::thread::spawn(move || {
1067 runtime.block_on(async {
1068 started_tx.send(()).unwrap();
1069 let _ = stop_rx.await;
1070 });
1071 });
1072 started_rx.recv().unwrap();
1073
1074 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1075 let figment_guard = cfg.inner.figment.read().unwrap();
1076
1077 sender
1078 .blocking_send(ConfigUpdate::Partial {
1079 key: "observed".to_string(),
1080 value: serde_json::json!("new"),
1081 })
1082 .unwrap();
1083
1084 let early_event = recv_with_timeout(&mut rx, std::time::Duration::from_millis(100));
1085 assert!(
1086 early_event.is_none(),
1087 "update event arrived before the figment map changed"
1088 );
1089
1090 drop(figment_guard);
1091
1092 let event = recv_with_timeout(&mut rx, std::time::Duration::from_secs(2))
1093 .expect("timed out waiting for observed update");
1094 assert_eq!(event.key, "observed");
1095 assert_eq!(cfg.get_typed::<String>("observed").unwrap(), "new");
1096
1097 let _ = stop_tx.send(());
1098 runtime_thread.join().unwrap();
1099 }
1100
1101 #[tokio::test]
1102 async fn environment_precedence_over_dynamic() {
1103 let (cfg, sender) = ConfigurationLoader::for_tests(
1104 Some(serde_json::json!({
1105 "foo": "bar",
1106 "baz": 5,
1107 "foobar": { "a": false, "b": "c" }
1108 })),
1109 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
1110 true,
1111 )
1112 .await;
1113 let sender = sender.expect("sender should exist");
1114
1115 sender
1116 .send(ConfigUpdate::Snapshot(serde_json::json!({
1117 "env_var": "from_snapshot_env_var"
1118 })))
1119 .await
1120 .unwrap();
1121
1122 cfg.ready().await;
1123
1124 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1126
1127 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1128
1129 sender
1131 .send(ConfigUpdate::Partial {
1132 key: "env_var".to_string(),
1133 value: serde_json::json!("from_partial"),
1134 })
1135 .await
1136 .unwrap();
1137
1138 sender
1140 .send(ConfigUpdate::Partial {
1141 key: "foobar.a".to_string(),
1142 value: serde_json::json!(false),
1143 })
1144 .await
1145 .unwrap();
1146
1147 sender
1149 .send(ConfigUpdate::Partial {
1150 key: "dummy".to_string(),
1151 value: serde_json::json!(1),
1152 })
1153 .await
1154 .unwrap();
1155
1156 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1157 loop {
1158 match rx.recv().await {
1159 Ok(ev) if ev.key == "dummy" => break,
1160 Err(e) => panic!("updates channel closed: {e}"),
1161 Ok(_) => continue,
1162 }
1163 }
1164 })
1165 .await
1166 .expect("timed out waiting for sync marker");
1167
1168 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1169 }
1170
1171 #[tokio::test]
1172 async fn dynamic_configuration_add_new_nested_key() {
1173 let (cfg, sender) = ConfigurationLoader::for_tests(
1174 Some(serde_json::json!({
1175 "foo": "bar",
1176 "baz": 5,
1177 "foobar": { "a": false, "b": "c" }
1178 })),
1179 None,
1180 true,
1181 )
1182 .await;
1183 let sender = sender.expect("sender should exist");
1184
1185 sender
1186 .send(ConfigUpdate::Snapshot(serde_json::json!({})))
1187 .await
1188 .unwrap();
1189 cfg.ready().await;
1190
1191 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1192
1193 sender
1194 .send(ConfigUpdate::Partial {
1195 key: "new_parent.new_child".to_string(),
1196 value: serde_json::json!(42),
1197 })
1198 .await
1199 .unwrap();
1200
1201 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1203 loop {
1204 match rx.recv().await {
1205 Ok(ev) if ev.key == "new_parent" => break ev,
1206 Err(e) => panic!("updates channel closed: {e}"),
1207 Ok(_) => continue,
1208 }
1209 }
1210 })
1211 .await
1212 .expect("timed out waiting for new_parent.new_child update");
1213
1214 assert_eq!(cfg.get_typed::<i32>("new_parent.new_child").unwrap(), 42);
1215 }
1216
1217 #[tokio::test]
1218 async fn underscore_fallback_on_get() {
1219 let (cfg, _) = ConfigurationLoader::for_tests(
1220 Some(serde_json::json!({})),
1221 Some(&[("RANDOM_KEY".to_string(), "from_env_only".to_string())]),
1222 false,
1223 )
1224 .await;
1225 cfg.ready().await;
1226
1227 assert_eq!(cfg.get_typed::<String>("random.key").unwrap(), "from_env_only");
1228 }
1229
1230 #[tokio::test]
1231 async fn underscore_fallback_on_get_multi_segment_key() {
1232 let (cfg, _) = ConfigurationLoader::for_tests(
1237 Some(serde_json::json!({})),
1238 Some(&[(
1239 "DATA_PLANE_API_LISTEN_ADDRESS".to_string(),
1240 "tcp://0.0.0.0:55100".to_string(),
1241 )]),
1242 false,
1243 )
1244 .await;
1245 cfg.ready().await;
1246
1247 assert_eq!(
1248 cfg.try_get_typed::<String>("data_plane.api_listen_address").unwrap(),
1249 Some("tcp://0.0.0.0:55100".to_string()),
1250 );
1251 }
1252
1253 #[tokio::test]
1254 async fn static_configuration_ready_and_subscribe() {
1255 let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, false).await;
1256 assert!(maybe_sender.is_none());
1257
1258 tokio::time::timeout(std::time::Duration::from_millis(500), cfg.ready())
1259 .await
1260 .expect("ready() should not block when dynamic is disabled");
1261
1262 assert!(cfg.subscribe_for_updates().is_none());
1263 }
1264
1265 #[tokio::test]
1266 async fn dynamic_configuration_ready_requires_initial_snapshot() {
1267 let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, true).await;
1269 assert!(maybe_sender.is_some());
1270
1271 let res = tokio::time::timeout(std::time::Duration::from_millis(1000), cfg.ready()).await;
1273 assert!(res.is_err(), "ready() should time out without an initial snapshot");
1274 }
1275
1276 #[tokio::test]
1277 async fn flattened_keys_flat_and_nested() {
1278 let (cfg, _) = ConfigurationLoader::for_tests(
1279 Some(serde_json::json!({
1280 "top": "value",
1281 "nested": { "a": 1, "b": { "c": true } }
1282 })),
1283 None,
1284 false,
1285 )
1286 .await;
1287 cfg.ready().await;
1288
1289 let pairs = cfg.flattened_keys().unwrap();
1290 let map: std::collections::HashMap<&str, &serde_json::Value> =
1291 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1292
1293 assert_eq!(map.get("top"), Some(&&serde_json::json!("value")));
1294 assert_eq!(map.get("nested.a"), Some(&&serde_json::json!(1)));
1295 assert_eq!(map.get("nested.b.c"), Some(&&serde_json::json!(true)));
1296 assert!(!map.contains_key("nested"));
1297 assert!(!map.contains_key("nested.b"));
1298 }
1299
1300 #[tokio::test]
1301 async fn flattened_keys_arrays_are_leaves() {
1302 let (cfg, _) = ConfigurationLoader::for_tests(
1303 Some(serde_json::json!({
1304 "tags": ["a", "b"],
1305 "matrix": [[1, 2], [3, 4]]
1306 })),
1307 None,
1308 false,
1309 )
1310 .await;
1311 cfg.ready().await;
1312
1313 let pairs = cfg.flattened_keys().unwrap();
1314 let map: std::collections::HashMap<&str, &serde_json::Value> =
1315 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1316
1317 assert_eq!(map.get("tags"), Some(&&serde_json::json!(["a", "b"])));
1318 assert_eq!(map.get("matrix"), Some(&&serde_json::json!([[1, 2], [3, 4]])));
1319 }
1320
1321 #[tokio::test]
1322 async fn flattened_keys_null_values_absent() {
1323 let (cfg, _) = ConfigurationLoader::for_tests(
1324 Some(serde_json::json!({
1325 "present": "yes",
1326 "absent": null
1327 })),
1328 None,
1329 false,
1330 )
1331 .await;
1332 cfg.ready().await;
1333
1334 let pairs = cfg.flattened_keys().unwrap();
1335 let map: std::collections::HashMap<&str, &serde_json::Value> =
1336 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1337
1338 assert_eq!(map.get("present"), Some(&&serde_json::json!("yes")));
1339 assert!(!map.contains_key("absent"));
1341 }
1342
1343 #[tokio::test]
1344 async fn from_yaml_loads_configuration_file() {
1345 use std::io::Write as _;
1346
1347 let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1348 file.write_all(b"top: value\nnested:\n inner: 7\n")
1349 .expect("should write temp file");
1350 file.flush().expect("should flush temp file");
1351
1352 let cfg = ConfigurationLoader::default()
1353 .from_yaml(file.path())
1354 .expect("YAML file should load")
1355 .into_generic()
1356 .await
1357 .expect("should build generic configuration");
1358
1359 assert_eq!(cfg.get_typed::<String>("top").unwrap(), "value");
1360 assert_eq!(cfg.get_typed::<i64>("nested.inner").unwrap(), 7);
1361 }
1362
1363 #[tokio::test]
1364 async fn try_from_yaml_ignores_unreadable_file() {
1365 let cfg = ConfigurationLoader::default()
1368 .try_from_yaml("/nonexistent/definitely/not/here.yaml")
1369 .into_generic()
1370 .await
1371 .expect("should build generic configuration even when the file is missing");
1372
1373 assert!(matches!(
1374 cfg.get::<String>("anything"),
1375 Err(ConfigurationError::MissingField { .. })
1376 ));
1377 }
1378
1379 #[tokio::test]
1380 async fn from_json_returns_error_for_invalid_file() {
1381 use std::io::Write as _;
1382
1383 let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1384 file.write_all(b"{ not valid json ").expect("should write temp file");
1385 file.flush().expect("should flush temp file");
1386
1387 let result = ConfigurationLoader::default().from_json(file.path());
1388 assert!(result.is_err(), "invalid JSON should fail to load at the loader level");
1389 }
1390}