saluki_config/
lib.rs

1//! Primitives for working with typed and untyped configuration data.
2#![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/// A configuration error.
59#[derive(Debug, Snafu)]
60#[snafu(context(suffix(false)))]
61pub enum ConfigurationError {
62    /// Environment variable prefix was empty.
63    #[snafu(display("Environment variable prefix must not be empty."))]
64    EmptyPrefix,
65
66    /// Requested field was missing from the configuration.
67    #[snafu(display("Missing field '{}' in configuration. {}", field, help_text))]
68    MissingField {
69        /// Help text describing how to set the missing field.
70        ///
71        /// This is meant to be displayed to the user, and includes environment variable-specific text if environment
72        /// variables had been loaded originally.
73        help_text: String,
74
75        /// Name of the missing field.
76        field: Cow<'static, str>,
77    },
78
79    /// Requested field's data type wasn't the unexpected data type.
80    #[snafu(display(
81        "Expected value for field '{}' to be '{}', got '{}' instead.",
82        field,
83        expected_ty,
84        actual_ty
85    ))]
86    InvalidFieldType {
87        /// Name of the invalid field.
88        ///
89        /// This is a period-separated path to the field.
90        field: String,
91
92        /// Expected data type.
93        expected_ty: String,
94
95        /// Actual data type.
96        actual_ty: String,
97    },
98
99    /// Generic configuration error.
100    #[snafu(transparent)]
101    Generic {
102        /// Error source.
103        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    /// The configuration key is looked up in a form suitable for environment variables.
123    Environment { prefix: String },
124}
125
126impl LookupSource {
127    fn transform_key(&self, key: &str) -> String {
128        match self {
129            // The prefix should already be uppercased, with a trailing underscore, which is needed when we actually
130            // configure the provider used for reading from the environment... so we don't need to re-do that here.
131            LookupSource::Environment { prefix } => format!("{}{}", prefix, key.replace('.', "_").to_uppercase()),
132        }
133    }
134}
135
136/// A configuration loader that can pull from various sources.
137///
138/// This loader provides a wrapper around a lower-level library, `figment`, to expose a simpler and focused API for both
139/// loading configuration data from various sources, as well as querying it.
140///
141/// A variety of configuration sources can be configured (see below), with an implicit priority based on the order in
142/// which sources are added: sources added later take precedence over sources prior. Additionally, either a typed value
143/// can be extracted from the configuration ([`into_typed`][Self::into_typed]), or the raw configuration data can be
144/// accessed via a generic API ([`into_generic`][Self::into_generic]).
145///
146/// # Supported sources
147///
148/// - YAML file
149/// - JSON file
150/// - environment variables (must be prefixed; see [`from_environment`][Self::from_environment])
151#[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    /// Sets key aliases to apply when loading file-based configuration sources.
160    ///
161    /// Each entry is `(nested_path, flat_key)`. When a YAML or JSON file contains a value at `nested_path`
162    /// (dot-separated), that value is also emitted under `flat_key` at the top level—but only if `flat_key`
163    /// isn't already explicitly set at the top level. This ensures that both YAML nested format and flat env var
164    /// format produce the same Figment key, so source precedence (env vars > file) works correctly.
165    ///
166    /// Must be called before any file-loading methods ([`from_yaml`][Self::from_yaml], etc.) to take effect.
167    pub fn with_key_aliases(mut self, aliases: &'static [(&'static str, &'static str)]) -> Self {
168        self.key_aliases = aliases;
169        self
170    }
171
172    /// Appends one or more providers to the configuration chain.
173    ///
174    /// Sources are merged in the order they're added: later sources take precedence over earlier ones. Call
175    /// this method after any file-loading methods and before [`from_environment`][Self::from_environment] to
176    /// place the added providers at the correct intermediate precedence level:
177    ///
178    /// ```text
179    /// file providers  <  add_providers(...)  <  from_environment(...)
180    /// ```
181    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    /// Loads the given YAML configuration file.
194    ///
195    /// # Errors
196    ///
197    /// If the file couldn't be read, or if the file isn't valid YAML, an error will be returned.
198    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    /// Attempts to load the given YAML configuration file, ignoring any errors.
209    ///
210    /// Errors include the file not existing, not being readable/accessible, and not being valid YAML.
211    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    /// Loads the given JSON configuration file.
232    ///
233    /// # Errors
234    ///
235    /// If the file couldn't be read, or if the file isn't valid JSON, an error will be returned.
236    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    /// Attempts to load the given JSON configuration file, ignoring any errors.
247    ///
248    /// Errors include the file not existing, not being readable/accessible, and not being valid JSON.
249    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    /// Loads configuration from environment variables.
270    ///
271    /// The prefix given will have an underscore appended to it if it doesn't already end with one. For
272    /// example, with a prefix of `app`, any environment variable starting with `app_` would be matched. The
273    /// prefix is case-insensitive.
274    ///
275    /// Sources are merged in the order they're added, with later sources taking precedence over earlier ones.
276    /// Sources added after this call will have higher precedence than environment variables.
277    ///
278    /// # Errors
279    ///
280    /// If the prefix is empty, an error will be returned.
281    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        // Convert to use Serialized::defaults since, Env isn't Send + Sync
293        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    /// Enables dynamic configuration.
306    ///
307    /// The receiver is used in `run_dynamic_config_updater` to handle retrieving the initial snapshot and subsequent updates.
308    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    /// Consumes the configuration loader, deserializing it as `T`.
314    ///
315    /// ## Errors
316    ///
317    /// If the configuration couldn't be deserialized into `T`, an error will be returned.
318    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    /// Creates a bootstrap `GenericConfiguration` without consuming the loader.
327    ///
328    /// This creates a static snapshot of the configuration loaded so far. As this is intended for bootstrapping
329    /// before dynamic configuration is active, the dynamic provider is ignored.
330    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    /// Consumes the configuration loader and wraps it in a generic wrapper.
344    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            // Build the initial figment object from the static providers. The dynamic provider is empty for now.
361            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            // Spawn the background task to handle retrieving the initial snapshot and subsequent updates.
376            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            // Otherwise, just build the static configuration.
387            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    /// Configures a [`GenericConfiguration`] that's suitable for tests.
401    ///
402    /// This configures the loader with the following defaults:
403    ///
404    /// - configuration from a JSON file
405    /// - configuration from environment variables
406    ///
407    /// If `enable_dynamic_configuration` is true, a dynamic configuration sender is returned.
408    ///
409    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate testing scenarios.
410    #[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    /// Like [`for_tests`][Self::for_tests], but applies `key_aliases` during file loading and calls
422    /// `provider_factory` to build an additional provider inserted between the file provider and the
423    /// environment provider.
424    ///
425    /// The factory receives an owned copy of the explicitly configured test environment variables. Providers can use
426    /// this input instead of reading unrelated variables from the ambient process environment. The factory is called
427    /// after the test environment variables have been set for providers that still require process environment access.
428    ///
429    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate testing scenarios.
430    #[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        // All tests that mutate process-wide environment variables while loading configuration serialize against a
455        // single shared lock (see `test_env_lock`), so that tests in other modules and crates can't race with the
456        // env-var manipulation below.
457        let guard = test_env_lock();
458
459        if let Some(pairs) = env_vars.as_ref() {
460            for (k, v) in pairs.iter() {
461                // Set under both the raw name and the TEST_ prefix:
462                //   - Raw name: available to any env-reading providers (for example, DatadogRemapper)
463                //   - TEST_ prefix: read by from_environment("TEST") (simulates DD_ prefix)
464                std::env::set_var(k, v);
465                std::env::set_var(format!("TEST_{}", k), v);
466            }
467        }
468
469        // Build and insert the extra provider while env vars are set so it can snapshot them.
470        let provider_env_vars = env_vars.unwrap_or_default().to_vec();
471        let loader = loader.add_providers([provider_factory(provider_env_vars)]);
472
473        // Add environment provider last so it has the highest precedence.
474        let loader = loader
475            .from_environment("TEST")
476            .expect("should not fail to add environment provider");
477
478        // Clean up test-provided env vars now that all providers have been built.
479        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/// Acquires the process-global lock that serializes tests which mutate environment variables while loading
498/// configuration, returning the held guard.
499///
500/// [`ConfigurationLoader::for_tests`] and [`ConfigurationLoader::for_tests_with_provider_factory`] set and unset
501/// process-wide environment variables to simulate `DD_`-prefixed configuration. Because the process environment is
502/// global mutable state, any test in any crate that reads or writes environment variables relevant to configuration
503/// loading MUST hold this lock for the duration of that access, so that all such tests serialize against each other
504/// rather than racing.
505///
506/// This is exposed publicly so that tests in downstream crates can serialize against the same single lock that the
507/// loader itself uses, instead of each hand-rolling an independent (and therefore non-serializing) mutex.
508///
509/// Lock poisoning is intentionally ignored: the mutex guards no data (its guarded type is `()`), so a panic in
510/// another test while the lock was held leaves nothing in an inconsistent state. Propagating poisoning would only
511/// cascade an unrelated test failure into every later env-mutating test.
512#[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/// Builds a [`GenericConfiguration`] from an in-memory JSON body, for use in tests.
519///
520/// This is a thin convenience wrapper around [`ConfigurationLoader::for_tests`] for the common case of a test that
521/// only needs file-based configuration values, with no environment variables and no dynamic configuration. The
522/// dynamic-configuration sender that `for_tests` can optionally return is unused in that case and is discarded here.
523///
524/// Callers that need a typed configuration should build it from the returned [`GenericConfiguration`] (for example,
525/// via a `SomeConfiguration::from_configuration` constructor or [`GenericConfiguration::as_typed`]).
526///
527/// This is exposed publicly so that tests in downstream crates can share a single loader helper instead of
528/// re-implementing it per file.
529#[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        // No-op. The merging is handled by the updater task.
539        ProviderSource::Dynamic(_) => figment,
540    })
541}
542
543/// Inserts or updates a value for a key.
544///
545/// Intermediate objects are created if they don't exist.
546pub 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    // Create a new node for each segment if the key is dotted.
553    let mut segments = key.split('.').peekable();
554
555    while let Some(seg) = segments.next() {
556        let is_leaf = segments.peek().is_none();
557
558        // Ensure current is an object before operating
559        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            // Ensure child exists and is an object
569            let should_create_node = match node.get(seg) {
570                Some(v) => !v.is_object(),
571                None => true,
572            };
573            // Check if we need to create an intermediate node if it doesn't exist.
574            if should_create_node {
575                node.insert(seg.to_string(), serde_json::Value::Object(serde_json::Map::new()));
576            }
577
578            // Advance the current node to the next level.
579            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    // The first message on the channel will be the initial snapshot.
589    let initial_update = match receiver.recv().await {
590        Some(update) => update,
591        None => {
592            // The channel was closed before we even received the initial snapshot.
593            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            // This is theoretically unreachable, as `configstream` should always send a snapshot first.
602            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    // Rebuild the configuration with the initial snapshot.
608    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    // Update the main figment object and then release the lock.
618    {
619        let mut figment_guard = inner.figment.write().unwrap();
620        *figment_guard = new_figment.clone();
621    }
622
623    // Signal that the initial snapshot has been processed and the configuration is ready.
624    if ready_sender.send(()).is_err() {
625        debug!("Configuration readiness receiver dropped. Updater task shutting down.");
626        return;
627    }
628
629    // Set our "current" state for the main loop.
630    let mut current_config: figment::value::Value = new_figment.extract().unwrap();
631
632    // Enter the main loop to process subsequent updates.
633    loop {
634        let update = match receiver.recv().await {
635            Some(update) => update,
636            None => {
637                // The sender was dropped, which means the config stream has terminated. We can exit.
638                debug!("Dynamic configuration update channel closed. Updater task shutting down.");
639                return;
640            }
641        };
642
643        // Update our local dynamic state based on the received message.
644        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        // Rebuild the figment object on every update, respecting the original provider order.
665        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(&current_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                // Send the change event to any receivers of the dynamic handler.
689                // If there are no receivers, `send` will fail. This is expected and fine,
690                // so we can ignore the error to avoid log spam.
691                let _ = sender.send(change);
692            }
693
694            // Update our "current" state for the next iteration.
695            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/// A generic configuration object.
709///
710/// This represents the merged configuration derived from [`ConfigurationLoader`] in its raw form. Values can be
711/// queried by key, and can be extracted either as typed values or in their raw form.
712///
713/// Keys must be in the form of `a.b.c`, where periods (`.`) as used to indicate a nested value.
714///
715/// Using an example JSON configuration:
716///
717/// ```json
718/// {
719///   "a": {
720///     "b": {
721///       "c": "value"
722///     }
723///   }
724/// }
725/// ```
726///
727/// Querying for the value of `a.b.c` would return `"value"`, and querying for `a.b` would return the nested object `{
728/// "c": "value" }`.
729#[derive(Clone, Debug)]
730pub struct GenericConfiguration {
731    inner: Arc<Inner>,
732}
733
734impl GenericConfiguration {
735    /// Waits for the configuration to be ready, if dynamic configuration is enabled.
736    ///
737    /// If dynamic configuration is in use, this method will asynchronously wait until the first snapshot has been
738    /// received and applied.
739    ///
740    /// If dynamic configuration isn't used, it returns immediately.
741    pub async fn ready(&self) {
742        // We need a lock to both ensure that multiple callers can race against this,
743        // and to allow us mutable access to consume the receiver.
744        let mut maybe_ready_rx = self.inner.ready_signal.lock().await;
745        if let Some(ready_rx) = maybe_ready_rx.take() {
746            // We're the first caller to wait for readiness.
747            //
748            // There is no timeout on this await by design: if the Core Agent never sends the first snapshot, startup
749            // blocks here forever.
750            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                    // We might have been given a key that uses nested notation -- `foo.bar` -- but is only present in the
775                    // environment variables. We specifically don't want to use a different separator in environment
776                    // variables to map to nested key separators, so we simply try again here but with all nested key
777                    // separators (`.`) replaced with `_`, to match environment variables.
778                    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    /// Gets a configuration value by key.
790    ///
791    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
792    ///
793    /// ## Errors
794    ///
795    /// If the key doesn't exist in the configuration, or if the value couldn't be deserialized into `T`, an error
796    /// variant will be returned.
797    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    /// Gets a configuration value by key, or the default value if a key doesn't exist or couldn't be deserialized.
805    ///
806    /// The `Default` implementation of `T` will be used both if the key couldn't be found, as well as for any error
807    /// during deserialization. This effectively swallows any errors and should generally be used sparingly.
808    ///
809    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
810    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    /// Gets a configuration value by key, if it exists.
818    ///
819    /// If the key exists in the configuration, and can be deserialized, `Ok(Some(value))` is returned. Otherwise,
820    /// `Ok(None)` will be returned.
821    ///
822    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
823    ///
824    /// ## Errors
825    ///
826    /// If the value couldn't be deserialized into `T`, an error will be returned.
827    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    /// Attempts to deserialize the entire configuration as `T`.
839    ///
840    /// ## Errors
841    ///
842    /// If the value couldn't be deserialized into `T`, an error will be returned.
843    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    /// Subscribes for updates to the configuration.
856    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    /// Extracts the entire configuration as a flat list of dot-separated key paths and their values.
861    ///
862    /// Nested JSON objects are descended into, joining keys with dots. Arrays, strings, numbers,
863    /// bools, and nulls are leaf values and are never descended into. The returned values are
864    /// never `Value::Object`.
865    ///
866    /// ## Errors
867    ///
868    /// If the configuration couldn't be serialized to JSON, an error will be returned.
869    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    /// Creates a watcher that yields only when the given key changes.
877    ///
878    /// If dynamic configuration is disabled, the returned watcher's `changed()`
879    /// will wait indefinitely.
880    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
888/// Recursively descend into the tree building a dot-separated path until hitting a non-Object leaf node.
889fn 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            // Always specify the original key as a valid key to try.
914            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        // Test that existing values still exist.
980        assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
981
982        // Test that new values from the snapshot exist.
983        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        // Test that an update with a nested key is applied.
1010        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        // Env provider has highest precedence so the snapshot should not override it.
1125        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        // Send a partial update that attempts to override the env-backed key.
1130        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        // Also attempt to override the nested env-backed key via dynamic.
1139        sender
1140            .send(ConfigUpdate::Partial {
1141                key: "foobar.a".to_string(),
1142                value: serde_json::json!(false),
1143            })
1144            .await
1145            .unwrap();
1146
1147        // Send a dummy partial update to ensure the updater has processed prior partials.
1148        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        // new_parent object did not exist before, so the diff will emit the object "new_parent"
1202        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        // A single-underscore Agent-style env var (e.g. `DD_DATA_PLANE_API_LISTEN_ADDRESS`, which
1233        // `for_tests` simulates with the `TEST_` prefix) produces a flat figment key. A deeply
1234        // nested `get`/`try_get_typed` query must still resolve it via the dot-to-underscore
1235        // fallback, so callers don't need double-underscore env vars for these keys.
1236        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        // Enable dynamic but do not send the initial snapshot.
1268        let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, true).await;
1269        assert!(maybe_sender.is_some());
1270
1271        // ready() should not resolve until the initial snapshot is processed.
1272        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        // Figment drops null values during deserialization, so they are absent from the output.
1340        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        // `try_from_yaml` swallows load errors (here, a nonexistent path), yielding a config with no values rather
1366        // than failing to build.
1367        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}