Skip to main content

anodizer_core/
env_source.rs

1//! Process-env-or-injected-map abstraction for reads in production code.
2//!
3//! Production code reads environment variables through
4//! [`Context::env_var`](crate::context::Context::env_var), which routes
5//! through an injected [`EnvSource`] trait object. Production code uses
6//! [`ProcessEnvSource`] (calls `std::env::var`); tests inject a
7//! [`MapEnvSource`] via
8//! [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env)
9//! to drive deterministic branches without mutating the process env.
10//!
11//! ```no_run
12//! use anodizer_core::{EnvSource, MapEnvSource, ProcessEnvSource};
13//!
14//! let prod = ProcessEnvSource;
15//! let _ = prod.var("PATH");
16//!
17//! let test_src = MapEnvSource::new()
18//!     .with("GITHUB_TOKEN", "ghp_synthetic")
19//!     .with("CI", "true");
20//! assert_eq!(test_src.var("GITHUB_TOKEN"), Some("ghp_synthetic".to_string()));
21//! assert_eq!(test_src.var("MISSING"), None);
22//! ```
23
24use std::collections::HashMap;
25
26/// Read-only lookup for an environment variable name.
27///
28/// Production code wires up [`ProcessEnvSource`] (which calls
29/// `std::env::var`). Tests wire up [`MapEnvSource`] to drive
30/// deterministic branches without mutating the process env.
31pub trait EnvSource: Send + Sync {
32    /// Look up `name` and return its value, or `None` if unset.
33    fn var(&self, name: &str) -> Option<String>;
34
35    /// Snapshot every `(name, value)` pair this source can enumerate.
36    ///
37    /// Callers that need to scan the whole env (e.g. the determinism
38    /// harness's Windows inherit-everything pass, which drops a
39    /// credential deny-list out of the host env) use this instead of
40    /// `std::env::vars()` so a test can inject a closed map of fixture
41    /// entries. The default implementation returns an empty `Vec` so
42    /// any source that only supports point lookups (a stub, a small
43    /// fixture) stays usable for `var(...)`-only call sites without
44    /// extra boilerplate.
45    fn vars(&self) -> Vec<(String, String)> {
46        Vec::new()
47    }
48}
49
50/// Production implementation that reads `std::env::var`.
51#[derive(Debug, Default, Clone, Copy)]
52pub struct ProcessEnvSource;
53
54impl EnvSource for ProcessEnvSource {
55    fn var(&self, name: &str) -> Option<String> {
56        std::env::var(name).ok()
57    }
58
59    fn vars(&self) -> Vec<(String, String)> {
60        std::env::vars().collect()
61    }
62}
63
64/// Map-backed implementation for tests. Built from any
65/// `IntoIterator<Item=(K,V)>` (including `HashMap<K, V>` via the
66/// [`From`] impl below) or fluently via [`MapEnvSource::with`].
67#[derive(Debug, Clone, Default)]
68pub struct MapEnvSource {
69    inner: HashMap<String, String>,
70}
71
72impl MapEnvSource {
73    /// Create an empty source. Use [`MapEnvSource::with`] to seed entries
74    /// fluently, or [`MapEnvSource::set`] for the mutable form.
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Insert `(k, v)` and return `self` so calls can chain.
80    pub fn with<K: Into<String>, V: Into<String>>(mut self, k: K, v: V) -> Self {
81        self.inner.insert(k.into(), v.into());
82        self
83    }
84
85    /// Insert `(k, v)` in place. Returns `&mut self` for chained mutation.
86    pub fn set<K: Into<String>, V: Into<String>>(&mut self, k: K, v: V) -> &mut Self {
87        self.inner.insert(k.into(), v.into());
88        self
89    }
90}
91
92impl<K, V> From<HashMap<K, V>> for MapEnvSource
93where
94    K: Into<String>,
95    V: Into<String>,
96{
97    fn from(map: HashMap<K, V>) -> Self {
98        Self {
99            inner: map.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
100        }
101    }
102}
103
104impl EnvSource for MapEnvSource {
105    fn var(&self, name: &str) -> Option<String> {
106        self.inner.get(name).cloned()
107    }
108
109    fn vars(&self) -> Vec<(String, String)> {
110        self.inner
111            .iter()
112            .map(|(k, v)| (k.clone(), v.clone()))
113            .collect()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::test_helpers::env::env_mutex;
121
122    /// Picked deliberately weird so a real CI / dev shell will not have it
123    /// set. Used by the "unset variable" tests below.
124    const UNSET_VAR: &str = "ANODIZER_T3_FIXTURE_UNSET_VAR";
125
126    #[test]
127    fn process_env_source_reads_actual_env() {
128        let _g = env_mutex().lock().unwrap_or_else(|e| e.into_inner());
129        let key = "ANODIZER_T3_PROCESS_ENV_FIXTURE";
130        // SAFETY: serialised by env_mutex; cleaned up before guard drop.
131        unsafe { std::env::set_var(key, "from-process-env") };
132        let got = ProcessEnvSource.var(key);
133        // SAFETY: serialised by env_mutex.
134        unsafe { std::env::remove_var(key) };
135        assert_eq!(got, Some("from-process-env".to_string()));
136    }
137
138    #[test]
139    fn process_env_source_returns_none_for_unset_var() {
140        assert_eq!(ProcessEnvSource.var(UNSET_VAR), None);
141    }
142
143    #[test]
144    fn map_env_source_returns_inserted_value() {
145        let src = MapEnvSource::new().with("K", "V");
146        assert_eq!(src.var("K"), Some("V".to_string()));
147    }
148
149    #[test]
150    fn map_env_source_returns_none_for_missing_key() {
151        let src = MapEnvSource::new().with("K", "V");
152        assert_eq!(src.var("OTHER"), None);
153    }
154
155    #[test]
156    fn map_env_source_from_hashmap_preserves_entries() {
157        let mut m: HashMap<&str, &str> = HashMap::new();
158        m.insert("A", "1");
159        m.insert("B", "2");
160        let src: MapEnvSource = m.into();
161        assert_eq!(src.var("A"), Some("1".to_string()));
162        assert_eq!(src.var("B"), Some("2".to_string()));
163        assert_eq!(src.var("C"), None);
164    }
165}