rhood_core/env.rs
1//! Environment variable source abstraction used by the config loader.
2//!
3//! Splitting the "where does an env var come from?" question behind a trait
4//! lets production code read from the process environment while tests pass
5//! an in-memory [`MapEnv`](crate::env::MapEnv). The config module never calls
6//! `std::env::var` directly, every env read goes through an
7//! [`&impl Env`](crate::env::Env).
8//!
9//! [`env_non_empty`](crate::env::env_non_empty) treats empty strings as
10//! "unset", while [`env_parsed`](crate::env::env_parsed) parses typed values
11//! and rejects malformed environment overrides.
12
13use std::collections::HashMap;
14
15use crate::{Result, RhoodError};
16
17/// Source of configuration environment variables.
18///
19/// The production impl ([`SystemEnv`]) reads from the real process
20/// environment. Test fakes ([`MapEnv`]) hold an in-memory map.
21pub trait Env {
22 /// Returns the raw value for `key`, or `None` if unset.
23 ///
24 /// Mirrors `std::env::var(key).ok()`: empty strings round-trip as
25 /// `Some("")`. Call sites that want to treat empty as missing should
26 /// layer [`env_non_empty`] on top.
27 fn get(&self, key: &str) -> Option<String>;
28}
29
30/// Production [`Env`] backed by `std::env::var`. Zero state.
31#[derive(Debug, Default, Clone, Copy)]
32pub struct SystemEnv;
33
34impl Env for SystemEnv {
35 fn get(&self, key: &str) -> Option<String> {
36 std::env::var(key).ok()
37 }
38}
39
40/// In-memory [`Env`] for tests. Keys absent from the map return `None`;
41/// empty-string values round-trip as `Some("")` to match [`SystemEnv`].
42///
43/// Construct with [`MapEnv::new`] (empty) or [`MapEnv::default`], then
44/// chain [`MapEnv::with`] to insert keys.
45#[derive(Debug, Default, Clone)]
46pub struct MapEnv {
47 vars: HashMap<String, String>,
48}
49
50impl MapEnv {
51 /// Returns a new empty `MapEnv`. Equivalent to [`MapEnv::default`].
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// Inserts `key` → `value`, returning `self` so calls chain.
57 pub fn with<K, V>(mut self, key: K, value: V) -> Self
58 where
59 K: Into<String>,
60 V: Into<String>,
61 {
62 self.vars.insert(key.into(), value.into());
63 self
64 }
65}
66
67impl Env for MapEnv {
68 fn get(&self, key: &str) -> Option<String> {
69 self.vars.get(key).cloned()
70 }
71}
72
73/// Returns the value of `key` from `env` if it is set and non-empty.
74///
75/// Treats empty strings as "unset" so a `FOO=` line in a `.env` file or a
76/// cleared-but-not-unset shell variable does not clobber a value coming from
77/// TOML or defaults.
78pub fn env_non_empty(env: &impl Env, key: &str) -> Option<String> {
79 env.get(key).filter(|value| !value.is_empty())
80}
81
82/// Returns the value of `key` from `env` parsed as `T`, if set and non-empty.
83///
84/// Builds on [`env_non_empty`]: unset and empty values return `Ok(None)`.
85/// A present-but-unparseable value is an error so configuration does not
86/// silently retain a default the operator intended to override.
87pub fn env_parsed<T>(env: &impl Env, key: &str) -> Result<Option<T>>
88where
89 T: std::str::FromStr,
90 T::Err: std::fmt::Display,
91{
92 let Some(value) = env_non_empty(env, key) else {
93 return Ok(None);
94 };
95
96 value.parse().map(Some).map_err(|error| {
97 RhoodError::InvalidParameter(format!(
98 "environment variable {key} has invalid value {value:?}: {error}"
99 ))
100 })
101}