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