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}