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