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::{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<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 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, receiver: mpsc::Receiver<ConfigUpdate>) -> Self {
295 self.provider_sources.push(ProviderSource::Dynamic(Some(receiver)));
296 self
297 }
298
299 pub fn into_typed<'a, T>(self) -> Result<T, ConfigurationError>
305 where
306 T: Deserialize<'a>,
307 {
308 let figment = build_figment_from_sources(&self.provider_sources);
309 figment.extract().map_err(Into::into)
310 }
311
312 pub fn bootstrap_generic(&self) -> GenericConfiguration {
317 let figment = build_figment_from_sources(&self.provider_sources);
318
319 GenericConfiguration {
320 inner: Arc::new(Inner {
321 figment: RwLock::new(figment),
322 lookup_sources: self.lookup_sources.clone(),
323 event_sender: None,
324 ready_signal: Mutex::new(None),
325 }),
326 }
327 }
328
329 pub async fn into_generic(mut self) -> Result<GenericConfiguration, ConfigurationError> {
331 let has_dynamic_provider = self
332 .provider_sources
333 .iter()
334 .any(|s| matches!(s, ProviderSource::Dynamic(_)));
335
336 if has_dynamic_provider {
337 let mut receiver_opt = None;
338 for source in self.provider_sources.iter_mut() {
339 if let ProviderSource::Dynamic(ref mut receiver) = source {
340 receiver_opt = receiver.take();
341 break;
342 }
343 }
344 let receiver = receiver_opt.expect("Dynamic receiver should exist but was not found");
345
346 let figment = build_figment_from_sources(&self.provider_sources);
348
349 let (event_sender, _) = broadcast::channel(100);
350 let (ready_sender, ready_receiver) = oneshot::channel();
351
352 let generic_config = GenericConfiguration {
353 inner: Arc::new(Inner {
354 figment: RwLock::new(figment),
355 lookup_sources: self.lookup_sources,
356 event_sender: Some(event_sender.clone()),
357 ready_signal: Mutex::new(Some(ready_receiver)),
358 }),
359 };
360
361 tokio::spawn(run_dynamic_config_updater(
363 generic_config.inner.clone(),
364 receiver,
365 self.provider_sources,
366 event_sender,
367 ready_sender,
368 ));
369
370 Ok(generic_config)
371 } else {
372 let figment = build_figment_from_sources(&self.provider_sources);
374
375 Ok(GenericConfiguration {
376 inner: Arc::new(Inner {
377 figment: RwLock::new(figment),
378 lookup_sources: self.lookup_sources,
379 event_sender: None,
380 ready_signal: Mutex::new(None),
381 }),
382 })
383 }
384 }
385
386 #[cfg(any(test, feature = "test-util"))]
397 pub async fn for_tests(
398 file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
399 enable_dynamic_configuration: bool,
400 ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>) {
401 Self::for_tests_with_provider_factory(file_values, env_vars, enable_dynamic_configuration, |_| {
402 Serialized::defaults(serde_json::json!({}))
403 })
404 .await
405 }
406
407 #[cfg(any(test, feature = "test-util"))]
416 pub async fn for_tests_with_provider_factory<P, F>(
417 file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
418 enable_dynamic_configuration: bool, provider_factory: F,
419 ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>)
420 where
421 P: Provider + Send + Sync + 'static,
422 F: FnOnce(Vec<(String, String)>) -> P,
423 {
424 let json_file = tempfile::NamedTempFile::new().expect("should not fail to create temp file.");
425 let path = &json_file.path();
426 let json_to_write = file_values.unwrap_or(serde_json::json!({}));
427 serde_json::to_writer(&json_file, &json_to_write).expect("should not fail to write to temp file.");
428
429 let mut loader = ConfigurationLoader::default().try_from_json(path);
430 let mut maybe_sender = None;
431 if enable_dynamic_configuration {
432 let (sender, receiver) = tokio::sync::mpsc::channel(1);
433 loader = loader.with_dynamic_configuration(receiver);
434 maybe_sender = Some(sender);
435 }
436
437 let guard = test_env_lock();
441
442 if let Some(pairs) = env_vars.as_ref() {
443 for (k, v) in pairs.iter() {
444 std::env::set_var(k, v);
448 std::env::set_var(format!("TEST_{}", k), v);
449 }
450 }
451
452 let provider_env_vars = env_vars.unwrap_or_default().to_vec();
454 let loader = loader.add_providers([provider_factory(provider_env_vars)]);
455
456 let loader = loader
458 .from_environment("TEST")
459 .expect("should not fail to add environment provider");
460
461 if let Some(pairs) = env_vars.as_ref() {
463 for (k, _) in pairs.iter() {
464 std::env::remove_var(k);
465 std::env::remove_var(format!("TEST_{}", k));
466 }
467 }
468
469 drop(guard);
470
471 let cfg = loader
472 .into_generic()
473 .await
474 .expect("should not fail to build generic configuration");
475
476 (cfg, maybe_sender)
477 }
478}
479
480#[cfg(any(test, feature = "test-util"))]
496pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
497 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
498 ENV_MUTEX.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
499}
500
501#[cfg(any(test, feature = "test-util"))]
513pub async fn config_from(file_values: serde_json::Value) -> GenericConfiguration {
514 let (config, _) = ConfigurationLoader::for_tests(Some(file_values), None, false).await;
515 config
516}
517
518fn build_figment_from_sources(sources: &[ProviderSource]) -> Figment {
519 sources.iter().fold(Figment::new(), |figment, source| match source {
520 ProviderSource::Static(p) => figment.admerge(p.clone()),
521 ProviderSource::Dynamic(_) => figment,
523 })
524}
525
526pub fn upsert(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
530 if !root.is_object() {
531 *root = serde_json::Value::Object(serde_json::Map::new());
532 }
533
534 let mut current = root;
535 let mut segments = key.split('.').peekable();
537
538 while let Some(seg) = segments.next() {
539 let is_leaf = segments.peek().is_none();
540
541 if !current.is_object() {
543 *current = serde_json::Value::Object(serde_json::Map::new());
544 }
545 let node = current.as_object_mut().expect("current node should be an object");
546
547 if is_leaf {
548 node.insert(seg.to_string(), value);
549 break;
550 } else {
551 let should_create_node = match node.get(seg) {
553 Some(v) => !v.is_object(),
554 None => true,
555 };
556 if should_create_node {
558 node.insert(seg.to_string(), serde_json::Value::Object(serde_json::Map::new()));
559 }
560
561 current = node.get_mut(seg).expect("should not fail to get nested object");
563 }
564 }
565}
566
567async fn run_dynamic_config_updater(
568 inner: Arc<Inner>, mut receiver: mpsc::Receiver<ConfigUpdate>, provider_sources: Vec<ProviderSource>,
569 sender: broadcast::Sender<ConfigChangeEvent>, ready_sender: oneshot::Sender<()>,
570) {
571 let initial_update = match receiver.recv().await {
573 Some(update) => update,
574 None => {
575 debug!("Dynamic configuration channel closed before initial snapshot.");
577 return;
578 }
579 };
580
581 let mut dynamic_state = match initial_update {
584 ConfigUpdate::Snapshot(settings) => settings_to_state(&settings),
585 ConfigUpdate::Partial(_) => {
586 error!("First dynamic config message was not a snapshot. Updater may be in an inconsistent state.");
588 serde_json::Value::Null
589 }
590 };
591
592 let new_figment = provider_sources
594 .iter()
595 .fold(Figment::new(), |figment, source| match source {
596 ProviderSource::Static(p) => figment.admerge(p.clone()),
597 ProviderSource::Dynamic(_) => {
598 figment.admerge(figment::providers::Serialized::defaults(dynamic_state.clone()))
599 }
600 });
601
602 {
604 let mut figment_guard = inner.figment.write().unwrap();
605 *figment_guard = new_figment.clone();
606 }
607
608 if ready_sender.send(()).is_err() {
610 debug!("Configuration readiness receiver dropped. Updater task shutting down.");
611 return;
612 }
613
614 let mut current_config: figment::value::Value = new_figment.extract().unwrap();
616
617 loop {
619 let update = match receiver.recv().await {
620 Some(update) => update,
621 None => {
622 debug!("Dynamic configuration update channel closed. Updater task shutting down.");
624 return;
625 }
626 };
627
628 match update {
630 ConfigUpdate::Snapshot(settings) => {
631 debug!("Received configuration snapshot update.");
632 dynamic_state = settings_to_state(&settings);
633 }
634 ConfigUpdate::Partial(setting) => {
635 debug!(key = %setting.key, "Received partial configuration update.");
636 if dynamic_state.is_null() {
637 dynamic_state = serde_json::Value::Object(serde_json::Map::new());
638 }
639 if dynamic_state.is_object() {
640 upsert(&mut dynamic_state, &setting.key, setting.value);
641 } else {
642 error!(
643 "Received partial update but current dynamic state is not an object. This should not happen."
644 );
645 }
646 }
647 }
648
649 let new_figment = provider_sources
651 .iter()
652 .fold(Figment::new(), |figment, source| match source {
653 ProviderSource::Static(p) => figment.admerge(p.clone()),
654 ProviderSource::Dynamic(_) => {
655 figment.admerge(figment::providers::Serialized::defaults(dynamic_state.clone()))
656 }
657 });
658
659 let new_config: figment::value::Value = new_figment.clone().extract().unwrap();
660
661 if current_config != new_config {
662 let changes = dynamic::diff_config(¤t_config, &new_config);
663
664 {
665 let mut figment_guard = inner.figment.write().unwrap_or_else(|e| {
666 error!("Failed to acquire write lock for dynamic configuration: {}", e);
667 e.into_inner()
668 });
669 *figment_guard = new_figment;
670 }
671
672 for change in changes {
673 let _ = sender.send(change);
677 }
678
679 current_config = new_config;
681 }
682 }
683}
684
685#[derive(Debug)]
686struct Inner {
687 figment: RwLock<Figment>,
688 lookup_sources: HashSet<LookupSource>,
689 event_sender: Option<broadcast::Sender<ConfigChangeEvent>>,
690 ready_signal: Mutex<Option<oneshot::Receiver<()>>>,
691}
692
693#[derive(Clone, Debug)]
715pub struct GenericConfiguration {
716 inner: Arc<Inner>,
717}
718
719impl GenericConfiguration {
720 pub async fn ready(&self) {
727 let mut maybe_ready_rx = self.inner.ready_signal.lock().await;
730 if let Some(ready_rx) = maybe_ready_rx.take() {
731 saluki_antithesis::reachable!("config readiness wait entered");
736
737 let ready_result = ready_rx.await;
738
739 if ready_result.is_err() {
740 saluki_antithesis::unreachable!(
741 "config readiness sender dropped before signalling — updater task may have panicked"
742 );
743 error!("Failed to receive configuration readiness signal; updater task may have panicked.");
744 } else {
745 saluki_antithesis::sometimes!(true, "config readiness signal received");
746 }
747 }
748 }
749
750 fn get<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
751 where
752 T: Deserialize<'a>,
753 {
754 let figment_guard = self.inner.figment.read().unwrap();
755 match figment_guard.extract_inner(key) {
756 Ok(value) => Ok(value),
757 Err(e) => {
758 if matches!(e.kind, figment::error::Kind::MissingField(_)) {
759 let fallback_key = key.replace('.', "_");
764 figment_guard
765 .extract_inner(&fallback_key)
766 .map_err(|fallback_e| from_figment_error(&self.inner.lookup_sources, fallback_e))
767 } else {
768 Err(e.into())
769 }
770 }
771 }
772 }
773
774 pub fn get_typed<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
783 where
784 T: Deserialize<'a>,
785 {
786 self.get(key)
787 }
788
789 pub fn get_typed_or_default<'a, T>(&self, key: &str) -> T
796 where
797 T: Default + Deserialize<'a>,
798 {
799 self.get(key).unwrap_or_default()
800 }
801
802 pub fn try_get_typed<'a, T>(&self, key: &str) -> Result<Option<T>, ConfigurationError>
813 where
814 T: Deserialize<'a>,
815 {
816 match self.get(key) {
817 Ok(value) => Ok(Some(value)),
818 Err(ConfigurationError::MissingField { .. }) => Ok(None),
819 Err(e) => Err(e),
820 }
821 }
822
823 pub fn as_typed<'a, T>(&self) -> Result<T, ConfigurationError>
829 where
830 T: Deserialize<'a>,
831 {
832 self.inner
833 .figment
834 .read()
835 .unwrap()
836 .extract()
837 .map_err(|e| from_figment_error(&self.inner.lookup_sources, e))
838 }
839
840 pub fn subscribe_for_updates(&self) -> Option<broadcast::Receiver<dynamic::ConfigChangeEvent>> {
842 self.inner.event_sender.as_ref().map(|s| s.subscribe())
843 }
844
845 pub fn flattened_keys(&self) -> Result<Vec<(String, serde_json::Value)>, ConfigurationError> {
855 let root: serde_json::Value = self.as_typed()?;
856 let mut out = Vec::new();
857 flatten_value(&root, &mut String::new(), &mut out);
858 Ok(out)
859 }
860
861 pub fn watch_for_updates(&self, key: &str) -> FieldUpdateWatcher {
866 FieldUpdateWatcher {
867 key: key.to_string(),
868 rx: self.subscribe_for_updates(),
869 }
870 }
871}
872
873fn flatten_value(value: &serde_json::Value, prefix: &mut String, out: &mut Vec<(String, serde_json::Value)>) {
875 if let serde_json::Value::Object(map) = value {
876 for (key, child) in map {
877 let prev_len = prefix.len();
878 if !prefix.is_empty() {
879 prefix.push('.');
880 }
881 prefix.push_str(key);
882 flatten_value(child, prefix, out);
883 prefix.truncate(prev_len);
884 }
885 } else {
886 out.push((prefix.clone(), value.clone()));
887 }
888}
889
890fn from_figment_error(lookup_sources: &HashSet<LookupSource>, e: figment::Error) -> ConfigurationError {
891 match e.kind {
892 Kind::MissingField(field) => {
893 let mut valid_keys = lookup_sources
894 .iter()
895 .map(|source| source.transform_key(&field))
896 .collect::<Vec<_>>();
897
898 valid_keys.insert(0, field.to_string());
900
901 let help_text = format!("Try setting `{}`.", valid_keys.join("` or `"));
902
903 ConfigurationError::MissingField { help_text, field }
904 }
905 Kind::InvalidType(actual_ty, expected_ty) => ConfigurationError::InvalidFieldType {
906 field: e.path.join("."),
907 expected_ty,
908 actual_ty: actual_ty.to_string(),
909 },
910 _ => ConfigurationError::Generic { source: e.into() },
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use super::dynamic::ConfigSetting;
917 use super::*;
918
919 #[tokio::test]
920 async fn static_configuration() {
921 let (cfg, _) = ConfigurationLoader::for_tests(
922 Some(serde_json::json!({
923 "foo": "bar",
924 "baz": 5,
925 "foobar": { "a": false, "b": "c" }
926 })),
927 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
928 false,
929 )
930 .await;
931 cfg.ready().await;
932
933 assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
934 assert_eq!(cfg.get_typed::<i32>("baz").unwrap(), 5);
935 assert!(!cfg.get_typed::<bool>("foobar.a").unwrap());
936 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
937 assert!(matches!(
938 cfg.get::<String>("nonexistentKey"),
939 Err(ConfigurationError::MissingField { .. })
940 ));
941 }
942
943 #[tokio::test]
944 async fn dynamic_configuration() {
945 let (cfg, sender) = ConfigurationLoader::for_tests(
946 Some(serde_json::json!({
947 "foo": "bar",
948 "baz": 5,
949 "foobar": { "a": false, "b": "c" }
950 })),
951 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
952 true,
953 )
954 .await;
955 let sender = sender.expect("sender should exist");
956 sender
957 .send(ConfigUpdate::snapshot([ConfigSetting::explicit(
958 "new",
959 serde_json::json!("from_snapshot"),
960 )]))
961 .await
962 .unwrap();
963
964 cfg.ready().await;
965
966 assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
968
969 assert_eq!(cfg.get_typed::<String>("new").unwrap(), "from_snapshot");
971
972 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
973
974 sender
975 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
976 "new_key",
977 "from dynamic update".to_string().into(),
978 )))
979 .await
980 .unwrap();
981
982 tokio::time::timeout(std::time::Duration::from_secs(2), async {
983 loop {
984 match rx.recv().await {
985 Ok(ev) if ev.key == "new_key" => break ev,
986 Err(e) => panic!("updates channel closed: {e}"),
987 Ok(_) => continue,
988 }
989 }
990 })
991 .await
992 .expect("timed out waiting for new_key update");
993
994 assert_eq!(cfg.get_typed::<String>("new_key").unwrap(), "from dynamic update");
995
996 sender
998 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
999 "foobar.a",
1000 serde_json::json!(true),
1001 )))
1002 .await
1003 .unwrap();
1004
1005 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1006 loop {
1007 match rx.recv().await {
1008 Ok(ev) if ev.key == "foobar.a" => break ev,
1009 Err(e) => panic!("updates channel closed: {e}"),
1010 Ok(_) => continue,
1011 }
1012 }
1013 })
1014 .await
1015 .expect("timed out waiting for foobar.a update");
1016
1017 assert!(cfg.get_typed::<bool>("foobar.a").unwrap());
1018 assert_eq!(cfg.get_typed::<String>("foobar.b").unwrap(), "c");
1019 }
1020
1021 #[test]
1022 fn update_events_are_sent_after_the_figment_map_is_updated() {
1023 fn recv_with_timeout(
1024 rx: &mut tokio::sync::broadcast::Receiver<ConfigChangeEvent>, timeout: std::time::Duration,
1025 ) -> Option<ConfigChangeEvent> {
1026 let deadline = std::time::Instant::now() + timeout;
1027 loop {
1028 match rx.try_recv() {
1029 Ok(event) => return Some(event),
1030 Err(tokio::sync::broadcast::error::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
1031 std::thread::sleep(std::time::Duration::from_millis(1));
1032 }
1033 Err(tokio::sync::broadcast::error::TryRecvError::Empty) => return None,
1034 Err(e) => panic!("updates channel failed: {e}"),
1035 }
1036 }
1037 }
1038
1039 let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
1040 let (cfg, sender) = runtime.block_on(async {
1041 let (cfg, sender) = ConfigurationLoader::for_tests(None, None, true).await;
1042 let sender = sender.expect("sender should exist");
1043 sender
1044 .send(ConfigUpdate::snapshot([ConfigSetting::explicit(
1045 "observed",
1046 serde_json::json!("old"),
1047 )]))
1048 .await
1049 .unwrap();
1050 cfg.ready().await;
1051 (cfg, sender)
1052 });
1053
1054 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
1055 let (started_tx, started_rx) = std::sync::mpsc::channel();
1056 let runtime_thread = std::thread::spawn(move || {
1057 runtime.block_on(async {
1058 started_tx.send(()).unwrap();
1059 let _ = stop_rx.await;
1060 });
1061 });
1062 started_rx.recv().unwrap();
1063
1064 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1065 let figment_guard = cfg.inner.figment.read().unwrap();
1066
1067 sender
1068 .blocking_send(ConfigUpdate::Partial(ConfigSetting::explicit(
1069 "observed",
1070 serde_json::json!("new"),
1071 )))
1072 .unwrap();
1073
1074 let early_event = recv_with_timeout(&mut rx, std::time::Duration::from_millis(100));
1075 assert!(
1076 early_event.is_none(),
1077 "update event arrived before the figment map changed"
1078 );
1079
1080 drop(figment_guard);
1081
1082 let event = recv_with_timeout(&mut rx, std::time::Duration::from_secs(2))
1083 .expect("timed out waiting for observed update");
1084 assert_eq!(event.key, "observed");
1085 assert_eq!(cfg.get_typed::<String>("observed").unwrap(), "new");
1086
1087 let _ = stop_tx.send(());
1088 runtime_thread.join().unwrap();
1089 }
1090
1091 #[tokio::test]
1092 async fn environment_precedence_over_dynamic() {
1093 let (cfg, sender) = ConfigurationLoader::for_tests(
1094 Some(serde_json::json!({
1095 "foo": "bar",
1096 "baz": 5,
1097 "foobar": { "a": false, "b": "c" }
1098 })),
1099 Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
1100 true,
1101 )
1102 .await;
1103 let sender = sender.expect("sender should exist");
1104
1105 sender
1106 .send(ConfigUpdate::snapshot([ConfigSetting::explicit(
1107 "env_var",
1108 serde_json::json!("from_snapshot_env_var"),
1109 )]))
1110 .await
1111 .unwrap();
1112
1113 cfg.ready().await;
1114
1115 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1117
1118 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1119
1120 sender
1122 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1123 "env_var",
1124 serde_json::json!("from_partial"),
1125 )))
1126 .await
1127 .unwrap();
1128
1129 sender
1131 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1132 "foobar.a",
1133 serde_json::json!(false),
1134 )))
1135 .await
1136 .unwrap();
1137
1138 sender
1140 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1141 "dummy",
1142 serde_json::json!(1),
1143 )))
1144 .await
1145 .unwrap();
1146
1147 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1148 loop {
1149 match rx.recv().await {
1150 Ok(ev) if ev.key == "dummy" => break,
1151 Err(e) => panic!("updates channel closed: {e}"),
1152 Ok(_) => continue,
1153 }
1154 }
1155 })
1156 .await
1157 .expect("timed out waiting for sync marker");
1158
1159 assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1160 }
1161
1162 #[tokio::test]
1163 async fn dynamic_configuration_add_new_nested_key() {
1164 let (cfg, sender) = ConfigurationLoader::for_tests(
1165 Some(serde_json::json!({
1166 "foo": "bar",
1167 "baz": 5,
1168 "foobar": { "a": false, "b": "c" }
1169 })),
1170 None,
1171 true,
1172 )
1173 .await;
1174 let sender = sender.expect("sender should exist");
1175
1176 sender.send(ConfigUpdate::snapshot([])).await.unwrap();
1177 cfg.ready().await;
1178
1179 let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1180
1181 sender
1182 .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1183 "new_parent.new_child",
1184 serde_json::json!(42),
1185 )))
1186 .await
1187 .unwrap();
1188
1189 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1191 loop {
1192 match rx.recv().await {
1193 Ok(ev) if ev.key == "new_parent" => break ev,
1194 Err(e) => panic!("updates channel closed: {e}"),
1195 Ok(_) => continue,
1196 }
1197 }
1198 })
1199 .await
1200 .expect("timed out waiting for new_parent.new_child update");
1201
1202 assert_eq!(cfg.get_typed::<i32>("new_parent.new_child").unwrap(), 42);
1203 }
1204
1205 #[tokio::test]
1206 async fn underscore_fallback_on_get() {
1207 let (cfg, _) = ConfigurationLoader::for_tests(
1208 Some(serde_json::json!({})),
1209 Some(&[("RANDOM_KEY".to_string(), "from_env_only".to_string())]),
1210 false,
1211 )
1212 .await;
1213 cfg.ready().await;
1214
1215 assert_eq!(cfg.get_typed::<String>("random.key").unwrap(), "from_env_only");
1216 }
1217
1218 #[tokio::test]
1219 async fn underscore_fallback_on_get_multi_segment_key() {
1220 let (cfg, _) = ConfigurationLoader::for_tests(
1225 Some(serde_json::json!({})),
1226 Some(&[(
1227 "DATA_PLANE_API_LISTEN_ADDRESS".to_string(),
1228 "tcp://0.0.0.0:55100".to_string(),
1229 )]),
1230 false,
1231 )
1232 .await;
1233 cfg.ready().await;
1234
1235 assert_eq!(
1236 cfg.try_get_typed::<String>("data_plane.api_listen_address").unwrap(),
1237 Some("tcp://0.0.0.0:55100".to_string()),
1238 );
1239 }
1240
1241 #[tokio::test]
1242 async fn static_configuration_ready_and_subscribe() {
1243 let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, false).await;
1244 assert!(maybe_sender.is_none());
1245
1246 tokio::time::timeout(std::time::Duration::from_millis(500), cfg.ready())
1247 .await
1248 .expect("ready() should not block when dynamic is disabled");
1249
1250 assert!(cfg.subscribe_for_updates().is_none());
1251 }
1252
1253 #[tokio::test]
1254 async fn dynamic_configuration_ready_requires_initial_snapshot() {
1255 let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, true).await;
1257 assert!(maybe_sender.is_some());
1258
1259 let res = tokio::time::timeout(std::time::Duration::from_millis(1000), cfg.ready()).await;
1261 assert!(res.is_err(), "ready() should time out without an initial snapshot");
1262 }
1263
1264 #[tokio::test]
1265 async fn flattened_keys_flat_and_nested() {
1266 let (cfg, _) = ConfigurationLoader::for_tests(
1267 Some(serde_json::json!({
1268 "top": "value",
1269 "nested": { "a": 1, "b": { "c": true } }
1270 })),
1271 None,
1272 false,
1273 )
1274 .await;
1275 cfg.ready().await;
1276
1277 let pairs = cfg.flattened_keys().unwrap();
1278 let map: std::collections::HashMap<&str, &serde_json::Value> =
1279 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1280
1281 assert_eq!(map.get("top"), Some(&&serde_json::json!("value")));
1282 assert_eq!(map.get("nested.a"), Some(&&serde_json::json!(1)));
1283 assert_eq!(map.get("nested.b.c"), Some(&&serde_json::json!(true)));
1284 assert!(!map.contains_key("nested"));
1285 assert!(!map.contains_key("nested.b"));
1286 }
1287
1288 #[tokio::test]
1289 async fn flattened_keys_arrays_are_leaves() {
1290 let (cfg, _) = ConfigurationLoader::for_tests(
1291 Some(serde_json::json!({
1292 "tags": ["a", "b"],
1293 "matrix": [[1, 2], [3, 4]]
1294 })),
1295 None,
1296 false,
1297 )
1298 .await;
1299 cfg.ready().await;
1300
1301 let pairs = cfg.flattened_keys().unwrap();
1302 let map: std::collections::HashMap<&str, &serde_json::Value> =
1303 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1304
1305 assert_eq!(map.get("tags"), Some(&&serde_json::json!(["a", "b"])));
1306 assert_eq!(map.get("matrix"), Some(&&serde_json::json!([[1, 2], [3, 4]])));
1307 }
1308
1309 #[tokio::test]
1310 async fn flattened_keys_null_values_absent() {
1311 let (cfg, _) = ConfigurationLoader::for_tests(
1312 Some(serde_json::json!({
1313 "present": "yes",
1314 "absent": null
1315 })),
1316 None,
1317 false,
1318 )
1319 .await;
1320 cfg.ready().await;
1321
1322 let pairs = cfg.flattened_keys().unwrap();
1323 let map: std::collections::HashMap<&str, &serde_json::Value> =
1324 pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1325
1326 assert_eq!(map.get("present"), Some(&&serde_json::json!("yes")));
1327 assert!(!map.contains_key("absent"));
1329 }
1330
1331 #[tokio::test]
1332 async fn from_yaml_loads_configuration_file() {
1333 use std::io::Write as _;
1334
1335 let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1336 file.write_all(b"top: value\nnested:\n inner: 7\n")
1337 .expect("should write temp file");
1338 file.flush().expect("should flush temp file");
1339
1340 let cfg = ConfigurationLoader::default()
1341 .from_yaml(file.path())
1342 .expect("YAML file should load")
1343 .into_generic()
1344 .await
1345 .expect("should build generic configuration");
1346
1347 assert_eq!(cfg.get_typed::<String>("top").unwrap(), "value");
1348 assert_eq!(cfg.get_typed::<i64>("nested.inner").unwrap(), 7);
1349 }
1350
1351 #[tokio::test]
1352 async fn try_from_yaml_ignores_unreadable_file() {
1353 let cfg = ConfigurationLoader::default()
1356 .try_from_yaml("/nonexistent/definitely/not/here.yaml")
1357 .into_generic()
1358 .await
1359 .expect("should build generic configuration even when the file is missing");
1360
1361 assert!(matches!(
1362 cfg.get::<String>("anything"),
1363 Err(ConfigurationError::MissingField { .. })
1364 ));
1365 }
1366
1367 #[tokio::test]
1368 async fn from_json_returns_error_for_invalid_file() {
1369 use std::io::Write as _;
1370
1371 let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1372 file.write_all(b"{ not valid json ").expect("should write temp file");
1373 file.flush().expect("should flush temp file");
1374
1375 let result = ConfigurationLoader::default().from_json(file.path());
1376 assert!(result.is_err(), "invalid JSON should fail to load at the loader level");
1377 }
1378}