Skip to main content

cli_engine/
env_config.rs

1//! Declarative, attribute-driven per-environment configuration.
2//!
3//! An [`EnvConfig`] struct describes, per field, where to find its value —
4//! a TOML key, an opt-in environment-variable suffix, a literal or computed
5//! default, and (when the defaults don't fit) a custom conversion function
6//! for either raw form. `#[derive(EnvConfig)]` generates the wiring; see
7//! `docs/environments.md` for the attribute grammar and
8//! [`crate::environments::Environments::resolve`] for the common way to
9//! build a [`SourceChain`] and assemble a struct from it.
10//!
11//! # Why `toml::Value`, and why it's exposed directly
12//!
13//! Every source except an environment variable represents its values as
14//! `toml::Value`/`toml::Table` — not a `cli_engine`-owned wrapper type.
15//! Deliberate, not incidental:
16//!
17//! - The file layer (`environments.toml`) genuinely *is* TOML; parsing it
18//!   already produces a `toml::Table`.
19//! - Compiled-in and in-memory sources ([`crate::environments::EnvTable`],
20//!   [`ValueSource`]) use that *same* representation so they merge with the
21//!   file layer key-by-key with no translation between "code-supplied" and
22//!   "file-supplied" shapes, and so a field's default conversion
23//!   ([`default_from_toml`](crate::env_config::default_from_toml)) can lean on `toml::Value`'s own
24//!   `serde::Deserialize` impl — a TOML array becomes a `Vec<String>`
25//!   natively, a TOML table becomes a nested struct, with no per-field
26//!   stringly-typed detour.
27//! - An environment variable is the one genuine exception: it's always a
28//!   plain `String` (an OS-level constraint, not a design choice), so
29//!   [`ConfigSource::env_var`] returns `Option<String>`, never
30//!   `Option<toml::Value>`, and a field's `from_env` conversion is always a
31//!   separate function from its `from_toml` conversion.
32//!
33//! Using `toml::Value`/`toml::Table` directly, instead of hiding them behind
34//! a `cli_engine`-owned newtype, means they're part of this crate's public
35//! API — and a consumer whose `toml::Value` came from its *own* direct
36//! dependency, rather than this re-export, would need that dependency on
37//! the same major version as this crate's, or the two crates' `toml::Value`
38//! types are different, incompatible types despite sharing a name.
39//! [`#[derive(EnvConfig)]`](cli_engine_macros::EnvConfig)-generated code
40//! always goes through [`crate::env_config::toml`] rather than a bare
41//! `toml::` path for exactly this reason, so the common case (no custom
42//! `from_toml`/`to_toml`) needs no direct `toml` dependency in the consumer
43//! at all. A consumer writing a custom `from_toml`/`to_toml` function or
44//! calling [`ValueSource::with`] directly should do the same —
45//! `cli_engine::env_config::toml::Value`, not its own `toml` dependency's
46//! `toml::Value` — to get the same guarantee.
47
48use std::fmt;
49
50pub use cli_engine_macros::EnvConfig;
51/// Re-exported so `#[derive(EnvConfig)]`-generated code (and a consumer's
52/// own `from_toml`/`to_toml` functions) can name `toml::Value`/`toml::Table`
53/// through `cli_engine` itself rather than needing a direct `toml`
54/// dependency of their own — see the module-level "Why `toml::Value`" note
55/// above.
56pub use toml;
57
58/// Something a field's assembly instructions can be checked against: "do you
59/// have a TOML-shaped value for this key" and "do you have a string value for
60/// an env var with this suffix." [`EnvConfig::assemble`] walks a whole
61/// [`SourceChain`] of these, in priority order, so more than one kind of
62/// fallback source can contribute to the same struct.
63pub trait ConfigSource {
64    /// The raw TOML value stored under `key`, if this source has one.
65    fn toml_value(&self, key: &str) -> Option<&toml::Value>;
66
67    /// The raw environment-variable string for a field, if this source has one.
68    fn env_var(&self, suffix: &str) -> Option<String> {
69        let _ = suffix;
70        None
71    }
72
73    /// The environment name this source represents, if it represents one at
74    /// all.
75    fn env_name(&self) -> Option<&str> {
76        None
77    }
78}
79
80/// One environment's source: its name and its merged TOML table (compiled-in
81/// table overlaid by the `environments.toml` file table). Purely a TOML lookup;
82/// env-var overrides are handled by a separate [`EnvVarSource`] pushed
83/// alongside it — see [`crate::environments::Environments::resolve`].
84#[derive(Debug, Clone)]
85pub struct EnvSource {
86    name: String,
87    table: toml::Table,
88}
89
90impl EnvSource {
91    /// Creates a source from an already-merged table.
92    #[must_use]
93    pub fn new(name: impl Into<String>, table: toml::Table) -> Self {
94        Self {
95            name: name.into(),
96            table,
97        }
98    }
99
100    /// The environment name this source was resolved for.
101    #[must_use]
102    pub fn name(&self) -> &str {
103        &self.name
104    }
105
106    /// The whole merged table, for generic introspection.
107    #[must_use]
108    pub fn table(&self) -> &toml::Table {
109        &self.table
110    }
111}
112
113impl ConfigSource for EnvSource {
114    fn toml_value(&self, key: &str) -> Option<&toml::Value> {
115        self.table.get(key)
116    }
117
118    fn env_name(&self) -> Option<&str> {
119        Some(&self.name)
120    }
121}
122
123/// A source that only ever answers environment-variable lookups, under a
124/// fixed prefix — the app id for the common case.
125#[derive(Debug, Clone)]
126pub struct EnvVarSource {
127    /// The prefix prepended to a field's `env` suffix, e.g. `"GDDY"` for
128    /// `GDDY_<SUFFIX>`.
129    pub prefix: String,
130}
131
132impl ConfigSource for EnvVarSource {
133    fn toml_value(&self, _key: &str) -> Option<&toml::Value> {
134        None
135    }
136
137    fn env_var(&self, suffix: &str) -> Option<String> {
138        std::env::var(format!("{}_{suffix}", self.prefix)).ok()
139    }
140}
141
142/// A source backed by values a consumer already has in hand — for example a
143/// provider's own constructor arguments, used as a last-resort fallback tier.
144#[derive(Debug, Clone, Default)]
145pub struct ValueSource(toml::Table);
146
147impl ValueSource {
148    /// Creates an empty value source.
149    #[must_use]
150    pub fn new() -> Self {
151        Self(toml::Table::new())
152    }
153
154    /// Sets `key` to `value`, converting via [`Into<toml::Value>`].
155    #[must_use]
156    pub fn with(mut self, key: impl Into<String>, value: impl Into<toml::Value>) -> Self {
157        self.0.insert(key.into(), value.into());
158        self
159    }
160}
161
162impl ConfigSource for ValueSource {
163    fn toml_value(&self, key: &str) -> Option<&toml::Value> {
164        self.0.get(key)
165    }
166}
167
168/// An ordered chain of [`ConfigSource`]s. Assembly walks the chain in order;
169/// within one source its env var is checked before its TOML value; the first
170/// source to answer either wins for that field. Build one with
171/// [`SourceChain::new`] and [`SourceChain::push`], then pass it to
172/// [`EnvConfig::assemble`].
173#[derive(Default)]
174pub struct SourceChain<'src>(Vec<&'src dyn ConfigSource>);
175
176impl fmt::Debug for SourceChain<'_> {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.debug_struct("SourceChain")
179            .field("len", &self.0.len())
180            .finish()
181    }
182}
183
184impl<'src> SourceChain<'src> {
185    /// Creates an empty chain.
186    #[must_use]
187    pub fn new() -> Self {
188        Self(Vec::new())
189    }
190
191    /// Appends `source` to the end of the chain (lowest priority so far).
192    #[must_use]
193    pub fn push(mut self, source: &'src dyn ConfigSource) -> Self {
194        self.0.push(source);
195        self
196    }
197
198    /// Iterates the chain in priority order (first pushed, checked first).
199    pub fn iter(&self) -> impl Iterator<Item = &'src dyn ConfigSource> + '_ {
200        self.0.iter().copied()
201    }
202
203    /// The first source in the chain that has a TOML value for `key`, if
204    /// any. A `default_fn` uses this to derive its field from a *sibling*
205    /// field's raw value (for example deriving `auth_url` from `api_url`)
206    /// without duplicating the chain-walk `resolve_field` already does.
207    #[must_use]
208    pub fn toml_value(&self, key: &str) -> Option<&toml::Value> {
209        self.iter().find_map(|source| source.toml_value(key))
210    }
211
212    /// The environment name of the first source in the chain that has one
213    /// (see [`ConfigSource::env_name`]), if any. A `default_fn` uses this to
214    /// derive its field from the environment's own identity (for example
215    /// `account.{name}.example.com`).
216    #[must_use]
217    pub fn env_name(&self) -> Option<&str> {
218        self.iter().find_map(|source| source.env_name())
219    }
220}
221
222/// A struct that can be assembled from a [`SourceChain`] — implemented by
223/// `#[derive(EnvConfig)]`.
224pub trait EnvConfig: Sized {
225    /// Threads `sources` through this struct's per-field assembly
226    /// instructions to build an instance.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`EnvConfigError::InvalidField`] when a present value fails to
231    /// convert to its field's type, or [`EnvConfigError::MissingField`] when
232    /// no source has a value and the field has no default.
233    fn assemble(sources: &SourceChain<'_>) -> Result<Self, EnvConfigError>;
234}
235
236/// An error assembling an [`EnvConfig`] struct.
237#[derive(Debug, thiserror::Error)]
238pub enum EnvConfigError {
239    /// A present value failed to convert to the field's type.
240    #[error("field {field}: {reason}")]
241    InvalidField {
242        /// The field's Rust name.
243        field: &'static str,
244        /// Why conversion failed.
245        reason: String,
246    },
247    /// No source had a value for this field, and it has no default.
248    #[error("field {field} has no value in any source, and no default")]
249    MissingField {
250        /// The field's Rust name.
251        field: &'static str,
252    },
253    /// The underlying environment failed to resolve at all (unknown name,
254    /// unreadable/malformed `environments.toml`).
255    #[error(transparent)]
256    Environment(Box<crate::error::CliCoreError>),
257}
258
259// A hand-written `From` (rather than thiserror's `#[from]`) so `?` converts a
260// plain `CliCoreError` directly — `CliCoreError` in turn converts *from*
261// `EnvConfigError` (see error.rs), and thiserror's `#[from]` would otherwise
262// need the boxed type on both sides of that cycle to keep each enum's size
263// finite, which breaks the ergonomic `?` conversion callers expect.
264impl From<crate::error::CliCoreError> for EnvConfigError {
265    fn from(err: crate::error::CliCoreError) -> Self {
266        Self::Environment(Box::new(err))
267    }
268}
269
270/// Default TOML-to-`T` conversion used when a field has no `from_toml`
271/// attribute: `T` must be [`serde::de::DeserializeOwned`].
272///
273/// # Errors
274///
275/// Returns an error when `value` doesn't match `T`'s shape.
276pub fn default_from_toml<T: serde::de::DeserializeOwned>(value: &toml::Value) -> Result<T, String> {
277    value.clone().try_into::<T>().map_err(|err| err.to_string())
278}
279
280/// Default string-to-`T` conversion used when a field has no `from_env`
281/// attribute: `T` must implement [`std::str::FromStr`].
282///
283/// # Errors
284///
285/// Returns an error when `raw` doesn't parse as `T`.
286pub fn default_from_env<T>(raw: &str) -> Result<T, String>
287where
288    T: std::str::FromStr,
289    T::Err: fmt::Display,
290{
291    raw.parse::<T>().map_err(|err| err.to_string())
292}
293
294/// Walks `sources` looking for a value for one field: within a source, its
295/// env var (if `env_suffix` is given) is checked before its TOML value; the
296/// first source to answer either wins. Returns `Ok(None)` when no source has
297/// the field at all, letting the caller apply a default.
298///
299/// By default (`allow_blank` is `false`), a source that answers with an
300/// empty-or-whitespace-only string, or an empty TOML array, is treated the
301/// same as that source not answering at all: `resolve_field` keeps walking
302/// the rest of the chain, trying the *next* source, and ultimately falls to
303/// the field's `default`/`default_fn` only once every source has been asked.
304/// (An env var is always a string; a TOML value counts only when it *is* a
305/// string or an array — any other TOML shape, including an empty table,
306/// never counts as blank.) This fits nearly every field — a blank or empty
307/// override is essentially always a mistake or an unset placeholder, not a
308/// real value, and this holds whether a field has one source or several
309/// fallback tiers. Set `allow_blank` on the rare field where `""` or `[]` is
310/// itself a meaningful, literal answer.
311///
312/// # Errors
313///
314/// Returns [`EnvConfigError::InvalidField`] when a present, non-blank value
315/// fails `from_env`/`from_toml`.
316pub fn resolve_field<T>(
317    sources: &SourceChain<'_>,
318    field: &'static str,
319    key: &str,
320    env_suffix: Option<&str>,
321    allow_blank: bool,
322    from_toml: impl Fn(&toml::Value) -> Result<T, String>,
323    from_env: impl Fn(&str) -> Result<T, String>,
324) -> Result<Option<T>, EnvConfigError> {
325    fn is_blank(s: &str) -> bool {
326        s.trim().is_empty()
327    }
328    fn is_blank_toml_value(value: &toml::Value) -> bool {
329        match value {
330            toml::Value::String(s) => is_blank(s),
331            toml::Value::Array(a) => a.is_empty(),
332            _ => false,
333        }
334    }
335
336    for source in sources.iter() {
337        if let Some(suffix) = env_suffix
338            && let Some(raw) = source.env_var(suffix)
339            && (allow_blank || !is_blank(&raw))
340        {
341            return from_env(&raw)
342                .map(Some)
343                .map_err(|reason| EnvConfigError::InvalidField { field, reason });
344        }
345        if let Some(value) = source.toml_value(key)
346            && (allow_blank || !is_blank_toml_value(value))
347        {
348            return from_toml(value)
349                .map(Some)
350                .map_err(|reason| EnvConfigError::InvalidField { field, reason });
351        }
352    }
353    Ok(None)
354}
355
356#[cfg(test)]
357#[allow(clippy::unwrap_used, clippy::expect_used, unsafe_code)]
358mod tests {
359    use super::*;
360
361    #[derive(Debug, PartialEq, Eq)]
362    struct Section {
363        client_id: String,
364        port: u32,
365    }
366
367    impl EnvConfig for Section {
368        fn assemble(sources: &SourceChain<'_>) -> Result<Self, EnvConfigError> {
369            let client_id = match resolve_field::<String>(
370                sources,
371                "client_id",
372                "client_id",
373                None,
374                false,
375                default_from_toml::<String>,
376                default_from_env::<String>,
377            )? {
378                Some(v) => v,
379                None => {
380                    return Err(EnvConfigError::MissingField { field: "client_id" });
381                }
382            };
383            let port = resolve_field::<u32>(
384                sources,
385                "port",
386                "port",
387                Some("PORT"),
388                false,
389                default_from_toml::<u32>,
390                default_from_env::<u32>,
391            )?
392            .unwrap_or(8080);
393            Ok(Self { client_id, port })
394        }
395    }
396
397    #[derive(Debug, PartialEq, Eq)]
398    struct WithDerivedField {
399        base: String,
400        derived: String,
401        env_name: String,
402    }
403
404    impl EnvConfig for WithDerivedField {
405        fn assemble(sources: &SourceChain<'_>) -> Result<Self, EnvConfigError> {
406            let base = resolve_field::<String>(
407                sources,
408                "base",
409                "base",
410                None,
411                true, // allow_blank: opts out of the default, accepting "" literally
412                default_from_toml::<String>,
413                default_from_env::<String>,
414            )?
415            .unwrap_or_default();
416            let derived = match resolve_field::<String>(
417                sources,
418                "derived",
419                "derived",
420                None,
421                false, // allow_blank: default — blank collapses to absent
422                default_from_toml::<String>,
423                default_from_env::<String>,
424            )? {
425                Some(v) => v,
426                None => format!("derived-from-{base}"),
427            };
428            let env_name = sources.env_name().unwrap_or_default().to_owned();
429            Ok(Self {
430                base,
431                derived,
432                env_name,
433            })
434        }
435    }
436
437    #[test]
438    fn toml_value_wins_when_no_env_var_set() {
439        let mut table = toml::Table::new();
440        table.insert("client_id".to_owned(), "from-toml".into());
441        let env = EnvSource::new("prod", table);
442        let chain = SourceChain::new().push(&env);
443        let section = Section::assemble(&chain).expect("assembles");
444        assert_eq!(section.client_id, "from-toml");
445        assert_eq!(section.port, 8080, "no source set port; default applies");
446    }
447
448    #[test]
449    fn env_var_source_outranks_toml_value_source() {
450        let mut table = toml::Table::new();
451        table.insert("client_id".to_owned(), "from-toml".into());
452        table.insert("port".to_owned(), toml::Value::Integer(1234));
453        let env = EnvSource::new("prod", table);
454
455        // SAFETY: single-threaded test, no other test reads this var.
456        unsafe { std::env::set_var("GDDY_PORT", "9999") };
457        let app = EnvVarSource {
458            prefix: "GDDY".to_owned(),
459        };
460        let chain = SourceChain::new().push(&app).push(&env);
461        let section = Section::assemble(&chain).expect("assembles");
462        // SAFETY: matches the set_var above.
463        unsafe { std::env::remove_var("GDDY_PORT") };
464
465        assert_eq!(
466            section.client_id, "from-toml",
467            "app source has no client_id, falls through to env table"
468        );
469        assert_eq!(
470            section.port, 9999,
471            "app-scoped env var outranks the TOML value"
472        );
473    }
474
475    #[test]
476    fn missing_required_field_errors() {
477        let chain = SourceChain::new();
478        let err = Section::assemble(&chain).unwrap_err();
479        assert!(matches!(
480            err,
481            EnvConfigError::MissingField { field: "client_id" }
482        ));
483    }
484
485    #[test]
486    fn malformed_value_is_a_hard_error() {
487        let mut table = toml::Table::new();
488        table.insert("client_id".to_owned(), "ok".into());
489        table.insert("port".to_owned(), "not-a-number".into());
490        let env = EnvSource::new("prod", table);
491        let chain = SourceChain::new().push(&env);
492        let err = Section::assemble(&chain).unwrap_err();
493        assert!(matches!(
494            err,
495            EnvConfigError::InvalidField { field: "port", .. }
496        ));
497    }
498
499    #[test]
500    fn value_source_is_a_pure_table_lookup() {
501        let base = ValueSource::new().with("client_id", "base-client");
502        let chain = SourceChain::new().push(&base);
503        let section = Section::assemble(&chain).expect("assembles");
504        assert_eq!(section.client_id, "base-client");
505    }
506
507    #[test]
508    fn default_fn_derives_from_a_sibling_field_via_source_chain_toml_value() {
509        let mut table = toml::Table::new();
510        table.insert("base".to_owned(), "widget".into());
511        let env = EnvSource::new("prod", table);
512        let chain = SourceChain::new().push(&env);
513        let section = WithDerivedField::assemble(&chain).expect("assembles");
514        assert_eq!(section.derived, "derived-from-widget");
515    }
516
517    #[test]
518    fn blank_value_falls_through_to_default_fn_by_default() {
519        let mut table = toml::Table::new();
520        table.insert("base".to_owned(), "widget".into());
521        table.insert("derived".to_owned(), "   ".into());
522        let env = EnvSource::new("prod", table);
523        let chain = SourceChain::new().push(&env);
524        let section = WithDerivedField::assemble(&chain).expect("assembles");
525        assert_eq!(
526            section.derived, "derived-from-widget",
527            "an explicit blank value is treated the same as an absent one by default"
528        );
529    }
530
531    #[test]
532    fn allow_blank_accepts_a_blank_value_as_is() {
533        let mut table = toml::Table::new();
534        table.insert("base".to_owned(), "   ".into());
535        let env = EnvSource::new("prod", table);
536        let chain = SourceChain::new().push(&env);
537        let section = WithDerivedField::assemble(&chain).expect("assembles");
538        assert_eq!(
539            section.base, "   ",
540            "`base` opts in via `allow_blank`, so its blank value is used as-is"
541        );
542    }
543
544    #[test]
545    fn empty_array_falls_through_to_the_next_source_by_default() {
546        // Mirrors the blank-string case, but for a Vec<T> field: a source
547        // that answers with an empty TOML array (e.g. a wired environment's
548        // `scopes = []`) must not silently win over a later tier's real
549        // value — same "blank is absent by default" rule, extended to
550        // arrays, not just strings.
551        let mut table = toml::Table::new();
552        table.insert("tags".to_owned(), toml::Value::Array(Vec::new()));
553        let env = EnvSource::new("prod", table);
554        let base = ValueSource::new().with("tags", vec!["real".to_owned()]);
555        let chain = SourceChain::new().push(&env).push(&base);
556
557        let tags = resolve_field::<Vec<String>>(
558            &chain,
559            "tags",
560            "tags",
561            None,
562            false, // allow_blank: default — empty array collapses to absent
563            default_from_toml::<Vec<String>>,
564            |_raw: &str| -> Result<Vec<String>, String> { Err(String::new()) },
565        )
566        .expect("resolves")
567        .expect("some tier has a value");
568
569        assert_eq!(
570            tags,
571            vec!["real".to_owned()],
572            "the higher-priority source's empty array must defer to the base's real value"
573        );
574    }
575
576    #[test]
577    fn allow_blank_accepts_an_empty_array_as_is() {
578        let mut table = toml::Table::new();
579        table.insert("tags".to_owned(), toml::Value::Array(Vec::new()));
580        let env = EnvSource::new("prod", table);
581        let base = ValueSource::new().with("tags", vec!["real".to_owned()]);
582        let chain = SourceChain::new().push(&env).push(&base);
583
584        let tags = resolve_field::<Vec<String>>(
585            &chain,
586            "tags",
587            "tags",
588            None,
589            true, // allow_blank: opts out, accepting [] literally
590            default_from_toml::<Vec<String>>,
591            |_raw: &str| -> Result<Vec<String>, String> { Err(String::new()) },
592        )
593        .expect("resolves")
594        .expect("some tier has a value");
595
596        assert_eq!(
597            tags,
598            Vec::<String>::new(),
599            "with allow_blank set, the empty array is accepted as-is, not skipped"
600        );
601    }
602
603    #[test]
604    fn source_chain_env_name_reflects_the_first_env_source() {
605        let table = toml::Table::new();
606        let env = EnvSource::new("staging", table);
607        let chain = SourceChain::new().push(&env);
608        let section = WithDerivedField::assemble(&chain).expect("assembles");
609        assert_eq!(section.env_name, "staging");
610
611        let base = ValueSource::new();
612        let no_env_chain = SourceChain::new().push(&base);
613        let section = WithDerivedField::assemble(&no_env_chain).expect("assembles");
614        assert_eq!(
615            section.env_name, "",
616            "a chain with no EnvSource has no env name to report"
617        );
618    }
619}