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