Skip to main content

omni_dev/utils/
env.rs

1//! Environment-variable dependency-injection seam.
2//!
3//! Production code reads the process environment only through an
4//! [`EnvSource`]. The real implementation, [`SystemEnv`], delegates to
5//! [`std::env::var`]; tests inject a pure in-memory fake
6//! (`crate::test_support::env::MapEnv`) instead of mutating the
7//! process-global environment.
8//!
9//! This removes the shared mutable global that the cross-module test race
10//! (issue #821) and the per-module env mutexes (#950, #1030) were fighting
11//! over: a test that constructs its own [`EnvSource`] never touches process
12//! env, so it needs no lock and runs fully in parallel. See
13//! [STYLE-0028](../../docs/STYLE_GUIDE.md) and `docs/plan/issue-1030-env-di.md`.
14//!
15//! `EnvSource` abstracts the **raw** environment only. The
16//! settings.json fallback layer composes on top of it — see
17//! [`crate::utils::settings`].
18
19/// A read-only view of environment variables.
20///
21/// Implemented by [`SystemEnv`] (the real process environment) in production
22/// and by an in-memory map in tests, so env-parsing boundaries can be tested
23/// without mutating the process-global environment.
24pub trait EnvSource {
25    /// Returns the value of `key`, or `None` if it is unset (or, for the
26    /// process environment, not valid Unicode).
27    fn var(&self, key: &str) -> Option<String>;
28
29    /// Returns the first set value among `keys`, in order.
30    fn var_any(&self, keys: &[&str]) -> Option<String> {
31        keys.iter().find_map(|k| self.var(k))
32    }
33
34    /// Returns `key` and `file_key` **from the same layer**, for the secret
35    /// resolver's `NAME`/`NAME_FILE` pairs
36    /// ([`crate::utils::secret_env`]).
37    ///
38    /// A single-layer source returns both lookups. A layered source
39    /// (settings.json's [`SettingsEnv`](crate::utils::settings::SettingsEnv))
40    /// overrides this to return the pair from the highest-precedence layer
41    /// that sets either, so a conflict is only ever detected within one layer.
42    fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
43        (self.var(key), self.var(file_key))
44    }
45}
46
47/// The real process environment, backed by [`std::env::var`].
48///
49/// This is the production [`EnvSource`]; pass `&SystemEnv` from the thin
50/// env-resolving wrapper that fronts each boundary seam.
51#[derive(Debug, Clone, Copy, Default)]
52pub struct SystemEnv;
53
54impl EnvSource for SystemEnv {
55    fn var(&self, key: &str) -> Option<String> {
56        std::env::var(key).ok()
57    }
58}
59
60/// `&T` is an `EnvSource` whenever `T` is, so callers can pass `&SystemEnv`
61/// or `&map_env` to functions taking `&impl EnvSource` without ceremony.
62impl<T: EnvSource + ?Sized> EnvSource for &T {
63    fn var(&self, key: &str) -> Option<String> {
64        (**self).var(key)
65    }
66
67    fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
68        (**self).var_pair(key, file_key)
69    }
70}
71
72/// Returns `var`'s value when it is set and non-empty.
73///
74/// The docs promise resolution "stopping at the first non-empty value", and
75/// treating `VAR=` as unset lets users neutralise an exported variable for a
76/// single invocation.
77pub(crate) fn non_empty_var(env: &impl EnvSource, key: &str) -> Option<String> {
78    env.var(key).filter(|v| !v.is_empty())
79}
80
81/// Parses `key`'s value as a truthy boolean: `1`, `true`, or `yes` (trimmed,
82/// case-insensitive) is `true`; `0`, `false`, or `no` is `false`; unset or
83/// empty is `false` with no warning (the ordinary "not set" case). Anything
84/// else — a typo like `treu` — is also treated as `false`, but logs a
85/// warning first: a silently-discarded, unrecognized value is exactly the
86/// broken-configuration case docs/STYLE_GUIDE.md's silent-discard rule
87/// warns against (issue #1677 review finding).
88pub(crate) fn truthy_var(env: &impl EnvSource, key: &str) -> bool {
89    let Some(raw) = non_empty_var(env, key) else {
90        return false;
91    };
92    match raw.trim().to_ascii_lowercase().as_str() {
93        "1" | "true" | "yes" => true,
94        "0" | "false" | "no" => false,
95        _ => {
96            tracing::warn!(
97                "{key}={raw:?} is not a recognized boolean (expected 1/true/yes or \
98                 0/false/no); treating it as unset"
99            );
100            false
101        }
102    }
103}
104
105/// RAII guard that sets a process env var for the duration of a scope,
106/// restoring (or removing) whatever was there before when the guard drops —
107/// so a caller that mutates process env can be invoked more than once per
108/// process without leaking state between calls (#1538).
109pub(crate) struct ScopedEnvVar {
110    key: &'static str,
111    previous: Option<String>,
112}
113
114impl ScopedEnvVar {
115    /// Sets `key` to `value`, snapshotting the previous value (if any) to
116    /// restore on drop.
117    pub(crate) fn set(key: &'static str, value: &str) -> Self {
118        let previous = std::env::var(key).ok();
119        std::env::set_var(key, value);
120        Self { key, previous }
121    }
122}
123
124impl Drop for ScopedEnvVar {
125    fn drop(&mut self) {
126        match &self.previous {
127            Some(value) => std::env::set_var(self.key, value),
128            None => std::env::remove_var(self.key),
129        }
130    }
131}
132
133#[cfg(test)]
134#[allow(clippy::unwrap_used)]
135mod tests {
136    use super::*;
137    use crate::test_support::env::MapEnv;
138
139    #[test]
140    fn map_env_returns_inserted_values_and_none_otherwise() {
141        let env = MapEnv::new().with("USE_OPENAI", "true");
142        assert_eq!(env.var("USE_OPENAI").as_deref(), Some("true"));
143        assert_eq!(env.var("MISSING"), None);
144    }
145
146    #[test]
147    fn var_any_returns_first_set_key() {
148        let env = MapEnv::new().with("ANTHROPIC_API_KEY", "k");
149        assert_eq!(
150            env.var_any(&["CLAUDE_API_KEY", "ANTHROPIC_API_KEY"])
151                .as_deref(),
152            Some("k")
153        );
154        assert_eq!(env.var_any(&["A", "B"]), None);
155    }
156
157    #[test]
158    fn reference_forwards_to_inner_source() {
159        let env = MapEnv::new().with("K", "v");
160        fn read(src: &impl EnvSource) -> Option<String> {
161            src.var("K")
162        }
163        // &MapEnv must itself satisfy `impl EnvSource`.
164        assert_eq!(read(&&env).as_deref(), Some("v"));
165    }
166
167    #[test]
168    fn reference_forwards_var_pair_to_inner_source() {
169        let env = MapEnv::new().with("K", "v").with("K_FILE", "f");
170        fn read_pair(src: &impl EnvSource) -> (Option<String>, Option<String>) {
171            src.var_pair("K", "K_FILE")
172        }
173        // &MapEnv's var_pair must also forward, not just var.
174        assert_eq!(
175            read_pair(&&env),
176            (Some("v".to_string()), Some("f".to_string()))
177        );
178    }
179
180    #[test]
181    fn truthy_var_parses_truthy_and_falsy_values_case_insensitively() {
182        for v in ["1", "true", "TRUE", "yes", "YES"] {
183            assert!(truthy_var(&MapEnv::new().with("FLAG", v), "FLAG"), "{v:?}");
184        }
185        for v in ["0", "false", "FALSE", "no", "NO"] {
186            assert!(!truthy_var(&MapEnv::new().with("FLAG", v), "FLAG"), "{v:?}");
187        }
188    }
189
190    #[test]
191    fn truthy_var_treats_unset_as_false() {
192        assert!(!truthy_var(&MapEnv::new(), "FLAG"));
193    }
194
195    #[test]
196    fn truthy_var_treats_unrecognized_value_as_false() {
197        assert!(!truthy_var(&MapEnv::new().with("FLAG", "treu"), "FLAG"));
198    }
199}