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.** `source_of` and every error report the provider's own
192 /// metadata name. A provider that describes itself badly produces a
193 /// diagnostic that describes it badly.
194 ///
195 /// The `Send + Sync` bound is not decoration: a `LoadSpec` is moved to
196 /// another thread by `load_async` and by the file watcher, so a provider
197 /// that cannot cross one would take those with it. Every provider figment
198 /// ships already satisfies it.
199 #[cfg(feature = "figment")]
200 #[cfg_attr(docsrs, doc(cfg(feature = "figment")))]
201 #[must_use]
202 pub const fn provider(provider: &'a (dyn figment::Provider + Send + Sync)) -> Self {
203 Self {
204 kind: Kind::Provider(provider),
205 // A provider parses nothing: it hands over values that are already
206 // figment's. `format()` says so by answering `None`.
207 format: Format::Json,
208 }
209 }
210
211 /// This source's format, for the kinds that parse text.
212 ///
213 /// `None` for a [`provider`](Self::provider), which hands over values that
214 /// are already figment's and never sees a byte of text.
215 #[must_use]
216 pub const fn format(&self) -> Option<Format> {
217 match self.kind {
218 #[cfg(feature = "figment")]
219 Kind::Provider(_) => None,
220 _ => Some(self.format),
221 }
222 }
223
224 /// Configuration in a file that is encrypted on disk.
225 ///
226 /// `format` is what the *plaintext* is, so `secrets.json.age` is
227 /// [`Format::Json`]. Reading it needs a decryptor installed with
228 /// [`set_decryptor`](crate::set_decryptor).
229 ///
230 /// In every other respect it is a file: same precedence, same profile
231 /// variants, watched the same way, skipped if it is not there.
232 #[must_use]
233 pub const fn encrypted(path: &'a str, format: Format) -> Self {
234 Self {
235 kind: Kind::Encrypted(path),
236 format,
237 }
238 }
239
240 /// The file path, if this source is a file — encrypted or not.
241 ///
242 /// Encrypted files are included because everything that asks this question
243 /// — profile variants, the directories to watch — wants the same answer for
244 /// both.
245 #[must_use]
246 pub const fn path(&self) -> Option<&'a str> {
247 match self.kind {
248 Kind::File(path) | Kind::Encrypted(path) => Some(path),
249 _ => None,
250 }
251 }
252
253 /// Whether this source has to be decrypted before it can be parsed.
254 #[must_use]
255 pub const fn is_encrypted(&self) -> bool {
256 matches!(self.kind, Kind::Encrypted(_))
257 }
258
259 /// The embedded text, if this source is inline.
260 pub(crate) fn inline_text(&self) -> Option<&'a str> {
261 match self.kind {
262 Kind::Inline(text) => Some(text),
263 _ => None,
264 }
265 }
266
267 /// The foreign provider, if this source is one.
268 #[cfg(feature = "figment")]
269 pub(crate) fn foreign(&self) -> Option<&'a (dyn figment::Provider + Send + Sync)> {
270 match self.kind {
271 Kind::Provider(provider) => Some(provider),
272 _ => None,
273 }
274 }
275}
276
277impl std::fmt::Debug for Source<'_> {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 match self.kind {
280 Kind::File(path) => f.debug_tuple("File").field(&path).finish(),
281 Kind::Encrypted(path) => f.debug_tuple("Encrypted").field(&path).finish(),
282 // Never the text: an inline source is as likely to hold secrets as
283 // a file is, and this is the type that would print them.
284 Kind::Inline(text) => f
285 .debug_struct("Inline")
286 .field("bytes", &text.len())
287 .field("format", &self.format)
288 .finish(),
289 #[cfg(feature = "figment")]
290 Kind::Provider(provider) => f
291 .debug_tuple("Provider")
292 .field(&provider.metadata().name)
293 .finish(),
294 }
295 }
296}
297
298/// The suffix that marks a config file as encrypted.
299///
300/// Lives here rather than with the decryption itself because the *naming* rules
301/// — which files are encrypted, what a profile variant of one is called — are
302/// needed whether or not this build can decrypt anything.
303pub(crate) const ENCRYPTED_SUFFIX: &str = "age";
304
305/// Splits `secrets.json.age` into `("secrets.json", "json")`.
306///
307/// `None` when the name does not end in the suffix, or has no extension under
308/// it to name a format.
309pub(crate) fn inner_name(path: &str) -> Option<(&str, &str)> {
310 let stripped = path.strip_suffix(ENCRYPTED_SUFFIX)?.strip_suffix('.')?;
311 let extension = std::path::Path::new(stripped)
312 .extension()
313 .and_then(|extension| extension.to_str())?;
314
315 Some((stripped, extension))
316}
317
318/// Everything the loader needs: which layers, which section, which env prefix.
319///
320/// Prefer [`new`](Self::new) and the `with_*` methods over a struct literal, so
321/// that a later release can add a knob without breaking every call site.
322#[derive(Clone, Copy)]
323pub struct LoadSpec<'a> {
324 /// The configuration section this maps to, e.g. `"db"`.
325 pub key: &'a str,
326 /// Layers, merged left to right. Later sources win.
327 pub sources: &'a [Source<'a>],
328 /// Environment variable prefix, e.g. `"APP_"`. Combined with `key`.
329 /// `None` ignores the environment entirely.
330 pub env_prefix: Option<&'a str>,
331 /// Where to look for configuration files by name, if anywhere.
332 ///
333 /// Discovered files are merged *before* [`sources`](Self::sources), so an
334 /// explicitly listed file still has the last word.
335 pub search: Option<crate::Search<'a>>,
336 /// Environment variable naming the active profile, e.g. `"APP_ENV"`.
337 ///
338 /// When it is set to `production`, every file gains a sibling layer:
339 /// `config.toml` is followed by `config.production.toml`, discovered or
340 /// listed alike. A variant that does not exist is skipped like any other
341 /// missing file.
342 pub profile_env: Option<&'a str>,
343 /// Values below the files: consulted only when nothing else supplies a key.
344 pub defaults: Option<&'a crate::Layer>,
345 /// A document fetched from a remote store: above the files, below the
346 /// environment.
347 pub remote: Option<&'a crate::Remote>,
348 /// `.env` files, read as the environment layer rather than as documents.
349 ///
350 /// Merged in order, just *below* the real environment: a variable somebody
351 /// exported for this run should beat a file in the repository.
352 pub env_files: &'a [&'a str],
353 /// Old key paths that still resolve, filling a gap rather than overriding.
354 pub aliases: Option<&'a crate::Aliases>,
355 /// Fields bound to environment variables by name: just above the prefixed
356 /// environment layer, because a binding is the more specific statement.
357 pub env_bindings: Option<&'a crate::EnvBindings>,
358 /// Values from the command line: above the environment, below overrides.
359 pub flags: Option<&'a crate::Layer>,
360 /// Values above everything, including the environment.
361 pub overrides: Option<&'a crate::Layer>,
362 /// Separator that introduces nesting in an environment variable name.
363 ///
364 /// Defaults to `"__"`, so `APP_DB_POOL__MAX_SIZE` is `pool.max_size`. A
365 /// single separator cannot mean both "word break" and "nesting", so
366 /// whatever this is set to, it has to be something a field name will not
367 /// contain.
368 pub nest: &'a str,
369 /// Whether `FOO=` counts as set-to-empty.
370 ///
371 /// Defaults to `false`, which treats it as unset. An unset value rendered
372 /// into a deployment template leaves exactly `FOO=`, and letting that blank
373 /// out a perfectly good configured value is a bad afternoon. Turn it on
374 /// when empty really is a value you need to be able to send.
375 pub allow_empty_env: bool,
376 /// Rejects environment values from the yes/no/on/off family instead of
377 /// letting them arrive as strings where a boolean was meant.
378 pub strict_env: bool,
379}
380
381impl std::fmt::Debug for LoadSpec<'_> {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 f.debug_struct("LoadSpec")
384 .field("key", &self.key)
385 .field("sources", &self.sources)
386 .field("search", &self.search)
387 .field("profile_env", &self.profile_env)
388 .field("env_prefix", &self.env_prefix)
389 .field("defaults", &self.defaults.is_some())
390 .field("remote", &self.remote.is_some())
391 .field("flags", &self.flags.is_some())
392 .field("overrides", &self.overrides.is_some())
393 .field("nest", &self.nest)
394 .field("allow_empty_env", &self.allow_empty_env)
395 .field("strict_env", &self.strict_env)
396 .finish()
397 }
398}
399
400impl<'a> LoadSpec<'a> {
401 /// A spec that reads `sources` and selects `key`, ignoring the environment.
402 #[must_use]
403 pub const fn new(key: &'a str, sources: &'a [Source<'a>]) -> Self {
404 Self {
405 key,
406 sources,
407 search: None,
408 profile_env: None,
409 env_prefix: None,
410 defaults: None,
411 remote: None,
412 env_files: &[],
413 aliases: None,
414 env_bindings: None,
415 flags: None,
416 overrides: None,
417 nest: DEFAULT_NEST,
418 allow_empty_env: false,
419 strict_env: false,
420 }
421 }
422
423 /// Looks for `{name}.{ext}` in each of `paths`, in order.
424 ///
425 /// Every directory that has a match contributes one file, so the search
426 /// order *is* the layering order. Discovered files sit below the explicit
427 /// [`sources`](Self::sources).
428 #[must_use]
429 pub const fn with_search(mut self, name: &'a str, paths: &'a [&'a str]) -> Self {
430 self.search = Some(crate::Search::new(name, paths));
431 self
432 }
433
434 /// Layers a per-profile sibling over every file.
435 ///
436 /// `variable` names the environment variable holding the profile, so the
437 /// profile itself is resolved at load time rather than baked in.
438 #[must_use]
439 pub const fn with_profile_env(mut self, variable: &'a str) -> Self {
440 self.profile_env = Some(variable);
441 self
442 }
443
444 /// Values consulted only when no file and no variable supplies a key.
445 #[must_use]
446 pub const fn with_defaults(mut self, layer: &'a crate::Layer) -> Self {
447 self.defaults = Some(layer);
448 self
449 }
450
451 /// `.env` files, merged just below the real environment.
452 ///
453 /// Needs the `dotenv` feature; without it, a non-empty list is an error at
454 /// load time naming the feature rather than a list silently ignored.
455 #[must_use]
456 pub const fn with_env_files(mut self, files: &'a [&'a str]) -> Self {
457 self.env_files = files;
458 self
459 }
460
461 /// Old key paths that still resolve.
462 #[must_use]
463 pub const fn with_aliases(mut self, aliases: &'a crate::Aliases) -> Self {
464 self.aliases = Some(aliases);
465 self
466 }
467
468 /// Fields bound to environment variables by name.
469 #[must_use]
470 pub const fn with_env_bindings(mut self, bindings: &'a crate::EnvBindings) -> Self {
471 self.env_bindings = Some(bindings);
472 self
473 }
474
475 /// A remote store's document, layered over the files.
476 #[must_use]
477 pub const fn with_remote(mut self, remote: &'a crate::Remote) -> Self {
478 self.remote = Some(remote);
479 self
480 }
481
482 /// Values from the command line, layered over the environment.
483 #[must_use]
484 pub const fn with_flags(mut self, layer: &'a crate::Layer) -> Self {
485 self.flags = Some(layer);
486 self
487 }
488
489 /// Values that win over the files, the environment and the flags alike.
490 #[must_use]
491 pub const fn with_overrides(mut self, layer: &'a crate::Layer) -> Self {
492 self.overrides = Some(layer);
493 self
494 }
495
496 /// Layers environment variables named `{prefix}{KEY}_*` over the files.
497 #[must_use]
498 pub const fn with_env(mut self, prefix: &'a str) -> Self {
499 self.env_prefix = Some(prefix);
500 self
501 }
502
503 /// Uses `separator` instead of `__` to introduce nesting.
504 #[must_use]
505 pub const fn with_nest(mut self, separator: &'a str) -> Self {
506 self.nest = separator;
507 self
508 }
509
510 /// Treats `FOO=` as set-to-empty rather than unset.
511 #[must_use]
512 pub const fn with_empty_env(mut self, allow: bool) -> Self {
513 self.allow_empty_env = allow;
514 self
515 }
516
517 /// Rejects ambiguous environment spellings instead of guessing.
518 ///
519 /// `APP_DB_TLS=off` reads like a boolean and arrives as the string
520 /// `"off"` — silently correct into a `String` field, silently wrong
521 /// everywhere else. Strict mode makes the yes/no/on/off family (and
522 /// `null`/`nil`/`none`) an error naming the variable; write `true`,
523 /// `false`, or the value you actually mean.
524 #[must_use]
525 pub const fn with_strict_env(mut self, strict: bool) -> Self {
526 self.strict_env = strict;
527 self
528 }
529
530 /// The environment variable that names the profile, if configured.
531 pub(crate) fn profile_variable(&self) -> Option<&'a str> {
532 self.profile_env
533 }
534
535 /// The active profile, if one is named and set to something.
536 pub(crate) fn profile(&self) -> Option<String> {
537 self.profile_env
538 .and_then(|variable| std::env::var(variable).ok())
539 .map(|profile| profile.trim().to_owned())
540 .filter(|profile| !profile.is_empty())
541 }
542
543 /// The full environment prefix for this section: `"APP_"` + `"db"` → `"APP_DB_"`.
544 pub(crate) fn full_env_prefix(&self) -> Option<String> {
545 self.env_prefix
546 .map(|prefix| format!("{prefix}{}_", self.key.to_ascii_uppercase()))
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn extensions_map_to_formats_case_insensitively() {
556 assert_eq!(Format::from_extension("JSON"), Some(Format::Json));
557 assert_eq!(Format::from_extension("yml"), Some(Format::Yaml));
558 assert_eq!(Format::from_extension("yaml"), Some(Format::Yaml));
559 assert_eq!(Format::from_extension("ini"), None);
560 }
561
562 #[test]
563 fn the_env_prefix_combines_the_caller_prefix_with_the_key() {
564 let spec = LoadSpec::new("db", &[]).with_env("APP_");
565
566 assert_eq!(spec.full_env_prefix().as_deref(), Some("APP_DB_"));
567 }
568
569 #[test]
570 fn no_env_prefix_means_no_environment() {
571 assert_eq!(LoadSpec::new("db", &[]).full_env_prefix(), None);
572 }
573
574 #[test]
575 fn an_empty_environment_variable_is_unset_by_default() {
576 assert!(!LoadSpec::new("db", &[]).allow_empty_env);
577 assert!(
578 LoadSpec::new("db", &[])
579 .with_empty_env(true)
580 .allow_empty_env
581 );
582 }
583
584 #[test]
585 fn only_file_sources_expose_a_path() {
586 assert_eq!(Source::file("a.json", Format::Json).path(), Some("a.json"));
587 assert_eq!(Source::inline("{}", Format::Json).path(), None);
588 }
589
590 #[test]
591 fn a_source_can_borrow_from_a_runtime_string() {
592 let text = String::from("{}");
593 let source = Source::inline(&text, Format::Json);
594
595 assert_eq!(source.path(), None);
596 }
597}