Skip to main content

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}