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::{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/// 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    lookup_sources: HashSet<LookupSource>,
154    provider_sources: Vec<ProviderSource>,
155}
156
157impl ConfigurationLoader {
158    /// Appends one or more providers to the configuration chain.
159    ///
160    /// Sources are merged in the order they're added: later sources take precedence over earlier ones. Call
161    /// this method after any file-loading methods and before [`from_environment`][Self::from_environment] to
162    /// place the added providers at the correct intermediate precedence level:
163    ///
164    /// ```text
165    /// file providers  <  add_providers(...)  <  from_environment(...)
166    /// ```
167    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    /// Loads the given YAML configuration file.
180    ///
181    /// # Errors
182    ///
183    /// If the file couldn't be read, or if the file isn't valid YAML, an error will be returned.
184    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    /// Attempts to load the given YAML configuration file, ignoring any errors.
195    ///
196    /// Errors include the file not existing, not being readable/accessible, and not being valid YAML.
197    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    /// Loads the given JSON configuration file.
218    ///
219    /// # Errors
220    ///
221    /// If the file couldn't be read, or if the file isn't valid JSON, an error will be returned.
222    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    /// Attempts to load the given JSON configuration file, ignoring any errors.
233    ///
234    /// Errors include the file not existing, not being readable/accessible, and not being valid JSON.
235    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    /// Loads configuration from environment variables.
256    ///
257    /// The prefix given will have an underscore appended to it if it doesn't already end with one. For
258    /// example, with a prefix of `app`, any environment variable starting with `app_` would be matched. The
259    /// prefix is case-insensitive.
260    ///
261    /// Sources are merged in the order they're added, with later sources taking precedence over earlier ones.
262    /// Sources added after this call will have higher precedence than environment variables.
263    ///
264    /// # Errors
265    ///
266    /// If the prefix is empty, an error will be returned.
267    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        // Convert to use Serialized::defaults since, Env isn't Send + Sync
279        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    /// Enables dynamic configuration.
292    ///
293    /// The receiver is used in `run_dynamic_config_updater` to handle retrieving the initial snapshot and subsequent updates.
294    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    /// Consumes the configuration loader, deserializing it as `T`.
300    ///
301    /// ## Errors
302    ///
303    /// If the configuration couldn't be deserialized into `T`, an error will be returned.
304    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    /// Creates a bootstrap `GenericConfiguration` without consuming the loader.
313    ///
314    /// This creates a static snapshot of the configuration loaded so far. As this is intended for bootstrapping
315    /// before dynamic configuration is active, the dynamic provider is ignored.
316    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    /// Consumes the configuration loader and wraps it in a generic wrapper.
330    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            // Build the initial figment object from the static providers. The dynamic provider is empty for now.
347            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            // Spawn the background task to handle retrieving the initial snapshot and subsequent updates.
362            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            // Otherwise, just build the static configuration.
373            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    /// Configures a [`GenericConfiguration`] that's suitable for tests.
387    ///
388    /// This configures the loader with the following defaults:
389    ///
390    /// - configuration from a JSON file
391    /// - configuration from environment variables
392    ///
393    /// If `enable_dynamic_configuration` is true, a dynamic configuration sender is returned.
394    ///
395    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate testing scenarios.
396    #[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    /// Like [`for_tests`][Self::for_tests], but calls `provider_factory` to build an additional
408    /// provider inserted between the file provider and the environment provider.
409    ///
410    /// The factory receives an owned copy of the explicitly configured test environment variables. Providers can use
411    /// this input instead of reading unrelated variables from the ambient process environment. The factory is called
412    /// after the test environment variables have been set for providers that still require process environment access.
413    ///
414    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate testing scenarios.
415    #[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        // All tests that mutate process-wide environment variables while loading configuration serialize against a
438        // single shared lock (see `test_env_lock`), so that tests in other modules and crates can't race with the
439        // env-var manipulation below.
440        let guard = test_env_lock();
441
442        if let Some(pairs) = env_vars.as_ref() {
443            for (k, v) in pairs.iter() {
444                // Set under both the raw name and the TEST_ prefix:
445                //   - Raw name: available to any env-reading providers built by `provider_factory`
446                //   - TEST_ prefix: read by from_environment("TEST") (simulates DD_ prefix)
447                std::env::set_var(k, v);
448                std::env::set_var(format!("TEST_{}", k), v);
449            }
450        }
451
452        // Build and insert the extra provider while env vars are set so it can snapshot them.
453        let provider_env_vars = env_vars.unwrap_or_default().to_vec();
454        let loader = loader.add_providers([provider_factory(provider_env_vars)]);
455
456        // Add environment provider last so it has the highest precedence.
457        let loader = loader
458            .from_environment("TEST")
459            .expect("should not fail to add environment provider");
460
461        // Clean up test-provided env vars now that all providers have been built.
462        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/// Acquires the process-global lock that serializes tests which mutate environment variables while loading
481/// configuration, returning the held guard.
482///
483/// [`ConfigurationLoader::for_tests`] and [`ConfigurationLoader::for_tests_with_provider_factory`] set and unset
484/// process-wide environment variables to simulate `DD_`-prefixed configuration. Because the process environment is
485/// global mutable state, any test in any crate that reads or writes environment variables relevant to configuration
486/// loading MUST hold this lock for the duration of that access, so that all such tests serialize against each other
487/// rather than racing.
488///
489/// This is exposed publicly so that tests in downstream crates can serialize against the same single lock that the
490/// loader itself uses, instead of each hand-rolling an independent (and therefore non-serializing) mutex.
491///
492/// Lock poisoning is intentionally ignored: the mutex guards no data (its guarded type is `()`), so a panic in
493/// another test while the lock was held leaves nothing in an inconsistent state. Propagating poisoning would only
494/// cascade an unrelated test failure into every later env-mutating test.
495#[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/// Builds a [`GenericConfiguration`] from an in-memory JSON body, for use in tests.
502///
503/// This is a thin convenience wrapper around [`ConfigurationLoader::for_tests`] for the common case of a test that
504/// only needs file-based configuration values, with no environment variables and no dynamic configuration. The
505/// dynamic-configuration sender that `for_tests` can optionally return is unused in that case and is discarded here.
506///
507/// Callers that need a typed configuration should build it from the returned [`GenericConfiguration`] (for example,
508/// via a `SomeConfiguration::from_configuration` constructor or [`GenericConfiguration::as_typed`]).
509///
510/// This is exposed publicly so that tests in downstream crates can share a single loader helper instead of
511/// re-implementing it per file.
512#[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        // No-op. The merging is handled by the updater task.
522        ProviderSource::Dynamic(_) => figment,
523    })
524}
525
526/// Inserts or updates a value for a key.
527///
528/// Intermediate objects are created if they don't exist.
529pub 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    // Create a new node for each segment if the key is dotted.
536    let mut segments = key.split('.').peekable();
537
538    while let Some(seg) = segments.next() {
539        let is_leaf = segments.peek().is_none();
540
541        // Ensure current is an object before operating
542        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            // Ensure child exists and is an object
552            let should_create_node = match node.get(seg) {
553                Some(v) => !v.is_object(),
554                None => true,
555            };
556            // Check if we need to create an intermediate node if it doesn't exist.
557            if should_create_node {
558                node.insert(seg.to_string(), serde_json::Value::Object(serde_json::Map::new()));
559            }
560
561            // Advance the current node to the next level.
562            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    // The first message on the channel will be the initial snapshot.
572    let initial_update = match receiver.recv().await {
573        Some(update) => update,
574        None => {
575            // The channel was closed before we even received the initial snapshot.
576            debug!("Dynamic configuration channel closed before initial snapshot.");
577            return;
578        }
579    };
580
581    // The by-key view is an effective-value view: it holds what each key resolves to, so it drops the
582    // provenance carried by each setting.
583    let mut dynamic_state = match initial_update {
584        ConfigUpdate::Snapshot(settings) => settings_to_state(&settings),
585        ConfigUpdate::Partial(_) => {
586            // This is theoretically unreachable, as `configstream` should always send a snapshot first.
587            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    // Rebuild the configuration with the initial snapshot.
593    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    // Update the main figment object and then release the lock.
603    {
604        let mut figment_guard = inner.figment.write().unwrap();
605        *figment_guard = new_figment.clone();
606    }
607
608    // Signal that the initial snapshot has been processed and the configuration is ready.
609    if ready_sender.send(()).is_err() {
610        debug!("Configuration readiness receiver dropped. Updater task shutting down.");
611        return;
612    }
613
614    // Set our "current" state for the main loop.
615    let mut current_config: figment::value::Value = new_figment.extract().unwrap();
616
617    // Enter the main loop to process subsequent updates.
618    loop {
619        let update = match receiver.recv().await {
620            Some(update) => update,
621            None => {
622                // The sender was dropped, which means the config stream has terminated. We can exit.
623                debug!("Dynamic configuration update channel closed. Updater task shutting down.");
624                return;
625            }
626        };
627
628        // Update our local dynamic state based on the received message.
629        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        // Rebuild the figment object on every update, respecting the original provider order.
650        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(&current_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                // Send the change event to any receivers of the dynamic handler.
674                // If there are no receivers, `send` will fail. This is expected and fine,
675                // so we can ignore the error to avoid log spam.
676                let _ = sender.send(change);
677            }
678
679            // Update our "current" state for the next iteration.
680            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/// A generic configuration object.
694///
695/// This represents the merged configuration derived from [`ConfigurationLoader`] in its raw form. Values can be
696/// queried by key, and can be extracted either as typed values or in their raw form.
697///
698/// Keys must be in the form of `a.b.c`, where periods (`.`) as used to indicate a nested value.
699///
700/// Using an example JSON configuration:
701///
702/// ```json
703/// {
704///   "a": {
705///     "b": {
706///       "c": "value"
707///     }
708///   }
709/// }
710/// ```
711///
712/// Querying for the value of `a.b.c` would return `"value"`, and querying for `a.b` would return the nested object `{
713/// "c": "value" }`.
714#[derive(Clone, Debug)]
715pub struct GenericConfiguration {
716    inner: Arc<Inner>,
717}
718
719impl GenericConfiguration {
720    /// Waits for the configuration to be ready, if dynamic configuration is enabled.
721    ///
722    /// If dynamic configuration is in use, this method will asynchronously wait until the first snapshot has been
723    /// received and applied.
724    ///
725    /// If dynamic configuration isn't used, it returns immediately.
726    pub async fn ready(&self) {
727        // We need a lock to both ensure that multiple callers can race against this,
728        // and to allow us mutable access to consume the receiver.
729        let mut maybe_ready_rx = self.inner.ready_signal.lock().await;
730        if let Some(ready_rx) = maybe_ready_rx.take() {
731            // We're the first caller to wait for readiness.
732            //
733            // There is no timeout on this await by design: if the Core Agent never sends the first snapshot, startup
734            // blocks here forever.
735            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                    // We might have been given a key that uses nested notation -- `foo.bar` -- but is only present in the
760                    // environment variables. We specifically don't want to use a different separator in environment
761                    // variables to map to nested key separators, so we simply try again here but with all nested key
762                    // separators (`.`) replaced with `_`, to match environment variables.
763                    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    /// Gets a configuration value by key.
775    ///
776    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
777    ///
778    /// ## Errors
779    ///
780    /// If the key doesn't exist in the configuration, or if the value couldn't be deserialized into `T`, an error
781    /// variant will be returned.
782    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    /// Gets a configuration value by key, or the default value if a key doesn't exist or couldn't be deserialized.
790    ///
791    /// The `Default` implementation of `T` will be used both if the key couldn't be found, as well as for any error
792    /// during deserialization. This effectively swallows any errors and should generally be used sparingly.
793    ///
794    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
795    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    /// Gets a configuration value by key, if it exists.
803    ///
804    /// If the key exists in the configuration, and can be deserialized, `Ok(Some(value))` is returned. Otherwise,
805    /// `Ok(None)` will be returned.
806    ///
807    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
808    ///
809    /// ## Errors
810    ///
811    /// If the value couldn't be deserialized into `T`, an error will be returned.
812    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    /// Attempts to deserialize the entire configuration as `T`.
824    ///
825    /// ## Errors
826    ///
827    /// If the value couldn't be deserialized into `T`, an error will be returned.
828    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    /// Subscribes for updates to the configuration.
841    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    /// Extracts the entire configuration as a flat list of dot-separated key paths and their values.
846    ///
847    /// Nested JSON objects are descended into, joining keys with dots. Arrays, strings, numbers,
848    /// bools, and nulls are leaf values and are never descended into. The returned values are
849    /// never `Value::Object`.
850    ///
851    /// ## Errors
852    ///
853    /// If the configuration couldn't be serialized to JSON, an error will be returned.
854    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    /// Creates a watcher that yields only when the given key changes.
862    ///
863    /// If dynamic configuration is disabled, the returned watcher's `changed()`
864    /// will wait indefinitely.
865    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
873/// Recursively descend into the tree building a dot-separated path until hitting a non-Object leaf node.
874fn 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            // Always specify the original key as a valid key to try.
899            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        // Test that existing values still exist.
967        assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
968
969        // Test that new values from the snapshot exist.
970        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        // Test that an update with a nested key is applied.
997        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        // Env provider has highest precedence so the snapshot should not override it.
1116        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        // Send a partial update that attempts to override the env-backed key.
1121        sender
1122            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1123                "env_var",
1124                serde_json::json!("from_partial"),
1125            )))
1126            .await
1127            .unwrap();
1128
1129        // Also attempt to override the nested env-backed key via dynamic.
1130        sender
1131            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1132                "foobar.a",
1133                serde_json::json!(false),
1134            )))
1135            .await
1136            .unwrap();
1137
1138        // Send a dummy partial update to ensure the updater has processed prior partials.
1139        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        // new_parent object did not exist before, so the diff will emit the object "new_parent"
1190        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        // A single-underscore Agent-style env var (e.g. `DD_DATA_PLANE_API_LISTEN_ADDRESS`, which
1221        // `for_tests` simulates with the `TEST_` prefix) produces a flat figment key. A deeply
1222        // nested `get`/`try_get_typed` query must still resolve it via the dot-to-underscore
1223        // fallback, so callers don't need double-underscore env vars for these keys.
1224        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        // Enable dynamic but do not send the initial snapshot.
1256        let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, true).await;
1257        assert!(maybe_sender.is_some());
1258
1259        // ready() should not resolve until the initial snapshot is processed.
1260        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        // Figment drops null values during deserialization, so they are absent from the output.
1328        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        // `try_from_yaml` swallows load errors (here, a nonexistent path), yielding a config with no values rather
1354        // than failing to build.
1355        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}