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;
18use tracing::{debug, error};
19
20pub mod duration_string;
21pub mod dynamic;
22mod provider;
23pub mod space_separated;
24
25pub use self::duration_string::{parse_duration, DurationString, ParseDurationError};
26pub use self::dynamic::FieldUpdateWatcher;
27use self::dynamic::{settings_to_state, ConfigChangeEvent, ConfigUpdate};
28use self::provider::ResolvedProvider;
29pub use self::space_separated::{deserialize_opt_space_separated_or_seq, deserialize_space_separated_or_seq};
30
31#[derive(Clone)]
32struct ArcProvider(Arc<dyn Provider + Send + Sync>);
33
34impl Provider for ArcProvider {
35    fn metadata(&self) -> figment::Metadata {
36        self.0.metadata()
37    }
38
39    fn data(&self) -> Result<figment::value::Map<figment::Profile, figment::value::Dict>, figment::Error> {
40        self.0.data()
41    }
42}
43
44enum ProviderSource {
45    Static(ArcProvider),
46    Dynamic(Option<ConfigUpdate>),
47}
48
49impl Clone for ProviderSource {
50    fn clone(&self) -> Self {
51        match self {
52            Self::Static(p) => Self::Static(p.clone()),
53            Self::Dynamic(_) => Self::Dynamic(None),
54        }
55    }
56}
57
58/// 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, starting from the given initial update.
292    ///
293    /// The dynamic configuration layers over the providers added before this call, and under the providers added after
294    /// it. [`into_generic`][Self::into_generic] applies `initial` to it, and returns the [`ConfigUpdater`] that applies
295    /// each later update.
296    pub fn with_dynamic_configuration(mut self, initial: ConfigUpdate) -> Self {
297        self.provider_sources.push(ProviderSource::Dynamic(Some(initial)));
298        self
299    }
300
301    /// Consumes the configuration loader, deserializing it as `T`.
302    ///
303    /// ## Errors
304    ///
305    /// If the configuration couldn't be deserialized into `T`, an error will be returned.
306    pub fn into_typed<'a, T>(self) -> Result<T, ConfigurationError>
307    where
308        T: Deserialize<'a>,
309    {
310        let figment = build_figment_from_sources(&self.provider_sources);
311        figment.extract().map_err(Into::into)
312    }
313
314    /// Consumes the configuration loader and wraps it in a generic wrapper.
315    ///
316    /// If dynamic configuration is enabled, the returned configuration already contains the initial update, and this
317    /// also returns the [`ConfigUpdater`] that applies each later update.
318    ///
319    /// ## Errors
320    ///
321    /// If dynamic configuration is enabled and the initial configuration can't be extracted from its providers, an
322    /// error will be returned.
323    pub async fn into_generic(mut self) -> Result<(GenericConfiguration, Option<ConfigUpdater>), ConfigurationError> {
324        let has_dynamic_provider = self
325            .provider_sources
326            .iter()
327            .any(|s| matches!(s, ProviderSource::Dynamic(_)));
328
329        if has_dynamic_provider {
330            let mut initial_opt = None;
331            for source in self.provider_sources.iter_mut() {
332                if let ProviderSource::Dynamic(ref mut initial) = source {
333                    initial_opt = initial.take();
334                    break;
335                }
336            }
337            let initial = initial_opt.expect("Dynamic initial update should exist but was not found");
338
339            // Build the configuration from the initial update, so that no caller can read the configuration before it
340            // contains that update.
341            let mut dynamic_state = serde_json::Value::Null;
342            apply_to_dynamic_state(&mut dynamic_state, initial);
343            let figment = build_figment_with_dynamic_state(&self.provider_sources, &dynamic_state);
344            let current_config = figment.extract()?;
345
346            let (event_sender, _) = broadcast::channel(100);
347
348            let generic_config = GenericConfiguration {
349                inner: Arc::new(Inner {
350                    figment: RwLock::new(figment),
351                    lookup_sources: self.lookup_sources,
352                    event_sender: Some(event_sender.clone()),
353                }),
354            };
355
356            let updater = ConfigUpdater {
357                inner: generic_config.inner.clone(),
358                provider_sources: self.provider_sources,
359                sender: event_sender,
360                dynamic_state,
361                current_config,
362            };
363
364            Ok((generic_config, Some(updater)))
365        } else {
366            // Otherwise, just build the static configuration.
367            let figment = build_figment_from_sources(&self.provider_sources);
368
369            let generic_config = GenericConfiguration {
370                inner: Arc::new(Inner {
371                    figment: RwLock::new(figment),
372                    lookup_sources: self.lookup_sources,
373                    event_sender: None,
374                }),
375            };
376
377            Ok((generic_config, None))
378        }
379    }
380
381    /// Configures a [`GenericConfiguration`] that's suitable for tests.
382    ///
383    /// This configures the loader with the following defaults:
384    ///
385    /// - configuration from a JSON file
386    /// - configuration from environment variables
387    ///
388    /// If `initial_snapshot` is set, dynamic configuration is enabled: the returned configuration already contains that
389    /// snapshot, and a dynamic configuration sender is returned for each later update. This function also spawns a task
390    /// on the current runtime that applies each update the sender sends.
391    ///
392    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate
393    /// testing scenarios.
394    #[cfg(any(test, feature = "test-util"))]
395    pub async fn for_tests(
396        file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
397        initial_snapshot: Option<ConfigUpdate>,
398    ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>) {
399        Self::for_tests_with_provider_factory(file_values, env_vars, initial_snapshot, |_| {
400            Serialized::defaults(serde_json::json!({}))
401        })
402        .await
403    }
404
405    /// Like [`for_tests`][Self::for_tests], but calls `provider_factory` to build an additional
406    /// provider inserted between the file provider and the environment provider.
407    ///
408    /// The factory receives an owned copy of the explicitly configured test environment variables. Providers can use
409    /// this input instead of reading unrelated variables from the ambient process environment. The factory is called
410    /// after the test environment variables have been set for providers that still require process environment access.
411    ///
412    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate testing scenarios.
413    #[cfg(any(test, feature = "test-util"))]
414    pub async fn for_tests_with_provider_factory<P, F>(
415        file_values: Option<serde_json::Value>, env_vars: Option<&[(String, String)]>,
416        initial_snapshot: Option<ConfigUpdate>, provider_factory: F,
417    ) -> (GenericConfiguration, Option<tokio::sync::mpsc::Sender<ConfigUpdate>>)
418    where
419        P: Provider + Send + Sync + 'static,
420        F: FnOnce(Vec<(String, String)>) -> P,
421    {
422        let json_file = tempfile::NamedTempFile::new().expect("should not fail to create temp file.");
423        let path = &json_file.path();
424        let json_to_write = file_values.unwrap_or(serde_json::json!({}));
425        serde_json::to_writer(&json_file, &json_to_write).expect("should not fail to write to temp file.");
426
427        let mut loader = ConfigurationLoader::default().try_from_json(path);
428        if let Some(initial_snapshot) = initial_snapshot {
429            loader = loader.with_dynamic_configuration(initial_snapshot);
430        }
431
432        // All tests that mutate process-wide environment variables while loading configuration serialize against a
433        // single shared lock (see `test_env_lock`), so that tests in other modules and crates can't race with the
434        // env-var manipulation below.
435        let guard = test_env_lock();
436
437        if let Some(pairs) = env_vars.as_ref() {
438            for (k, v) in pairs.iter() {
439                // Set under both the raw name and the TEST_ prefix:
440                //   - Raw name: available to any env-reading providers built by `provider_factory`
441                //   - TEST_ prefix: read by from_environment("TEST") (simulates DD_ prefix)
442                std::env::set_var(k, v);
443                std::env::set_var(format!("TEST_{}", k), v);
444            }
445        }
446
447        // Build and insert the extra provider while env vars are set so it can snapshot them.
448        let provider_env_vars = env_vars.unwrap_or_default().to_vec();
449        let loader = loader.add_providers([provider_factory(provider_env_vars)]);
450
451        // Add environment provider last so it has the highest precedence.
452        let loader = loader
453            .from_environment("TEST")
454            .expect("should not fail to add environment provider");
455
456        // Clean up test-provided env vars now that all providers have been built.
457        if let Some(pairs) = env_vars.as_ref() {
458            for (k, _) in pairs.iter() {
459                std::env::remove_var(k);
460                std::env::remove_var(format!("TEST_{}", k));
461            }
462        }
463
464        drop(guard);
465
466        let (cfg, updater) = loader
467            .into_generic()
468            .await
469            .expect("should not fail to build generic configuration");
470        let maybe_sender = updater.map(|mut updater| {
471            let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
472            tokio::spawn(async move {
473                while let Some(update) = receiver.recv().await {
474                    updater
475                        .apply(update)
476                        .expect("should not fail to apply dynamic configuration update");
477                }
478            });
479            sender
480        });
481
482        (cfg, maybe_sender)
483    }
484}
485
486/// Acquires the process-global lock that serializes tests which mutate environment variables while loading
487/// configuration, returning the held guard.
488///
489/// [`ConfigurationLoader::for_tests`] and [`ConfigurationLoader::for_tests_with_provider_factory`] set and unset
490/// process-wide environment variables to simulate `DD_`-prefixed configuration. Because the process environment is
491/// global mutable state, any test in any crate that reads or writes environment variables relevant to configuration
492/// loading MUST hold this lock for the duration of that access, so that all such tests serialize against each other
493/// rather than racing.
494///
495/// This is exposed publicly so that tests in downstream crates can serialize against the same single lock that the
496/// loader itself uses, instead of each hand-rolling an independent (and therefore non-serializing) mutex.
497///
498/// Lock poisoning is intentionally ignored: the mutex guards no data (its guarded type is `()`), so a panic in
499/// another test while the lock was held leaves nothing in an inconsistent state. Propagating poisoning would only
500/// cascade an unrelated test failure into every later env-mutating test.
501#[cfg(any(test, feature = "test-util"))]
502pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
503    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
504    ENV_MUTEX.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
505}
506
507/// Builds a [`GenericConfiguration`] from an in-memory JSON body, for use in tests.
508///
509/// This is a thin convenience wrapper around [`ConfigurationLoader::for_tests`] for the common case of a test that
510/// only needs file-based configuration values, with no environment variables and no dynamic configuration. The
511/// dynamic-configuration sender that `for_tests` can optionally return is unused in that case and is discarded here.
512///
513/// Callers that need a typed configuration should build it from the returned [`GenericConfiguration`] (for example,
514/// via a `SomeConfiguration::from_configuration` constructor or [`GenericConfiguration::as_typed`]).
515///
516/// This is exposed publicly so that tests in downstream crates can share a single loader helper instead of
517/// re-implementing it per file.
518#[cfg(any(test, feature = "test-util"))]
519pub async fn config_from(file_values: serde_json::Value) -> GenericConfiguration {
520    let (config, _) = ConfigurationLoader::for_tests(Some(file_values), None, None).await;
521    config
522}
523
524fn build_figment_from_sources(sources: &[ProviderSource]) -> Figment {
525    sources.iter().fold(Figment::new(), |figment, source| match source {
526        ProviderSource::Static(p) => figment.admerge(p.clone()),
527        // No-op. The merging is handled by the updater task.
528        ProviderSource::Dynamic(_) => figment,
529    })
530}
531
532/// Inserts or updates a value for a key.
533///
534/// Intermediate objects are created if they don't exist.
535pub fn upsert(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
536    if !root.is_object() {
537        *root = serde_json::Value::Object(serde_json::Map::new());
538    }
539
540    let mut current = root;
541    // Create a new node for each segment if the key is dotted.
542    let mut segments = key.split('.').peekable();
543
544    while let Some(seg) = segments.next() {
545        let is_leaf = segments.peek().is_none();
546
547        // Ensure current is an object before operating
548        if !current.is_object() {
549            *current = serde_json::Value::Object(serde_json::Map::new());
550        }
551        let node = current.as_object_mut().expect("current node should be an object");
552
553        if is_leaf {
554            node.insert(seg.to_string(), value);
555            break;
556        } else {
557            // Ensure child exists and is an object
558            let should_create_node = match node.get(seg) {
559                Some(v) => !v.is_object(),
560                None => true,
561            };
562            // Check if we need to create an intermediate node if it doesn't exist.
563            if should_create_node {
564                node.insert(seg.to_string(), serde_json::Value::Object(serde_json::Map::new()));
565            }
566
567            // Advance the current node to the next level.
568            current = node.get_mut(seg).expect("should not fail to get nested object");
569        }
570    }
571}
572
573/// Applies dynamic configuration updates to a [`GenericConfiguration`].
574///
575/// [`ConfigurationLoader::into_generic`] returns this when dynamic configuration is enabled, after it has applied the
576/// initial update. The caller decides where later updates come from and when to apply them, by calling
577/// [`apply`][Self::apply] for each one.
578pub struct ConfigUpdater {
579    inner: Arc<Inner>,
580    provider_sources: Vec<ProviderSource>,
581    sender: broadcast::Sender<ConfigChangeEvent>,
582    dynamic_state: serde_json::Value,
583    current_config: figment::value::Value,
584}
585
586impl ConfigUpdater {
587    /// Applies an update to the dynamic configuration.
588    ///
589    /// A snapshot replaces the whole dynamic configuration, and a partial update sets one key. When this returns, the
590    /// configuration contains the update, and a change event has been sent for each key whose value changed.
591    ///
592    /// ## Errors
593    ///
594    /// If the updated configuration can't be extracted from its providers, an error will be returned, and the
595    /// configuration keeps its previous values. The update is still recorded, so each later update applies on top of
596    /// it.
597    pub fn apply(&mut self, update: ConfigUpdate) -> Result<(), ConfigurationError> {
598        apply_to_dynamic_state(&mut self.dynamic_state, update);
599
600        // Rebuild the figment object on every update, respecting the original provider order.
601        let new_figment = build_figment_with_dynamic_state(&self.provider_sources, &self.dynamic_state);
602        let new_config: figment::value::Value = new_figment.extract()?;
603
604        if self.current_config != new_config {
605            let changes = dynamic::diff_config(&self.current_config, &new_config);
606
607            {
608                let mut figment_guard = self.inner.figment.write().unwrap_or_else(|e| {
609                    error!("Failed to acquire write lock for dynamic configuration: {}", e);
610                    e.into_inner()
611                });
612                *figment_guard = new_figment;
613            }
614
615            for change in changes {
616                // Send the change event to any receivers of the dynamic handler.
617                // If there are no receivers, `send` will fail. This is expected and fine,
618                // so we can ignore the error to avoid log spam.
619                let _ = self.sender.send(change);
620            }
621
622            // Update our "current" state for the next update.
623            self.current_config = new_config;
624        }
625
626        Ok(())
627    }
628}
629
630/// Applies one update to the dynamic state: a snapshot replaces it, and a partial update sets one (possibly dotted) key.
631fn apply_to_dynamic_state(dynamic_state: &mut serde_json::Value, update: ConfigUpdate) {
632    // The by-key view is an effective-value view: it holds what each key resolves to, so it drops the
633    // provenance carried by each setting.
634    match update {
635        ConfigUpdate::Snapshot(settings) => {
636            debug!("Received configuration snapshot update.");
637            *dynamic_state = settings_to_state(&settings);
638        }
639        ConfigUpdate::Partial(setting) => {
640            debug!(key = %setting.key, "Received partial configuration update.");
641            if dynamic_state.is_null() {
642                *dynamic_state = serde_json::Value::Object(serde_json::Map::new());
643            }
644            if dynamic_state.is_object() {
645                upsert(dynamic_state, &setting.key, setting.value);
646            } else {
647                error!("Received partial update but current dynamic state is not an object. This should not happen.");
648            }
649        }
650    }
651}
652
653/// Builds the figment from every provider source, in order, with `dynamic_state` in place of the dynamic provider.
654fn build_figment_with_dynamic_state(sources: &[ProviderSource], dynamic_state: &serde_json::Value) -> Figment {
655    sources.iter().fold(Figment::new(), |figment, source| match source {
656        ProviderSource::Static(p) => figment.admerge(p.clone()),
657        ProviderSource::Dynamic(_) => figment.admerge(Serialized::defaults(dynamic_state.clone())),
658    })
659}
660
661#[derive(Debug)]
662struct Inner {
663    figment: RwLock<Figment>,
664    lookup_sources: HashSet<LookupSource>,
665    event_sender: Option<broadcast::Sender<ConfigChangeEvent>>,
666}
667
668/// A generic configuration object.
669///
670/// This represents the merged configuration derived from [`ConfigurationLoader`] in its raw form. Values can be
671/// queried by key, and can be extracted either as typed values or in their raw form.
672///
673/// Keys must be in the form of `a.b.c`, where periods (`.`) as used to indicate a nested value.
674///
675/// Using an example JSON configuration:
676///
677/// ```json
678/// {
679///   "a": {
680///     "b": {
681///       "c": "value"
682///     }
683///   }
684/// }
685/// ```
686///
687/// Querying for the value of `a.b.c` would return `"value"`, and querying for `a.b` would return the nested object `{
688/// "c": "value" }`.
689#[derive(Clone, Debug)]
690pub struct GenericConfiguration {
691    inner: Arc<Inner>,
692}
693
694impl GenericConfiguration {
695    fn get<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
696    where
697        T: Deserialize<'a>,
698    {
699        let figment_guard = self.inner.figment.read().unwrap();
700        match figment_guard.extract_inner(key) {
701            Ok(value) => Ok(value),
702            Err(e) => {
703                if matches!(e.kind, figment::error::Kind::MissingField(_)) {
704                    // We might have been given a key that uses nested notation -- `foo.bar` -- but is only present in the
705                    // environment variables. We specifically don't want to use a different separator in environment
706                    // variables to map to nested key separators, so we simply try again here but with all nested key
707                    // separators (`.`) replaced with `_`, to match environment variables.
708                    let fallback_key = key.replace('.', "_");
709                    figment_guard
710                        .extract_inner(&fallback_key)
711                        .map_err(|fallback_e| from_figment_error(&self.inner.lookup_sources, fallback_e))
712                } else {
713                    Err(e.into())
714                }
715            }
716        }
717    }
718
719    /// Gets a configuration value by key.
720    ///
721    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
722    ///
723    /// ## Errors
724    ///
725    /// If the key doesn't exist in the configuration, or if the value couldn't be deserialized into `T`, an error
726    /// variant will be returned.
727    pub fn get_typed<'a, T>(&self, key: &str) -> Result<T, ConfigurationError>
728    where
729        T: Deserialize<'a>,
730    {
731        self.get(key)
732    }
733
734    /// Gets a configuration value by key, or the default value if a key doesn't exist or couldn't be deserialized.
735    ///
736    /// The `Default` implementation of `T` will be used both if the key couldn't be found, as well as for any error
737    /// during deserialization. This effectively swallows any errors and should generally be used sparingly.
738    ///
739    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
740    pub fn get_typed_or_default<'a, T>(&self, key: &str) -> T
741    where
742        T: Default + Deserialize<'a>,
743    {
744        self.get(key).unwrap_or_default()
745    }
746
747    /// Gets a configuration value by key, if it exists.
748    ///
749    /// If the key exists in the configuration, and can be deserialized, `Ok(Some(value))` is returned. Otherwise,
750    /// `Ok(None)` will be returned.
751    ///
752    /// The key must be in the form of `a.b.c`, where periods (`.`) are used to indicate a nested lookup.
753    ///
754    /// ## Errors
755    ///
756    /// If the value couldn't be deserialized into `T`, an error will be returned.
757    pub fn try_get_typed<'a, T>(&self, key: &str) -> Result<Option<T>, ConfigurationError>
758    where
759        T: Deserialize<'a>,
760    {
761        match self.get(key) {
762            Ok(value) => Ok(Some(value)),
763            Err(ConfigurationError::MissingField { .. }) => Ok(None),
764            Err(e) => Err(e),
765        }
766    }
767
768    /// Attempts to deserialize the entire configuration as `T`.
769    ///
770    /// ## Errors
771    ///
772    /// If the value couldn't be deserialized into `T`, an error will be returned.
773    pub fn as_typed<'a, T>(&self) -> Result<T, ConfigurationError>
774    where
775        T: Deserialize<'a>,
776    {
777        self.inner
778            .figment
779            .read()
780            .unwrap()
781            .extract()
782            .map_err(|e| from_figment_error(&self.inner.lookup_sources, e))
783    }
784
785    /// Subscribes for updates to the configuration.
786    pub fn subscribe_for_updates(&self) -> Option<broadcast::Receiver<dynamic::ConfigChangeEvent>> {
787        self.inner.event_sender.as_ref().map(|s| s.subscribe())
788    }
789
790    /// Extracts the entire configuration as a flat list of dot-separated key paths and their values.
791    ///
792    /// Nested JSON objects are descended into, joining keys with dots. Arrays, strings, numbers,
793    /// bools, and nulls are leaf values and are never descended into. The returned values are
794    /// never `Value::Object`.
795    ///
796    /// ## Errors
797    ///
798    /// If the configuration couldn't be serialized to JSON, an error will be returned.
799    pub fn flattened_keys(&self) -> Result<Vec<(String, serde_json::Value)>, ConfigurationError> {
800        let root: serde_json::Value = self.as_typed()?;
801        let mut out = Vec::new();
802        flatten_value(&root, &mut String::new(), &mut out);
803        Ok(out)
804    }
805
806    /// Creates a watcher that yields only when the given key changes.
807    ///
808    /// If dynamic configuration is disabled, the returned watcher's `changed()`
809    /// will wait indefinitely.
810    pub fn watch_for_updates(&self, key: &str) -> FieldUpdateWatcher {
811        FieldUpdateWatcher {
812            key: key.to_string(),
813            rx: self.subscribe_for_updates(),
814        }
815    }
816}
817
818/// Recursively descend into the tree building a dot-separated path until hitting a non-Object leaf node.
819fn flatten_value(value: &serde_json::Value, prefix: &mut String, out: &mut Vec<(String, serde_json::Value)>) {
820    if let serde_json::Value::Object(map) = value {
821        for (key, child) in map {
822            let prev_len = prefix.len();
823            if !prefix.is_empty() {
824                prefix.push('.');
825            }
826            prefix.push_str(key);
827            flatten_value(child, prefix, out);
828            prefix.truncate(prev_len);
829        }
830    } else {
831        out.push((prefix.clone(), value.clone()));
832    }
833}
834
835fn from_figment_error(lookup_sources: &HashSet<LookupSource>, e: figment::Error) -> ConfigurationError {
836    match e.kind {
837        Kind::MissingField(field) => {
838            let mut valid_keys = lookup_sources
839                .iter()
840                .map(|source| source.transform_key(&field))
841                .collect::<Vec<_>>();
842
843            // Always specify the original key as a valid key to try.
844            valid_keys.insert(0, field.to_string());
845
846            let help_text = format!("Try setting `{}`.", valid_keys.join("` or `"));
847
848            ConfigurationError::MissingField { help_text, field }
849        }
850        Kind::InvalidType(actual_ty, expected_ty) => ConfigurationError::InvalidFieldType {
851            field: e.path.join("."),
852            expected_ty,
853            actual_ty: actual_ty.to_string(),
854        },
855        _ => ConfigurationError::Generic { source: e.into() },
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::dynamic::ConfigSetting;
862    use super::*;
863
864    #[tokio::test]
865    async fn static_configuration() {
866        let (cfg, _) = ConfigurationLoader::for_tests(
867            Some(serde_json::json!({
868                "foo": "bar",
869                "baz": 5,
870                "foobar": { "a": false, "b": "c" }
871            })),
872            Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
873            None,
874        )
875        .await;
876
877        assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
878        assert_eq!(cfg.get_typed::<i32>("baz").unwrap(), 5);
879        assert!(!cfg.get_typed::<bool>("foobar.a").unwrap());
880        assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
881        assert!(matches!(
882            cfg.get::<String>("nonexistentKey"),
883            Err(ConfigurationError::MissingField { .. })
884        ));
885    }
886
887    #[tokio::test]
888    async fn dynamic_configuration() {
889        let (cfg, sender) = ConfigurationLoader::for_tests(
890            Some(serde_json::json!({
891                "foo": "bar",
892                "baz": 5,
893                "foobar": { "a": false, "b": "c" }
894            })),
895            Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
896            Some(ConfigUpdate::snapshot([ConfigSetting::explicit(
897                "new",
898                serde_json::json!("from_snapshot"),
899            )])),
900        )
901        .await;
902        let sender = sender.expect("sender should exist");
903
904        // Test that existing values still exist.
905        assert_eq!(cfg.get_typed::<String>("foo").unwrap(), "bar");
906
907        // Test that new values from the snapshot exist.
908        assert_eq!(cfg.get_typed::<String>("new").unwrap(), "from_snapshot");
909
910        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
911
912        sender
913            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
914                "new_key",
915                "from dynamic update".to_string().into(),
916            )))
917            .await
918            .unwrap();
919
920        tokio::time::timeout(std::time::Duration::from_secs(2), async {
921            loop {
922                match rx.recv().await {
923                    Ok(ev) if ev.key == "new_key" => break ev,
924                    Err(e) => panic!("updates channel closed: {e}"),
925                    Ok(_) => continue,
926                }
927            }
928        })
929        .await
930        .expect("timed out waiting for new_key update");
931
932        assert_eq!(cfg.get_typed::<String>("new_key").unwrap(), "from dynamic update");
933
934        // Test that an update with a nested key is applied.
935        sender
936            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
937                "foobar.a",
938                serde_json::json!(true),
939            )))
940            .await
941            .unwrap();
942
943        tokio::time::timeout(std::time::Duration::from_secs(2), async {
944            loop {
945                match rx.recv().await {
946                    Ok(ev) if ev.key == "foobar.a" => break ev,
947                    Err(e) => panic!("updates channel closed: {e}"),
948                    Ok(_) => continue,
949                }
950            }
951        })
952        .await
953        .expect("timed out waiting for foobar.a update");
954
955        assert!(cfg.get_typed::<bool>("foobar.a").unwrap());
956        assert_eq!(cfg.get_typed::<String>("foobar.b").unwrap(), "c");
957    }
958
959    #[test]
960    fn update_events_are_sent_after_the_figment_map_is_updated() {
961        fn recv_with_timeout(
962            rx: &mut tokio::sync::broadcast::Receiver<ConfigChangeEvent>, timeout: std::time::Duration,
963        ) -> Option<ConfigChangeEvent> {
964            let deadline = std::time::Instant::now() + timeout;
965            loop {
966                match rx.try_recv() {
967                    Ok(event) => return Some(event),
968                    Err(tokio::sync::broadcast::error::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
969                        std::thread::sleep(std::time::Duration::from_millis(1));
970                    }
971                    Err(tokio::sync::broadcast::error::TryRecvError::Empty) => return None,
972                    Err(e) => panic!("updates channel failed: {e}"),
973                }
974            }
975        }
976
977        let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
978        let (cfg, sender) = runtime.block_on(async {
979            let (cfg, sender) = ConfigurationLoader::for_tests(
980                None,
981                None,
982                Some(ConfigUpdate::snapshot([ConfigSetting::explicit(
983                    "observed",
984                    serde_json::json!("old"),
985                )])),
986            )
987            .await;
988            (cfg, sender.expect("sender should exist"))
989        });
990
991        let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
992        let (started_tx, started_rx) = std::sync::mpsc::channel();
993        let runtime_thread = std::thread::spawn(move || {
994            runtime.block_on(async {
995                started_tx.send(()).unwrap();
996                let _ = stop_rx.await;
997            });
998        });
999        started_rx.recv().unwrap();
1000
1001        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1002        let figment_guard = cfg.inner.figment.read().unwrap();
1003
1004        sender
1005            .blocking_send(ConfigUpdate::Partial(ConfigSetting::explicit(
1006                "observed",
1007                serde_json::json!("new"),
1008            )))
1009            .unwrap();
1010
1011        let early_event = recv_with_timeout(&mut rx, std::time::Duration::from_millis(100));
1012        assert!(
1013            early_event.is_none(),
1014            "update event arrived before the figment map changed"
1015        );
1016
1017        drop(figment_guard);
1018
1019        let event = recv_with_timeout(&mut rx, std::time::Duration::from_secs(2))
1020            .expect("timed out waiting for observed update");
1021        assert_eq!(event.key, "observed");
1022        assert_eq!(cfg.get_typed::<String>("observed").unwrap(), "new");
1023
1024        let _ = stop_tx.send(());
1025        runtime_thread.join().unwrap();
1026    }
1027
1028    #[tokio::test]
1029    async fn environment_precedence_over_dynamic() {
1030        let (cfg, sender) = ConfigurationLoader::for_tests(
1031            Some(serde_json::json!({
1032                "foo": "bar",
1033                "baz": 5,
1034                "foobar": { "a": false, "b": "c" }
1035            })),
1036            Some(&[("ENV_VAR".to_string(), "from_env".to_string())]),
1037            Some(ConfigUpdate::snapshot([ConfigSetting::explicit(
1038                "env_var",
1039                serde_json::json!("from_snapshot_env_var"),
1040            )])),
1041        )
1042        .await;
1043        let sender = sender.expect("sender should exist");
1044
1045        // Env provider has highest precedence so the snapshot should not override it.
1046        assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1047
1048        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1049
1050        // Send a partial update that attempts to override the env-backed key.
1051        sender
1052            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1053                "env_var",
1054                serde_json::json!("from_partial"),
1055            )))
1056            .await
1057            .unwrap();
1058
1059        // Also attempt to override the nested env-backed key via dynamic.
1060        sender
1061            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1062                "foobar.a",
1063                serde_json::json!(false),
1064            )))
1065            .await
1066            .unwrap();
1067
1068        // Send a dummy partial update to ensure the updater has processed prior partials.
1069        sender
1070            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1071                "dummy",
1072                serde_json::json!(1),
1073            )))
1074            .await
1075            .unwrap();
1076
1077        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1078            loop {
1079                match rx.recv().await {
1080                    Ok(ev) if ev.key == "dummy" => break,
1081                    Err(e) => panic!("updates channel closed: {e}"),
1082                    Ok(_) => continue,
1083                }
1084            }
1085        })
1086        .await
1087        .expect("timed out waiting for sync marker");
1088
1089        assert_eq!(cfg.get_typed::<String>("env_var").unwrap(), "from_env");
1090    }
1091
1092    #[tokio::test]
1093    async fn dynamic_configuration_add_new_nested_key() {
1094        let (cfg, sender) = ConfigurationLoader::for_tests(
1095            Some(serde_json::json!({
1096                "foo": "bar",
1097                "baz": 5,
1098                "foobar": { "a": false, "b": "c" }
1099            })),
1100            None,
1101            Some(ConfigUpdate::snapshot([])),
1102        )
1103        .await;
1104        let sender = sender.expect("sender should exist");
1105
1106        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1107
1108        sender
1109            .send(ConfigUpdate::Partial(ConfigSetting::explicit(
1110                "new_parent.new_child",
1111                serde_json::json!(42),
1112            )))
1113            .await
1114            .unwrap();
1115
1116        // new_parent object did not exist before, so the diff will emit the object "new_parent"
1117        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1118            loop {
1119                match rx.recv().await {
1120                    Ok(ev) if ev.key == "new_parent" => break ev,
1121                    Err(e) => panic!("updates channel closed: {e}"),
1122                    Ok(_) => continue,
1123                }
1124            }
1125        })
1126        .await
1127        .expect("timed out waiting for new_parent.new_child update");
1128
1129        assert_eq!(cfg.get_typed::<i32>("new_parent.new_child").unwrap(), 42);
1130    }
1131
1132    #[tokio::test]
1133    async fn underscore_fallback_on_get() {
1134        let (cfg, _) = ConfigurationLoader::for_tests(
1135            Some(serde_json::json!({})),
1136            Some(&[("RANDOM_KEY".to_string(), "from_env_only".to_string())]),
1137            None,
1138        )
1139        .await;
1140
1141        assert_eq!(cfg.get_typed::<String>("random.key").unwrap(), "from_env_only");
1142    }
1143
1144    #[tokio::test]
1145    async fn underscore_fallback_on_get_multi_segment_key() {
1146        // A single-underscore Agent-style env var (e.g. `DD_DATA_PLANE_API_LISTEN_ADDRESS`, which
1147        // `for_tests` simulates with the `TEST_` prefix) produces a flat figment key. A deeply
1148        // nested `get`/`try_get_typed` query must still resolve it via the dot-to-underscore
1149        // fallback, so callers don't need double-underscore env vars for these keys.
1150        let (cfg, _) = ConfigurationLoader::for_tests(
1151            Some(serde_json::json!({})),
1152            Some(&[(
1153                "DATA_PLANE_API_LISTEN_ADDRESS".to_string(),
1154                "tcp://0.0.0.0:55100".to_string(),
1155            )]),
1156            None,
1157        )
1158        .await;
1159
1160        assert_eq!(
1161            cfg.try_get_typed::<String>("data_plane.api_listen_address").unwrap(),
1162            Some("tcp://0.0.0.0:55100".to_string()),
1163        );
1164    }
1165
1166    #[tokio::test]
1167    async fn static_configuration_has_no_updates() {
1168        let (cfg, maybe_sender) = ConfigurationLoader::for_tests(Some(serde_json::json!({})), None, None).await;
1169        assert!(maybe_sender.is_none());
1170        assert!(cfg.subscribe_for_updates().is_none());
1171    }
1172
1173    #[tokio::test]
1174    async fn updater_applies_each_update_before_returning() {
1175        let (cfg, updater) = ConfigurationLoader::default()
1176            .with_dynamic_configuration(ConfigUpdate::snapshot([ConfigSetting::explicit(
1177                "key",
1178                serde_json::json!("from_snapshot"),
1179            )]))
1180            .into_generic()
1181            .await
1182            .expect("should not fail to build generic configuration");
1183        let mut updater = updater.expect("dynamic configuration should return an updater");
1184        assert_eq!(cfg.get_typed::<String>("key").unwrap(), "from_snapshot");
1185
1186        let mut rx = cfg.subscribe_for_updates().expect("dynamic updates should be enabled");
1187        updater
1188            .apply(ConfigUpdate::Partial(ConfigSetting::explicit(
1189                "key",
1190                serde_json::json!("from_update"),
1191            )))
1192            .expect("should not fail to apply the update");
1193
1194        // Nothing runs in the background: the update and its change event are both visible as soon as `apply` returns.
1195        assert_eq!(cfg.get_typed::<String>("key").unwrap(), "from_update");
1196        let event = rx.try_recv().expect("the change event should already be sent");
1197        assert_eq!(event.key, "key");
1198    }
1199
1200    #[tokio::test]
1201    async fn flattened_keys_flat_and_nested() {
1202        let (cfg, _) = ConfigurationLoader::for_tests(
1203            Some(serde_json::json!({
1204                "top": "value",
1205                "nested": { "a": 1, "b": { "c": true } }
1206            })),
1207            None,
1208            None,
1209        )
1210        .await;
1211
1212        let pairs = cfg.flattened_keys().unwrap();
1213        let map: std::collections::HashMap<&str, &serde_json::Value> =
1214            pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1215
1216        assert_eq!(map.get("top"), Some(&&serde_json::json!("value")));
1217        assert_eq!(map.get("nested.a"), Some(&&serde_json::json!(1)));
1218        assert_eq!(map.get("nested.b.c"), Some(&&serde_json::json!(true)));
1219        assert!(!map.contains_key("nested"));
1220        assert!(!map.contains_key("nested.b"));
1221    }
1222
1223    #[tokio::test]
1224    async fn flattened_keys_arrays_are_leaves() {
1225        let (cfg, _) = ConfigurationLoader::for_tests(
1226            Some(serde_json::json!({
1227                "tags": ["a", "b"],
1228                "matrix": [[1, 2], [3, 4]]
1229            })),
1230            None,
1231            None,
1232        )
1233        .await;
1234
1235        let pairs = cfg.flattened_keys().unwrap();
1236        let map: std::collections::HashMap<&str, &serde_json::Value> =
1237            pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1238
1239        assert_eq!(map.get("tags"), Some(&&serde_json::json!(["a", "b"])));
1240        assert_eq!(map.get("matrix"), Some(&&serde_json::json!([[1, 2], [3, 4]])));
1241    }
1242
1243    #[tokio::test]
1244    async fn flattened_keys_null_values_absent() {
1245        let (cfg, _) = ConfigurationLoader::for_tests(
1246            Some(serde_json::json!({
1247                "present": "yes",
1248                "absent": null
1249            })),
1250            None,
1251            None,
1252        )
1253        .await;
1254
1255        let pairs = cfg.flattened_keys().unwrap();
1256        let map: std::collections::HashMap<&str, &serde_json::Value> =
1257            pairs.iter().map(|(k, v)| (k.as_str(), v)).collect();
1258
1259        assert_eq!(map.get("present"), Some(&&serde_json::json!("yes")));
1260        // Figment drops null values during deserialization, so they are absent from the output.
1261        assert!(!map.contains_key("absent"));
1262    }
1263
1264    #[tokio::test]
1265    async fn from_yaml_loads_configuration_file() {
1266        use std::io::Write as _;
1267
1268        let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1269        file.write_all(b"top: value\nnested:\n  inner: 7\n")
1270            .expect("should write temp file");
1271        file.flush().expect("should flush temp file");
1272
1273        let (cfg, _) = ConfigurationLoader::default()
1274            .from_yaml(file.path())
1275            .expect("YAML file should load")
1276            .into_generic()
1277            .await
1278            .expect("should build generic configuration");
1279
1280        assert_eq!(cfg.get_typed::<String>("top").unwrap(), "value");
1281        assert_eq!(cfg.get_typed::<i64>("nested.inner").unwrap(), 7);
1282    }
1283
1284    #[tokio::test]
1285    async fn try_from_yaml_ignores_unreadable_file() {
1286        // `try_from_yaml` swallows load errors (here, a nonexistent path), yielding a config with no values rather
1287        // than failing to build.
1288        let (cfg, _) = ConfigurationLoader::default()
1289            .try_from_yaml("/nonexistent/definitely/not/here.yaml")
1290            .into_generic()
1291            .await
1292            .expect("should build generic configuration even when the file is missing");
1293
1294        assert!(matches!(
1295            cfg.get::<String>("anything"),
1296            Err(ConfigurationError::MissingField { .. })
1297        ));
1298    }
1299
1300    #[tokio::test]
1301    async fn from_json_returns_error_for_invalid_file() {
1302        use std::io::Write as _;
1303
1304        let mut file = tempfile::NamedTempFile::new().expect("should create temp file");
1305        file.write_all(b"{ not valid json ").expect("should write temp file");
1306        file.flush().expect("should flush temp file");
1307
1308        let result = ConfigurationLoader::default().from_json(file.path());
1309        assert!(result.is_err(), "invalid JSON should fail to load at the loader level");
1310    }
1311}