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