Skip to main content

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