Skip to main content

dynamic_config/
source.rs

1//! What to load, and from where.
2
3/// Separator that introduces one level of nesting in a variable name.
4///
5/// A single underscore cannot mean both "word break" and "nesting", so nesting
6/// gets the doubled form: `APP_DB_POOL__MAX_SIZE` is `pool.max_size`, while
7/// `APP_DB_MAX_SIZE` is the single field `max_size`.
8pub const DEFAULT_NEST: &str = "__";
9
10/// A configuration file format.
11///
12/// Every variant exists regardless of which features are enabled; parsing one
13/// whose feature is off is a runtime [`ErrorKind::Backend`](crate::ErrorKind).
14/// Code written through `#[dynamic_config]` cannot reach that error — the macro
15/// turns it into a compile error naming the missing feature.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Format {
18    /// JSON, via the `json` feature.
19    Json,
20    /// TOML, via the `toml` feature.
21    Toml,
22    /// YAML, via the `yaml` feature.
23    Yaml,
24}
25
26impl Format {
27    /// The format a path's extension names.
28    ///
29    /// A `.age` suffix is looked *through*: `secrets.json.age` is JSON that
30    /// happens to be encrypted, so the format is the one under the suffix.
31    ///
32    /// # Errors
33    ///
34    /// If the name resolves to no supported format.
35    pub fn from_path(path: &std::path::Path) -> Result<Self, crate::Error> {
36        let named = path.to_str().unwrap_or_default();
37        let inner = inner_name(named).map_or(named, |(inner, _)| inner);
38
39        std::path::Path::new(inner)
40            .extension()
41            .and_then(|extension| extension.to_str())
42            .and_then(Self::from_extension)
43            .ok_or_else(|| crate::Error::unsupported(path))
44    }
45
46    /// The cargo feature that enables this format.
47    #[must_use]
48    pub fn feature(self) -> &'static str {
49        match self {
50            Self::Json => "json",
51            Self::Toml => "toml",
52            Self::Yaml => "yaml",
53        }
54    }
55
56    /// Infers a format from a file extension, case-insensitively.
57    ///
58    /// Returns `None` for an unknown or absent extension. The macro performs
59    /// the same inference at compile time.
60    #[must_use]
61    pub fn from_extension(extension: &str) -> Option<Self> {
62        match extension.to_ascii_lowercase().as_str() {
63            "json" => Some(Self::Json),
64            "toml" => Some(Self::Toml),
65            "yaml" | "yml" => Some(Self::Yaml),
66            _ => None,
67        }
68    }
69
70    /// Infers a format from a store key's extension: `app/config.json` is
71    /// JSON.
72    ///
73    /// What every remote store crate does with the key it was given, written
74    /// once. Returns `None` when the key has no extension or an unknown one —
75    /// the store's `with_format` exists for exactly that case.
76    #[must_use]
77    pub fn from_key(key: &str) -> Option<Self> {
78        std::path::Path::new(key)
79            .extension()
80            .and_then(|extension| extension.to_str())
81            .and_then(Self::from_extension)
82    }
83}
84
85/// No `Debug` or `PartialEq`: a `&dyn Provider` is neither, and equality of
86/// sources was never something a caller could rely on. `Source` renders itself
87/// by hand instead — see its `Debug` impl.
88#[derive(Clone, Copy)]
89enum Kind<'a> {
90    File(&'a str),
91    /// A file whose bytes are ciphertext. `format` describes the plaintext.
92    Encrypted(&'a str),
93    Inline(&'a str),
94    /// Somebody else's figment provider, merged in place.
95    #[cfg(feature = "figment")]
96    Provider(&'a (dyn figment::Provider + Send + Sync)),
97}
98
99/// One layer of configuration.
100///
101/// Constructors are `const`, so a `&'static [Source<'static>]` can live in a
102/// `static` — which is how the macro emits it. The lifetime is there for
103/// everything else: configuration assembled at runtime, fetched over the
104/// network, or read from a pipe borrows just as happily.
105#[derive(Clone, Copy)]
106pub struct Source<'a> {
107    kind: Kind<'a>,
108    format: Format,
109}
110
111impl<'a> Source<'a> {
112    /// A file on disk. A file that does not exist is skipped, not an error —
113    /// listing an optional `secrets.toml` is the whole point of layering.
114    #[must_use]
115    pub const fn file(path: &'a str, format: Format) -> Self {
116        Self {
117            kind: Kind::File(path),
118            format,
119        }
120    }
121
122    /// Configuration already in memory: a compiled-in default, a fixture, or
123    /// something just read off a socket.
124    ///
125    /// figment parses from a string, so anything implementing [`Read`] arrives
126    /// here through [`std::io::read_to_string`] rather than through a variant
127    /// of its own — a reader source would only hide that one line.
128    ///
129    /// ```
130    /// # #[cfg(feature = "json")] {
131    /// use dynamic_config::{load, Format, LoadSpec, Source};
132    /// use serde::Deserialize;
133    ///
134    /// #[derive(Deserialize)]
135    /// struct Db { host: String }
136    ///
137    /// let mut pipe = std::io::Cursor::new(r#"{"db": {"host": "localhost"}}"#);
138    /// let text = std::io::read_to_string(&mut pipe).unwrap();
139    ///
140    /// let sources = [Source::inline(&text, Format::Json)];
141    /// let db: Db = load(&LoadSpec::new("db", &sources)).unwrap();
142    ///
143    /// assert_eq!(db.host, "localhost");
144    /// # }
145    /// ```
146    ///
147    /// [`Read`]: std::io::Read
148    #[must_use]
149    pub const fn inline(text: &'a str, format: Format) -> Self {
150        Self {
151            kind: Kind::Inline(text),
152            format,
153        }
154    }
155
156    /// Configuration from a figment provider of your own.
157    ///
158    /// The three built-in kinds — a file, an encrypted file, inline text — are
159    /// the ones this crate can describe. A provider is anything figment can
160    /// read: `Serialized::defaults(T)`, an `Env` with a filter this crate does
161    /// not model, a provider you wrote, one from another crate.
162    ///
163    /// ```
164    /// # #[cfg(all(feature = "figment", feature = "json"))] {
165    /// use dynamic_config::{load, LoadSpec, Source};
166    /// use figment::providers::{Format as _, Json};
167    /// # use serde::Deserialize;
168    /// # #[derive(Deserialize)] struct Db { host: String }
169    ///
170    /// // `.nested()` because this crate reads a top-level key as a section.
171    /// let provider = Json::string(r#"{"db": {"host": "localhost"}}"#).nested();
172    /// let sources = [Source::provider(&provider)];
173    ///
174    /// let db: Db = load(&LoadSpec::new("db", &sources)).unwrap();
175    /// assert_eq!(db.host, "localhost");
176    /// # }
177    /// ```
178    ///
179    /// # Two things it is on you to get right
180    ///
181    /// **Sections.** Every other source here goes through this crate's own
182    /// mapping of top-level keys to sections. A provider does not: what it
183    /// yields is merged as figment sees it, so it has to produce the section as
184    /// a profile — `.nested()` on a figment `Data` provider does exactly that.
185    /// The loader namespaces section profiles internally (so a section named
186    /// `global` cannot collide with figment's reserved profiles); the prefix
187    /// is applied *for* the provider on the way in, and `default` / `global`
188    /// pass through untouched — for a provider author they are figment's own
189    /// vocabulary, deliberately reachable through this one door.
190    ///
191    /// **Provenance comes from the metadata's *source*, not its name.**
192    /// `Metadata::named("INI file")` alone leaves every value it supplies
193    /// answering [`Origin::Unknown`](crate::Origin::Unknown): the name
194    /// reaches error messages, but
195    /// [`source_of`](crate::source_of) reads the source. Set both —
196    /// `Metadata::from("INI file", path)` — and a value traces back to the
197    /// file that holds it, exactly as one from `.file(..)` does. A provider
198    /// that describes itself badly produces a diagnostic that describes it
199    /// badly; one that describes itself not at all produces none.
200    ///
201    /// The `Send + Sync` bound is not decoration: a `LoadSpec` is moved to
202    /// another thread by `load_async` and by the file watcher, so a provider
203    /// that cannot cross one would take those with it. Every provider figment
204    /// ships already satisfies it.
205    #[cfg(feature = "figment")]
206    #[cfg_attr(docsrs, doc(cfg(feature = "figment")))]
207    #[must_use]
208    pub const fn provider(provider: &'a (dyn figment::Provider + Send + Sync)) -> Self {
209        Self {
210            kind: Kind::Provider(provider),
211            // A provider parses nothing: it hands over values that are already
212            // figment's. `format()` says so by answering `None`.
213            format: Format::Json,
214        }
215    }
216
217    /// This source's format, for the kinds that parse text.
218    ///
219    /// `None` for a [`provider`](Self::provider), which hands over values that
220    /// are already figment's and never sees a byte of text.
221    #[must_use]
222    pub const fn format(&self) -> Option<Format> {
223        match self.kind {
224            #[cfg(feature = "figment")]
225            Kind::Provider(_) => None,
226            _ => Some(self.format),
227        }
228    }
229
230    /// Configuration in a file that is encrypted on disk.
231    ///
232    /// `format` is what the *plaintext* is, so `secrets.json.age` is
233    /// [`Format::Json`]. Reading it needs a decryptor installed with
234    /// [`set_decryptor`](crate::set_decryptor).
235    ///
236    /// In every other respect it is a file: same precedence, same profile
237    /// variants, watched the same way, skipped if it is not there.
238    #[must_use]
239    pub const fn encrypted(path: &'a str, format: Format) -> Self {
240        Self {
241            kind: Kind::Encrypted(path),
242            format,
243        }
244    }
245
246    /// The file path, if this source is a file — encrypted or not.
247    ///
248    /// Encrypted files are included because everything that asks this question
249    /// — profile variants, the directories to watch — wants the same answer for
250    /// both.
251    #[must_use]
252    pub const fn path(&self) -> Option<&'a str> {
253        match self.kind {
254            Kind::File(path) | Kind::Encrypted(path) => Some(path),
255            _ => None,
256        }
257    }
258
259    /// Whether this source has to be decrypted before it can be parsed.
260    #[must_use]
261    pub const fn is_encrypted(&self) -> bool {
262        matches!(self.kind, Kind::Encrypted(_))
263    }
264
265    /// The embedded text, if this source is inline.
266    pub(crate) fn inline_text(&self) -> Option<&'a str> {
267        match self.kind {
268            Kind::Inline(text) => Some(text),
269            _ => None,
270        }
271    }
272
273    /// The foreign provider, if this source is one.
274    #[cfg(feature = "figment")]
275    pub(crate) fn foreign(&self) -> Option<&'a (dyn figment::Provider + Send + Sync)> {
276        match self.kind {
277            Kind::Provider(provider) => Some(provider),
278            _ => None,
279        }
280    }
281}
282
283impl std::fmt::Debug for Source<'_> {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        match self.kind {
286            Kind::File(path) => f.debug_tuple("File").field(&path).finish(),
287            Kind::Encrypted(path) => f.debug_tuple("Encrypted").field(&path).finish(),
288            // Never the text: an inline source is as likely to hold secrets as
289            // a file is, and this is the type that would print them.
290            Kind::Inline(text) => f
291                .debug_struct("Inline")
292                .field("bytes", &text.len())
293                .field("format", &self.format)
294                .finish(),
295            #[cfg(feature = "figment")]
296            Kind::Provider(provider) => f
297                .debug_tuple("Provider")
298                .field(&provider.metadata().name)
299                .finish(),
300        }
301    }
302}
303
304/// The suffix that marks a config file as encrypted.
305///
306/// Lives here rather than with the decryption itself because the *naming* rules
307/// — which files are encrypted, what a profile variant of one is called — are
308/// needed whether or not this build can decrypt anything.
309pub(crate) const ENCRYPTED_SUFFIX: &str = "age";
310
311/// Splits `secrets.json.age` into `("secrets.json", "json")`.
312///
313/// `None` when the name does not end in the suffix, or has no extension under
314/// it to name a format.
315pub(crate) fn inner_name(path: &str) -> Option<(&str, &str)> {
316    let stripped = path.strip_suffix(ENCRYPTED_SUFFIX)?.strip_suffix('.')?;
317    let extension = std::path::Path::new(stripped)
318        .extension()
319        .and_then(|extension| extension.to_str())?;
320
321    Some((stripped, extension))
322}
323
324/// Everything the loader needs: which layers, which section, which env prefix.
325///
326/// Prefer [`new`](Self::new) and the `with_*` methods over a struct literal, so
327/// that a later release can add a knob without breaking every call site.
328#[derive(Clone, Copy)]
329pub struct LoadSpec<'a> {
330    /// The configuration section this maps to, e.g. `"db"`.
331    pub key: &'a str,
332    /// Layers, merged left to right. Later sources win.
333    pub sources: &'a [Source<'a>],
334    /// Environment variable prefix, e.g. `"APP_"`. Combined with `key`.
335    /// `None` ignores the environment entirely.
336    pub env_prefix: Option<&'a str>,
337    /// Where to look for configuration files by name, if anywhere.
338    ///
339    /// Discovered files are merged *before* [`sources`](Self::sources), so an
340    /// explicitly listed file still has the last word.
341    pub search: Option<crate::Search<'a>>,
342    /// Environment variable naming the active profile, e.g. `"APP_ENV"`.
343    ///
344    /// When it is set to `production`, every file gains a sibling layer:
345    /// `config.toml` is followed by `config.production.toml`, discovered or
346    /// listed alike. A variant that does not exist is skipped like any other
347    /// missing file.
348    pub profile_env: Option<&'a str>,
349    /// Values below the files: consulted only when nothing else supplies a key.
350    pub defaults: Option<&'a crate::Layer>,
351    /// A document fetched from a remote store: above the files, below the
352    /// environment.
353    pub remote: Option<&'a crate::Remote>,
354    /// A directory of single-value files — one file per key, the filename is
355    /// the key, the contents are the value.
356    ///
357    /// How Docker and Kubernetes mount secrets. Nesting is spelled in the
358    /// filename with [`nest`](Self::nest), so one setting governs this layer
359    /// and the environment alike; a directory that is not there is skipped
360    /// like a missing file.
361    pub secrets_dir: Option<&'a str>,
362    /// `.env` files, read as the environment layer rather than as documents.
363    ///
364    /// Merged in order, just *below* the real environment: a variable somebody
365    /// exported for this run should beat a file in the repository.
366    pub env_files: &'a [&'a str],
367    /// Old key paths that still resolve, filling a gap rather than overriding.
368    pub aliases: Option<&'a crate::Aliases>,
369    /// Fields bound to environment variables by name: just above the prefixed
370    /// environment layer, because a binding is the more specific statement.
371    pub env_bindings: Option<&'a crate::EnvBindings>,
372    /// Values from the command line: above the environment, below overrides.
373    pub flags: Option<&'a crate::Layer>,
374    /// Values above everything, including the environment.
375    pub overrides: Option<&'a crate::Layer>,
376    /// Separator that introduces nesting in an environment variable name.
377    ///
378    /// Defaults to `"__"`, so `APP_DB_POOL__MAX_SIZE` is `pool.max_size`. A
379    /// single separator cannot mean both "word break" and "nesting", so
380    /// whatever this is set to, it has to be something a field name will not
381    /// contain.
382    pub nest: &'a str,
383    /// Whether `FOO=` counts as set-to-empty.
384    ///
385    /// Defaults to `false`, which treats it as unset. An unset value rendered
386    /// into a deployment template leaves exactly `FOO=`, and letting that blank
387    /// out a perfectly good configured value is a bad afternoon. Turn it on
388    /// when empty really is a value you need to be able to send.
389    pub allow_empty_env: bool,
390    /// Rejects environment values from the yes/no/on/off family instead of
391    /// letting them arrive as strings where a boolean was meant.
392    pub strict_env: bool,
393    /// Whether the documents this reads carry a section header at all.
394    ///
395    /// `false` — the default — means every top-level key in a document is a
396    /// section, which is what lets one file serve several configuration
397    /// types and what [`key`](Self::key) selects out of it.
398    ///
399    /// `true` means the document *is* this section's values —
400    /// `{"host": "0.0.0.0", "port": 8000}`, with no `server` above it. The
401    /// key still names the load: the environment prefix, the cache entry
402    /// and what a diagnostic calls this configuration are all still built
403    /// from it. It simply stops being looked for inside the document. See
404    /// [`with_whole_document`](Self::with_whole_document).
405    pub whole_document: bool,
406}
407
408impl std::fmt::Debug for LoadSpec<'_> {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        f.debug_struct("LoadSpec")
411            .field("key", &self.key)
412            .field("sources", &self.sources)
413            .field("search", &self.search)
414            .field("profile_env", &self.profile_env)
415            .field("env_prefix", &self.env_prefix)
416            .field("defaults", &self.defaults.is_some())
417            .field("remote", &self.remote.is_some())
418            .field("secrets_dir", &self.secrets_dir)
419            .field("flags", &self.flags.is_some())
420            .field("overrides", &self.overrides.is_some())
421            .field("nest", &self.nest)
422            .field("allow_empty_env", &self.allow_empty_env)
423            .field("strict_env", &self.strict_env)
424            .field("whole_document", &self.whole_document)
425            .finish()
426    }
427}
428
429impl<'a> LoadSpec<'a> {
430    /// A spec that reads `sources` and selects `key`, ignoring the environment.
431    #[must_use]
432    pub const fn new(key: &'a str, sources: &'a [Source<'a>]) -> Self {
433        Self {
434            key,
435            sources,
436            search: None,
437            profile_env: None,
438            env_prefix: None,
439            defaults: None,
440            remote: None,
441            secrets_dir: None,
442            env_files: &[],
443            aliases: None,
444            env_bindings: None,
445            flags: None,
446            overrides: None,
447            nest: DEFAULT_NEST,
448            allow_empty_env: false,
449            strict_env: false,
450            whole_document: false,
451        }
452    }
453
454    /// Looks for `{name}.{ext}` in each of `paths`, in order.
455    ///
456    /// Every directory that has a match contributes one file, so the search
457    /// order *is* the layering order. Discovered files sit below the explicit
458    /// [`sources`](Self::sources).
459    #[must_use]
460    pub const fn with_search(mut self, name: &'a str, paths: &'a [&'a str]) -> Self {
461        self.search = Some(crate::Search::new(name, paths));
462        self
463    }
464
465    /// Layers a per-profile sibling over every file.
466    ///
467    /// `variable` names the environment variable holding the profile, so the
468    /// profile itself is resolved at load time rather than baked in.
469    #[must_use]
470    pub const fn with_profile_env(mut self, variable: &'a str) -> Self {
471        self.profile_env = Some(variable);
472        self
473    }
474
475    /// Values consulted only when no file and no variable supplies a key.
476    #[must_use]
477    pub const fn with_defaults(mut self, layer: &'a crate::Layer) -> Self {
478        self.defaults = Some(layer);
479        self
480    }
481
482    /// `.env` files, merged just below the real environment.
483    ///
484    /// Needs the `dotenv` feature; without it, a non-empty list is an error at
485    /// load time naming the feature rather than a list silently ignored.
486    #[must_use]
487    pub const fn with_env_files(mut self, files: &'a [&'a str]) -> Self {
488        self.env_files = files;
489        self
490    }
491
492    /// Old key paths that still resolve.
493    #[must_use]
494    pub const fn with_aliases(mut self, aliases: &'a crate::Aliases) -> Self {
495        self.aliases = Some(aliases);
496        self
497    }
498
499    /// Fields bound to environment variables by name.
500    #[must_use]
501    pub const fn with_env_bindings(mut self, bindings: &'a crate::EnvBindings) -> Self {
502        self.env_bindings = Some(bindings);
503        self
504    }
505
506    /// A remote store's document, layered over the files.
507    #[must_use]
508    pub const fn with_remote(mut self, remote: &'a crate::Remote) -> Self {
509        self.remote = Some(remote);
510        self
511    }
512
513    /// A directory of single-value files, layered just below the `.env` files
514    /// and the environment.
515    ///
516    /// One directory level: every regular file in it is one key, named by the
517    /// file and valued by its contents with a single trailing newline
518    /// removed. Subdirectories are not descended into — nesting is spelled in
519    /// the filename with [`with_nest`](Self::with_nest), which is what a
520    /// Kubernetes mount produces anyway.
521    #[must_use]
522    pub const fn with_secrets_dir(mut self, path: &'a str) -> Self {
523        self.secrets_dir = Some(path);
524        self
525    }
526
527    /// Values from the command line, layered over the environment.
528    #[must_use]
529    pub const fn with_flags(mut self, layer: &'a crate::Layer) -> Self {
530        self.flags = Some(layer);
531        self
532    }
533
534    /// Values that win over the files, the environment and the flags alike.
535    #[must_use]
536    pub const fn with_overrides(mut self, layer: &'a crate::Layer) -> Self {
537        self.overrides = Some(layer);
538        self
539    }
540
541    /// Layers environment variables named `{prefix}{KEY}_*` over the files.
542    #[must_use]
543    pub const fn with_env(mut self, prefix: &'a str) -> Self {
544        self.env_prefix = Some(prefix);
545        self
546    }
547
548    /// Uses `separator` instead of `__` to introduce nesting.
549    #[must_use]
550    pub const fn with_nest(mut self, separator: &'a str) -> Self {
551        self.nest = separator;
552        self
553    }
554
555    /// Treats `FOO=` as set-to-empty rather than unset.
556    #[must_use]
557    pub const fn with_empty_env(mut self, allow: bool) -> Self {
558        self.allow_empty_env = allow;
559        self
560    }
561
562    /// Rejects ambiguous environment spellings instead of guessing.
563    ///
564    /// `APP_DB_TLS=off` reads like a boolean and arrives as the string
565    /// `"off"` — silently correct into a `String` field, silently wrong
566    /// everywhere else. Strict mode makes the yes/no/on/off family (and
567    /// `null`/`nil`/`none`) an error naming the variable; write `true`,
568    /// `false`, or the value you actually mean.
569    #[must_use]
570    pub const fn with_strict_env(mut self, strict: bool) -> Self {
571        self.strict_env = strict;
572        self
573    }
574
575    /// Reads each document as this section's values, with no section header.
576    ///
577    /// The default layout is one file, several sections: every top-level key
578    /// names one, and [`key`](Self::key) says which is yours. That is what
579    /// lets a `config.toml` hold `[db]` and `[server]` for two configuration
580    /// types that know nothing about each other.
581    ///
582    /// A file that is *only* this configuration has no use for the header,
583    /// and a file this crate did not write may not have one to begin with —
584    /// a container image's `{"host": "0.0.0.0", "port": 8000}`, a chart's
585    /// rendered values, a file some other tool owns. This says so.
586    ///
587    /// Everything else is unchanged, and that is the point: the environment
588    /// prefix is still `{prefix}{KEY}_`, profile variants
589    /// (`config.production.toml`) still layer on top, defaults, flags,
590    /// overrides, aliases, the secrets directory, the cache and every
591    /// diagnostic all behave exactly as they do for a sectioned load. Only
592    /// where a document's values are found changes.
593    ///
594    /// It applies to **every** document this spec reads — listed files,
595    /// discovered files, inline text and the remote store's document —
596    /// because a load whose sources disagreed about their own shape would be
597    /// a load nobody could reason about.
598    #[must_use]
599    pub const fn with_whole_document(mut self, whole: bool) -> Self {
600        self.whole_document = whole;
601        self
602    }
603
604    /// The environment variable that names the profile, if configured.
605    pub(crate) fn profile_variable(&self) -> Option<&'a str> {
606        self.profile_env
607    }
608
609    /// The active profile, if one is named and set to something.
610    pub(crate) fn profile(&self) -> Option<String> {
611        self.profile_env
612            .and_then(|variable| std::env::var(variable).ok())
613            .map(|profile| profile.trim().to_owned())
614            .filter(|profile| !profile.is_empty())
615    }
616
617    /// The full environment prefix for this section: `"APP_"` + `"db"` → `"APP_DB_"`.
618    ///
619    /// An empty key contributes nothing rather than an extra underscore: a
620    /// whole-document load may have no name to give the section, and
621    /// `APP__HOST` is a variable nobody would guess they had to set.
622    pub(crate) fn full_env_prefix(&self) -> Option<String> {
623        self.env_prefix.map(|prefix| {
624            if self.key.is_empty() {
625                prefix.to_owned()
626            } else {
627                format!("{prefix}{}_", self.key.to_ascii_uppercase())
628            }
629        })
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn extensions_map_to_formats_case_insensitively() {
639        assert_eq!(Format::from_extension("JSON"), Some(Format::Json));
640        assert_eq!(Format::from_extension("yml"), Some(Format::Yaml));
641        assert_eq!(Format::from_extension("yaml"), Some(Format::Yaml));
642        assert_eq!(Format::from_extension("ini"), None);
643    }
644
645    #[test]
646    fn the_env_prefix_combines_the_caller_prefix_with_the_key() {
647        let spec = LoadSpec::new("db", &[]).with_env("APP_");
648
649        assert_eq!(spec.full_env_prefix().as_deref(), Some("APP_DB_"));
650    }
651
652    #[test]
653    fn no_env_prefix_means_no_environment() {
654        assert_eq!(LoadSpec::new("db", &[]).full_env_prefix(), None);
655    }
656
657    #[test]
658    fn an_empty_environment_variable_is_unset_by_default() {
659        assert!(!LoadSpec::new("db", &[]).allow_empty_env);
660        assert!(
661            LoadSpec::new("db", &[])
662                .with_empty_env(true)
663                .allow_empty_env
664        );
665    }
666
667    #[test]
668    fn only_file_sources_expose_a_path() {
669        assert_eq!(Source::file("a.json", Format::Json).path(), Some("a.json"));
670        assert_eq!(Source::inline("{}", Format::Json).path(), None);
671    }
672
673    #[test]
674    fn a_source_can_borrow_from_a_runtime_string() {
675        let text = String::from("{}");
676        let source = Source::inline(&text, Format::Json);
677
678        assert_eq!(source.path(), None);
679    }
680}