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;
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::{settings_to_state, 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<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 lookup_sources: HashSet<LookupSource>,
154 provider_sources: Vec<ProviderSource>,
155}
156
157impl ConfigurationLoader {
158 pub fn add_providers<P, I>(mut self, providers: I) -> Self
168 where
169 P: Provider + Send + Sync + 'static,
170 I: IntoIterator<Item = P>,
171 {
172 for p in providers {
173 self.provider_sources
174 .push(ProviderSource::Static(ArcProvider(Arc::new(p))));
175 }
176 self
177 }
178
179 pub fn from_yaml<P>(mut self, path: P) -> Result<Self, ConfigurationError>
185 where
186 P: AsRef<std::path::Path>,
187 {
188 let resolved_provider = ResolvedProvider::from_yaml(&path)?;
189 self.provider_sources
190 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
191 Ok(self)
192 }
193
194 pub fn try_from_yaml<P>(mut self, path: P) -> Self
198 where
199 P: AsRef<std::path::Path>,
200 {
201 match ResolvedProvider::from_yaml(&path) {
202 Ok(resolved_provider) => {
203 self.provider_sources
204 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
205 }
206 Err(e) => {
207 println!(
208 "Unable to read YAML configuration file '{}': {}. Ignoring.",
209 path.as_ref().to_string_lossy(),
210 e
211 );
212 }
213 }
214 self
215 }
216
217 pub fn from_json<P>(mut self, path: P) -> Result<Self, ConfigurationError>
223 where
224 P: AsRef<std::path::Path>,
225 {
226 let resolved_provider = ResolvedProvider::from_json(&path)?;
227 self.provider_sources
228 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
229 Ok(self)
230 }
231
232 pub fn try_from_json<P>(mut self, path: P) -> Self
236 where
237 P: AsRef<std::path::Path>,
238 {
239 match ResolvedProvider::from_json(&path) {
240 Ok(resolved_provider) => {
241 self.provider_sources
242 .push(ProviderSource::Static(ArcProvider(Arc::new(resolved_provider))));
243 }
244 Err(e) => {
245 println!(
246 "Unable to read JSON configuration file '{}': {}. Ignoring.",
247 path.as_ref().to_string_lossy(),
248 e
249 );
250 }
251 }
252 self
253 }
254
255 pub fn from_environment(mut self, prefix: &'static str) -> Result<Self, ConfigurationError> {
268 if prefix.is_empty() {
269 return Err(ConfigurationError::EmptyPrefix);
270 }
271
272 let prefix = if prefix.ends_with('_') {
273 prefix.to_string()
274 } else {
275 format!("{}_", prefix)
276 };
277
278 let env = Env::prefixed(&prefix).split("__");
280 let values = env.data().unwrap();
281 if let Some(default_dict) = values.get(&figment::Profile::Default) {
282 self.provider_sources
283 .push(ProviderSource::Static(ArcProvider(Arc::new(Serialized::defaults(
284 default_dict.clone(),
285 )))));
286 self.lookup_sources.insert(LookupSource::Environment { prefix });
287 }
288 Ok(self)
289 }
290
291 pub fn with_dynamic_configuration(mut self, initial: ConfigUpdate) -> Self {
297 self.provider_sources.push(ProviderSource::Dynamic(Some(initial)));
298 self
299 }
300
301 pub fn into_typed<'a, T>(self) -> Result<T, ConfigurationError>
307 where
308 T: Deserialize<'a>,
309 {
310 let figment = build_figment_from_sources(&self.provider_sources);
311 figment.extract().map_err(Into::into)
312 }
313
314 pub async fn into_generic(mut self) -> Result<(GenericConfiguration, Option<ConfigUpdater>), ConfigurationError> {
324 let has_dynamic_provider = self
325 .provider_sources
326 .iter()
327 .any(|s| matches!(s, ProviderSource::Dynamic(_)));
328
329 if has_dynamic_provider {
330 let mut initial_opt = None;
331 for source in self.provider_sources.iter_mut() {
332 if let ProviderSource::Dynamic(ref mut initial) = source {
333 initial_opt = initial.take();
334 break;
335 }
336 }
337 let initial = initial_opt.expect("Dynamic initial update should exist but was not found");
338
339 let mut dynamic_state = serde_json::Value::Null;
342 apply_to_dynamic_state(&mut dynamic_state, initial);
343 let figment = build_figment_with_dynamic_state(&self.provider_sources, &dynamic_state);
344 let current_config = figment.extract()?;
345
346 let (event_sender, _) = broadcast::channel(100);
347
348 let generic_config = GenericConfiguration {
349 inner: Arc::new(Inner {
350 figment: RwLock::new(figment),
351 lookup_sources: self.lookup_sources,
352 event_sender: Some(event_sender.clone()),
353 }),
354 };
355
356 let updater = ConfigUpdater {
357 inner: generic_config.inner.clone(),
358 provider_sources: self.provider_sources,
359 sender: event_sender,
360 dynamic_state,
361 current_config,
362 };
363
364 Ok((generic_config, Some(updater)))
365 } else {
366 let figment = build_figment_from_sources(&self.provider_sources);
368
369 let generic_config = GenericConfiguration {
370 inner: Arc::new(Inner {
371 figment: RwLock::new(figment),
372 lookup_sources: self.lookup_sources,
373 event_sender: None,
374 }),
375 };
376
377 Ok((generic_config, None))
378 }
379 }
380
381 #[cfg(any(test, feature = "test-util"))]
395 pub async fn for_tests(
396 file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
397 initial_snapshot: Option<ConfigUpdate>,
398 ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>) {
399 Self::for_tests_with_provider_factory(file_values, env_vars, initial_snapshot, |_| {
400 Serialized::defaults(serde_json::json!({}))
401 })
402 .await
403 }
404
405 #[cfg(any(test, feature = "test-util"))]
414 pub async fn for_tests_with_provider_factory<P, F>(
415 file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
416 initial_snapshot: Option<ConfigUpdate>, provider_factory: F,
417 ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>)
418 where
419 P: Provider + Send + Sync + 'static,
420 F: FnOnce(Vec<(String, String)>) -> P,
421 {
422 let json_file = tempfile::NamedTempFile::new().expect("should not fail to create temp file.");
423 let path = &json_file.path();
424 let json_to_write = file_values.unwrap_or(serde_json::json!({}));
425 serde_json::to_writer(&json_file, &json_to_write).expect("should not fail to write to temp file.");
426
427 let mut loader = ConfigurationLoader::default().try_from_json(path);
428 if let Some(initial_snapshot) = initial_snapshot {
429 loader = loader.with_dynamic_configuration(initial_snapshot);
430 }
431
432 let guard = test_env_lock();
436
437 if let Some(pairs) = env_vars.as_ref() {
438 for (k, v) in pairs.iter() {
439 std::env::set_var(k, v);
443 std::env::set_var(format!("TEST_{}", k), v);
444 }
445 }
446
447 let provider_env_vars = env_vars.unwrap_or_default().to_vec();
449 let loader = loader.add_providers([provider_factory(provider_env_vars)]);
450
451 let loader = loader
453 .from_environment("TEST")
454 .expect("should not fail to add environment provider");
455
456 if let Some(pairs) = env_vars.as_ref() {
458 for (k, _) in pairs.iter() {
459 std::env::remove_var(k);
460 std::env::remove_var(format!("TEST_{}", k));
461 }
462 }
463
464 drop(guard);
465
466 let (cfg, updater) = loader
467 .into_generic()
468 .await
469 .expect("should not fail to build generic configuration");
470 let maybe_sender = updater.map(|mut updater| {
471 let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
472 tokio::spawn(async move {
473 while let Some(update) = receiver.recv().await {
474 updater
475 .apply(update)
476 .expect("should not fail to apply dynamic configuration update");
477 }
478 });
479 sender
480 });
481
482 (cfg, maybe_sender)
483 }
484}
485
486#[cfg(any(test, feature = "test-util"))]
502pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
503 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
504 ENV_MUTEX.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
505}
506
507#[cfg(any(test, feature = "test-util"))]
519pub async fn config_from(file_values: serde_json::Value) -> GenericConfiguration {
520 let (config, _) = ConfigurationLoader::for_tests(Some(file_values), None, None).await;
521 config
522}
523
524fn build_figment_from_sources(sources: &[ProviderSource]) -> Figment {
525 sources.iter().fold(Figment::new(), |figment, source| match source {
526 ProviderSource::Static(p) => figment.admerge(p.clone()),
527 ProviderSource::Dynamic(_) => figment,
529 })
530}
531
532pub fn upsert(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
536 if !root.is_object() {
537 *root = serde_json::Value::Object(serde_json::Map::new());
538 }
539
540 let mut current = root;
541 let mut segments = key.split('.').peekable();
543
544 while let Some(seg) = segments.next() {
545 let is_leaf = segments.peek().is_none();
546
547 if !current.is_object() {
549 *current = serde_json::Value::Object(serde_json::Map::new());
550 }
551 let node = current.as_object_mut().expect("current node should be an object");
552
553 if is_leaf {
554 node.insert(seg.to_string(), value);
555 break;
556 } else {
557 let should_create_node = match node.get(seg) {
559 Some(v) => !v.is_object(),
560 None => true,
561 };
562 if should_create_node {
564 node.insert(seg.to_string(), serde_json::Value::Object(serde_json::Map::new()));
565 }
566
567 current = node.get_mut(seg).expect("should not fail to get nested object");
569 }
570 }
571}
572
573pub struct ConfigUpdater {
579 inner: Arc<Inner>,
580 provider_sources: Vec<ProviderSource>,
581 sender: broadcast::Sender<ConfigChangeEvent>,
582 dynamic_state: serde_json::Value,
583 current_config: figment::value::Value,
584}
585
586impl ConfigUpdater {
587 pub fn apply(&mut self, update: ConfigUpdate) -> Result<(), ConfigurationError> {
598 apply_to_dynamic_state(&mut self.dynamic_state, update);
599
600 let new_figment = build_figment_with_dynamic_state(&self.provider_sources, &self.dynamic_state);
602 let new_config: figment::value::Value = new_figment.extract()?;
603
604 if self.current_config != new_config {
605 let changes = dynamic::diff_config(&self.current_config, &new_config);
606
607 {
608 let mut figment_guard = self.inner.figment.write().unwrap_or_else(|e| {
609 error!("Failed to acquire write lock for dynamic configuration: {}", e);
610 e.into_inner()
611 });
612 *figment_guard = new_figment;
613 }
614
615 for change in changes {
616 let _ = self.sender.send(change);
620 }
621
622 self.current_config = new_config;
624 }
625
626 Ok(())
627 }
628}
629
630fn apply_to_dynamic_state(dynamic_state: &mut serde_json::Value, update: ConfigUpdate) {
632 match update {
635 ConfigUpdate::Snapshot(settings) => {
636 debug!("Received configuration snapshot update.");
637 *dynamic_state = settings_to_state(&settings);
638 }
639 ConfigUpdate::Partial(setting) => {
640 debug!(key = %setting.key, "Received partial configuration update.");
641 if dynamic_state.is_null() {
642 *dynamic_state = serde_json::Value::Object(serde_json::Map::new());
643 }
644 if dynamic_state.is_object() {
645 upsert(dynamic_state, &setting.key, setting.value);
646 } else {
647 error!("Received partial update but current dynamic state is not an object. This should not happen.");
648 }
649 }
650 }
651}
652
653fn build_figment_with_dynamic_state(sources: &[ProviderSource], dynamic_state: &serde_json::Value) -> Figment {
655 sources.iter().fold(Figment::new(), |figment, source| match source {
656 ProviderSource::Static(p) => figment.admerge(p.clone()),
657 ProviderSource::Dynamic(_) => figment.admerge(Serialized::defaults(dynamic_state.clone())),
658 })
659}
660
661#[derive(Debug)]
662struct Inner {
663 figment: RwLock<Figment>,
664 lookup_sources: HashSet<LookupSource>,
665 event_sender: Option<broadcast::Sender<ConfigChangeEvent>>,
666}
667
668#[derive(Clone, Debug)]
690pub struct GenericConfiguration {
691 inner: Arc<Inner>,
692}
693
694impl GenericConfiguration {
695 fn get<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
696 where
697 T: Deserialize<'a>,
698 {
699 let figment_guard = self.inner.figment.read().unwrap();
700 match figment_guard.extract_inner(key) {
701 Ok(value) => Ok(value),
702 Err(e) => {
703 if matches!(e.kind, figment::error::Kind::MissingField(_)) {
704 let fallback_key = key.replace('.', "_");
709 figment_guard
710 .extract_inner(&fallback_key)
711 .map_err(|fallback_e| from_figment_error(&self.inner.lookup_sources, fallback_e))
712 } else {
713 Err(e.into())
714 }
715 }
716 }
717 }
718
719 pub fn get_typed<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
728 where
729 T: Deserialize<'a>,
730 {
731 self.get(key)
732 }
733
734 pub fn get_typed_or_default<'a, T>(&self, key: &str) -> T
741 where
742 T: Default + Deserialize<'a>,
743 {
744 self.get(key).unwrap_or_default()
745 }
746
747 pub fn try_get_typed<'a, T>(&self, key: &str) -> Result<Option<T>, ConfigurationError>
758 where
759 T: Deserialize<'a>,
760 {
761 match self.get(key) {
762 Ok(value) => Ok(Some(value)),
763 Err(ConfigurationError::MissingField { .. }) => Ok(None),
764 Err(e) => Err(e),
765 }
766 }
767
768 pub fn as_typed<'a, T>(&self) -> Result<T, ConfigurationError>
774 where
775 T: Deserialize<'a>,
776 {
777 self.inner
778 .figment
779 .read()
780 .unwrap()
781 .extract()
782 .map_err(|e| from_figment_error(&self.inner.lookup_sources, e))
783 }
784
785 pub fn subscribe_for_updates(&self) -> Option<broadcast::Receiver<dynamic::ConfigChangeEvent>> {
787 self.inner.event_sender.as_ref().map(|s| s.subscribe())
788 }
789
790 pub fn flattened_keys(&self) -> Result<Vec<(String, serde_json::Value)>, ConfigurationError> {
800 let root: serde_json::Value = self.as_typed()?;
801 let mut out = Vec::new();
802 flatten_value(&root, &mut String::new(), &mut out);
803 Ok(out)
804 }
805
806 pub fn watch_for_updates(&self, key: &str) -> FieldUpdateWatcher {
811 FieldUpdateWatcher {
812 key: key.to_string(),
813 rx: self.subscribe_for_updates(),
814 }
815 }
816}
817
818fn flatten_value(value: &serde_json::Value, prefix: &mut String, out: &mut Vec<(String, serde_json::Value)>) {
820 if let serde_json::Value::Object(map) = value {
821 for (key, child) in map {
822 let prev_len = prefix.len();
823 if !prefix.is_empty() {
824 prefix.push('.');
825 }
826 prefix.push_str(key);
827 flatten_value(child, prefix, out);
828 prefix.truncate(prev_len);
829 }
830 } else {
831 out.push((prefix.clone(), value.clone()));
832 }
833}
834
835fn from_figment_error(lookup_sources: &HashSet<LookupSource>, e: figment::Error) -> ConfigurationError {
836 match e.kind {
837 Kind::MissingField(field) => {
838 let mut valid_keys = lookup_sources
839 .iter()
840 .map(|source| source.transform_key(&field))
841 .collect::<Vec<_>>();
842
843 valid_keys.insert(0, field.to_string());
845
846 let help_text = format!("Try setting `{}`.", valid_keys.join("` or `"));
847
848 ConfigurationError::MissingField { help_text, field }
849 }
850 Kind::InvalidType(actual_ty, expected_ty) => ConfigurationError::InvalidFieldType {
851 field: e.path.join("."),
852 expected_ty,
853 actual_ty: actual_ty.to_string(),
854 },
855 _ => ConfigurationError::Generic { source: e.into() },
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::dynamic::ConfigSetting;
862 use super::*;
863
864 #[tokio::test]
865 async fn static_configuration() {
866 let (cfg, _) = ConfigurationLoader::for_tests(
867 Some(serde_json::json!({
868 "foo": "bar",
869 "baz": 5,
870 "foobar": { "a": false, "b": "c" }
871 })),
872 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
873 None,
874 )
875 .await;
876
877 assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
878 assert_eq!(cfg.get_typed::<i32>("baz").unwrap(), 5);
879 assert!(!cfg.get_typed::<bool>("foobar.a").unwrap());
880 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
881 assert!(matches!(
882 cfg.get::<String>("nonexistentKey"),
883 Err(ConfigurationError::MissingField { .. })
884 ));
885 }
886
887 #[tokio::test]
888 async fn dynamic_configuration() {
889 let (cfg, sender) = ConfigurationLoader::for_tests(
890 Some(serde_json::json!({
891 "foo": "bar",
892 "baz": 5,
893 "foobar": { "a": false, "b": "c" }
894 })),
895 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
896 Some(ConfigUpdate::snapshot([ConfigSetting::explicit(
897 "new",
898 serde_json::json!("from_snapshot"),
899 )])),
900 )
901 .await;
902 let sender = sender.expect("sender should exist");
903
904 assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
906
907 assert_eq!(cfg.get_typed::<String>("new").unwrap(), "from_snapshot");
909
910 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
911
912 sender
913 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
914 "new_key",
915 "from dynamic update".to_string().into(),
916 )))
917 .await
918 .unwrap();
919
920 tokio::time::timeout(std::time::Duration::from_secs(2), async {
921 loop {
922 match rx.recv().await {
923 Ok(ev) if ev.key == "new_key" => break ev,
924 Err(e) => panic!("updates channel closed: {e}"),
925 Ok(_) => continue,
926 }
927 }
928 })
929 .await
930 .expect("timed out waiting for new_key update");
931
932 assert_eq!(cfg.get_typed::<String>("new_key").unwrap(), "from dynamic update");
933
934 sender
936 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
937 "foobar.a",
938 serde_json::json!(true),
939 )))
940 .await
941 .unwrap();
942
943 tokio::time::timeout(std::time::Duration::from_secs(2), async {
944 loop {
945 match rx.recv().await {
946 Ok(ev) if ev.key == "foobar.a" => break ev,
947 Err(e) => panic!("updates channel closed: {e}"),
948 Ok(_) => continue,
949 }
950 }
951 })
952 .await
953 .expect("timed out waiting for foobar.a update");
954
955 assert!(cfg.get_typed::<bool>("foobar.a").unwrap());
956 assert_eq!(cfg.get_typed::<String>("foobar.b").unwrap(), "c");
957 }
958
959 #[test]
960 fn update_events_are_sent_after_the_figment_map_is_updated() {
961 fn recv_with_timeout(
962 rx: &mut tokio::sync::broadcast::Receiver<ConfigChangeEvent>, timeout: std::time::Duration,
963 ) -> Option<ConfigChangeEvent> {
964 let deadline = std::time::Instant::now() + timeout;
965 loop {
966 match rx.try_recv() {
967 Ok(event) => return Some(event),
968 Err(tokio::sync::broadcast::error::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
969 std::thread::sleep(std::time::Duration::from_millis(1));
970 }
971 Err(tokio::sync::broadcast::error::TryRecvError::Empty) => return None,
972 Err(e) => panic!("updates channel failed: {e}"),
973 }
974 }
975 }
976
977 let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
978 let (cfg, sender) = runtime.block_on(async {
979 let (cfg, sender) = ConfigurationLoader::for_tests(
980 None,
981 None,
982 Some(ConfigUpdate::snapshot([ConfigSetting::explicit(
983 "observed",
984 serde_json::json!("old"),
985 )])),
986 )
987 .await;
988 (cfg, sender.expect("sender should exist"))
989 });
990
991 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
992 let (started_tx, started_rx) = std::sync::mpsc::channel();
993 let runtime_thread = std::thread::spawn(move || {
994 runtime.block_on(async {
995 started_tx.send(()).unwrap();
996 let _ = stop_rx.await;
997 });
998 });
999 started_rx.recv().unwrap();
1000
1001 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1002 let figment_guard = cfg.inner.figment.read().unwrap();
1003
1004 sender
1005 .blocking_send(ConfigUpdate::Partial(ConfigSetting::explicit(
1006 "observed",
1007 serde_json::json!("new"),
1008 )))
1009 .unwrap();
1010
1011 let early_event = recv_with_timeout(&mut rx, std::time::Duration::from_millis(100));
1012 assert!(
1013 early_event.is_none(),
1014 "update event arrived before the figment map changed"
1015 );
1016
1017 drop(figment_guard);
1018
1019 let event = recv_with_timeout(&mut rx, std::time::Duration::from_secs(2))
1020 .expect("timed out waiting for observed update");
1021 assert_eq!(event.key, "observed");
1022 assert_eq!(cfg.get_typed::<String>("observed").unwrap(), "new");
1023
1024 let _ = stop_tx.send(());
1025 runtime_thread.join().unwrap();
1026 }
1027
1028 #[tokio::test]
1029 async fn environment_precedence_over_dynamic() {
1030 let (cfg, sender) = ConfigurationLoader::for_tests(
1031 Some(serde_json::json!({
1032 "foo": "bar",
1033 "baz": 5,
1034 "foobar": { "a": false, "b": "c" }
1035 })),
1036 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
1037 Some(ConfigUpdate::snapshot([ConfigSetting::explicit(
1038 "env_var",
1039 serde_json::json!("from_snapshot_env_var"),
1040 )])),
1041 )
1042 .await;
1043 let sender = sender.expect("sender should exist");
1044
1045 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1047
1048 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1049
1050 sender
1052 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1053 "env_var",
1054 serde_json::json!("from_partial"),
1055 )))
1056 .await
1057 .unwrap();
1058
1059 sender
1061 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1062 "foobar.a",
1063 serde_json::json!(false),
1064 )))
1065 .await
1066 .unwrap();
1067
1068 sender
1070 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1071 "dummy",
1072 serde_json::json!(1),
1073 )))
1074 .await
1075 .unwrap();
1076
1077 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1078 loop {
1079 match rx.recv().await {
1080 Ok(ev) if ev.key == "dummy" => break,
1081 Err(e) => panic!("updates channel closed: {e}"),
1082 Ok(_) => continue,
1083 }
1084 }
1085 })
1086 .await
1087 .expect("timed out waiting for sync marker");
1088
1089 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1090 }
1091
1092 #[tokio::test]
1093 async fn dynamic_configuration_add_new_nested_key() {
1094 let (cfg, sender) = ConfigurationLoader::for_tests(
1095 Some(serde_json::json!({
1096 "foo": "bar",
1097 "baz": 5,
1098 "foobar": { "a": false, "b": "c" }
1099 })),
1100 None,
1101 Some(ConfigUpdate::snapshot([])),
1102 )
1103 .await;
1104 let sender = sender.expect("sender should exist");
1105
1106 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1107
1108 sender
1109 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1110 "new_parent.new_child",
1111 serde_json::json!(42),
1112 )))
1113 .await
1114 .unwrap();
1115
1116 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1118 loop {
1119 match rx.recv().await {
1120 Ok(ev) if ev.key == "new_parent" => break ev,
1121 Err(e) => panic!("updates channel closed: {e}"),
1122 Ok(_) => continue,
1123 }
1124 }
1125 })
1126 .await
1127 .expect("timed out waiting for new_parent.new_child update");
1128
1129 assert_eq!(cfg.get_typed::<i32>("new_parent.new_child").unwrap(), 42);
1130 }
1131
1132 #[tokio::test]
1133 async fn underscore_fallback_on_get() {
1134 let (cfg, _) = ConfigurationLoader::for_tests(
1135 Some(serde_json::json!({})),
1136 Some(&[("RANDOM_KEY".to_string(), "from_env_only".to_string())]),
1137 None,
1138 )
1139 .await;
1140
1141 assert_eq!(cfg.get_typed::<String>("random.key").unwrap(), "from_env_only");
1142 }
1143
1144 #[tokio::test]
1145 async fn underscore_fallback_on_get_multi_segment_key() {
1146 let (cfg, _) = ConfigurationLoader::for_tests(
1151 Some(serde_json::json!({})),
1152 Some(&[(
1153 "DATA_PLANE_API_LISTEN_ADDRESS".to_string(),
1154 "tcp://0.0.0.0:55100".to_string(),
1155 )]),
1156 None,
1157 )
1158 .await;
1159
1160 assert_eq!(
1161 cfg.try_get_typed::<String>("data_plane.api_listen_address").unwrap(),
1162 Some("tcp://0.0.0.0:55100".to_string()),
1163 );
1164 }
1165
1166 #[tokio::test]
1167 async fn static_configuration_has_no_updates() {
1168 let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, None).await;
1169 assert!(maybe_sender.is_none());
1170 assert!(cfg.subscribe_for_updates().is_none());
1171 }
1172
1173 #[tokio::test]
1174 async fn updater_applies_each_update_before_returning() {
1175 let (cfg, updater) = ConfigurationLoader::default()
1176 .with_dynamic_configuration(ConfigUpdate::snapshot([ConfigSetting::explicit(
1177 "key",
1178 serde_json::json!("from_snapshot"),
1179 )]))
1180 .into_generic()
1181 .await
1182 .expect("should not fail to build generic configuration");
1183 let mut updater = updater.expect("dynamic configuration should return an updater");
1184 assert_eq!(cfg.get_typed::<String>("key").unwrap(), "from_snapshot");
1185
1186 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1187 updater
1188 .apply(ConfigUpdate::Partial(ConfigSetting::explicit(
1189 "key",
1190 serde_json::json!("from_update"),
1191 )))
1192 .expect("should not fail to apply the update");
1193
1194 assert_eq!(cfg.get_typed::<String>("key").unwrap(), "from_update");
1196 let event = rx.try_recv().expect("the change event should already be sent");
1197 assert_eq!(event.key, "key");
1198 }
1199
1200 #[tokio::test]
1201 async fn flattened_keys_flat_and_nested() {
1202 let (cfg, _) = ConfigurationLoader::for_tests(
1203 Some(serde_json::json!({
1204 "top": "value",
1205 "nested": { "a": 1, "b": { "c": true } }
1206 })),
1207 None,
1208 None,
1209 )
1210 .await;
1211
1212 let pairs = cfg.flattened_keys().unwrap();
1213 let map: std::collections::HashMap<&str, &serde_json::Value> =
1214 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1215
1216 assert_eq!(map.get("top"), Some(&&serde_json::json!("value")));
1217 assert_eq!(map.get("nested.a"), Some(&&serde_json::json!(1)));
1218 assert_eq!(map.get("nested.b.c"), Some(&&serde_json::json!(true)));
1219 assert!(!map.contains_key("nested"));
1220 assert!(!map.contains_key("nested.b"));
1221 }
1222
1223 #[tokio::test]
1224 async fn flattened_keys_arrays_are_leaves() {
1225 let (cfg, _) = ConfigurationLoader::for_tests(
1226 Some(serde_json::json!({
1227 "tags": ["a", "b"],
1228 "matrix": [[1, 2], [3, 4]]
1229 })),
1230 None,
1231 None,
1232 )
1233 .await;
1234
1235 let pairs = cfg.flattened_keys().unwrap();
1236 let map: std::collections::HashMap<&str, &serde_json::Value> =
1237 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1238
1239 assert_eq!(map.get("tags"), Some(&&serde_json::json!(["a", "b"])));
1240 assert_eq!(map.get("matrix"), Some(&&serde_json::json!([[1, 2], [3, 4]])));
1241 }
1242
1243 #[tokio::test]
1244 async fn flattened_keys_null_values_absent() {
1245 let (cfg, _) = ConfigurationLoader::for_tests(
1246 Some(serde_json::json!({
1247 "present": "yes",
1248 "absent": null
1249 })),
1250 None,
1251 None,
1252 )
1253 .await;
1254
1255 let pairs = cfg.flattened_keys().unwrap();
1256 let map: std::collections::HashMap<&str, &serde_json::Value> =
1257 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1258
1259 assert_eq!(map.get("present"), Some(&&serde_json::json!("yes")));
1260 assert!(!map.contains_key("absent"));
1262 }
1263
1264 #[tokio::test]
1265 async fn from_yaml_loads_configuration_file() {
1266 use std::io::Write as _;
1267
1268 let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1269 file.write_all(b"top: value\nnested:\n inner: 7\n")
1270 .expect("should write temp file");
1271 file.flush().expect("should flush temp file");
1272
1273 let (cfg, _) = ConfigurationLoader::default()
1274 .from_yaml(file.path())
1275 .expect("YAML file should load")
1276 .into_generic()
1277 .await
1278 .expect("should build generic configuration");
1279
1280 assert_eq!(cfg.get_typed::<String>("top").unwrap(), "value");
1281 assert_eq!(cfg.get_typed::<i64>("nested.inner").unwrap(), 7);
1282 }
1283
1284 #[tokio::test]
1285 async fn try_from_yaml_ignores_unreadable_file() {
1286 let (cfg, _) = ConfigurationLoader::default()
1289 .try_from_yaml("/nonexistent/definitely/not/here.yaml")
1290 .into_generic()
1291 .await
1292 .expect("should build generic configuration even when the file is missing");
1293
1294 assert!(matches!(
1295 cfg.get::<String>("anything"),
1296 Err(ConfigurationError::MissingField { .. })
1297 ));
1298 }
1299
1300 #[tokio::test]
1301 async fn from_json_returns_error_for_invalid_file() {
1302 use std::io::Write as _;
1303
1304 let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1305 file.write_all(b"{ not valid json ").expect("should write temp file");
1306 file.flush().expect("should flush temp file");
1307
1308 let result = ConfigurationLoader::default().from_json(file.path());
1309 assert!(result.is_err(), "invalid JSON should fail to load at the loader level");
1310 }
1311}