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}