Skip to main content

wowlab_common/
env.rs

1use std::{fmt, str::FromStr};
2
3use secrecy::SecretString;
4
5/// Source of process-style key/value configuration.
6pub trait Environment {
7    /// Read `key` from this environment source.
8    ///
9    /// # Errors
10    ///
11    /// Returns [`std::env::VarError`] when the key is absent or its value is not valid Unicode.
12    fn var(&self, key: &str) -> Result<String, std::env::VarError>;
13}
14
15/// Adapter backed by the current process environment.
16#[derive(Clone, Copy, Debug, Default)]
17pub struct ProcessEnvironment;
18
19impl Environment for ProcessEnvironment {
20    fn var(&self, key: &str) -> Result<String, std::env::VarError> {
21        // #t(rust_ambient_syscall) this is the process-environment adapter boundary
22        std::env::var(key)
23    }
24}
25
26wowlab_engine_macros::define_error! {
27/// Errors raised by environment-variable helpers in this crate.
28#[non_exhaustive]
29pub struct EnvError {
30    #[source]
31    kind: EnvErrorKind,
32}
33
34#[derive(Debug, thiserror::Error)]
35enum EnvErrorKind {
36    #[error("required environment variable {key} not set")]
37    Missing {
38        key: String,
39        #[source]
40        source: std::env::VarError,
41    },
42}
43}
44
45impl EnvError {
46    fn missing(key: String, source: std::env::VarError) -> Self {
47        Self {
48            kind: EnvErrorKind::Missing { key, source },
49        }
50    }
51
52    /// Return the missing environment-variable name.
53    #[must_use]
54    pub fn key(&self) -> &str {
55        match &self.kind {
56            EnvErrorKind::Missing { key, .. } => key,
57        }
58    }
59}
60
61impl fmt::Debug for EnvError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.debug_struct("EnvError")
64            .field("key", &self.key())
65            .finish_non_exhaustive()
66    }
67}
68
69/// Read an environment variable, returning a default if not set.
70#[must_use]
71pub fn env_or(key: &str, default: &str) -> String {
72    env_or_with(&ProcessEnvironment, key, default)
73}
74
75/// Read a value through an injected environment, returning a default when absent.
76pub fn env_or_with(env: &impl Environment, key: &str, default: &str) -> String {
77    env.var(key).unwrap_or_else(|_| default.to_string())
78}
79
80/// Read a required environment variable.
81///
82/// # Errors
83///
84/// Returns [`EnvError`] when `key` is absent or not valid Unicode.
85pub fn require_env(key: &str) -> Result<String, EnvError> {
86    require_env_with(&ProcessEnvironment, key)
87}
88
89/// Read a required value through an injected environment.
90///
91/// # Errors
92///
93/// Returns [`EnvError`] when `key` is absent or not valid Unicode.
94pub fn require_env_with(env: &impl Environment, key: &str) -> Result<String, EnvError> {
95    env.var(key)
96        .map_err(|source| EnvError::missing(key.to_string(), source))
97}
98
99/// Read an unprefixed value from the process environment.
100#[must_use]
101pub fn get_raw_env(name: &str) -> Option<String> {
102    ProcessEnvironment.var(name).ok()
103}
104
105/// Loads environment variables with a common prefix, supporting required, optional, and secret values.
106#[derive(Debug)]
107pub struct EnvLoader {
108    prefix: &'static str,
109}
110
111impl EnvLoader {
112    #[must_use]
113    pub fn new(prefix: &'static str) -> Self {
114        Self { prefix }
115    }
116
117    /// Read a required prefixed environment variable.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`EnvError`] when the prefixed key is absent or not valid Unicode.
122    pub fn require(&self, name: &str) -> Result<String, EnvError> {
123        let key = self.prefixed(name);
124
125        ProcessEnvironment
126            .var(&key)
127            .map_err(|source| EnvError::missing(key, source))
128    }
129
130    #[must_use]
131    pub fn get(&self, name: &str) -> Option<String> {
132        ProcessEnvironment.var(&self.prefixed(name)).ok()
133    }
134
135    #[must_use]
136    pub fn get_or(&self, name: &str, default: &str) -> String {
137        self.get(name).unwrap_or_else(|| default.to_string())
138    }
139
140    pub fn parse_or<T>(&self, name: &str, default: T) -> T
141    where
142        T: FromStr,
143    {
144        self.get(name)
145            .and_then(|s| s.parse().ok())
146            .unwrap_or(default)
147    }
148
149    #[must_use]
150    pub fn parse_bool(&self, name: &str, default: bool) -> bool {
151        match self.get(name) {
152            Some(v) => matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"),
153            None => default,
154        }
155    }
156
157    /// Read a required prefixed environment variable as a secret.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`EnvError`] when the prefixed key is absent or not valid Unicode.
162    pub fn require_secret(&self, name: &str) -> Result<SecretString, EnvError> {
163        self.require(name).map(Into::into)
164    }
165
166    pub fn get_secret(&self, name: &str) -> Option<SecretString> {
167        self.get(name).map(Into::into)
168    }
169
170    fn prefixed(&self, name: &str) -> String {
171        format!("{}_{}", self.prefix, name)
172    }
173}
174
175use directories::ProjectDirs;
176use wowlab_fs::path::PathBuf;
177
178/// Identifies an application for platform-specific project directories.
179#[derive(Clone, Copy, Debug)]
180pub struct ProjectIdentity<'a> {
181    pub qualifier: &'a str,
182    pub organization: &'a str,
183    pub application: &'a str,
184}
185
186#[derive(Clone, Copy)]
187enum ProjectDirectory {
188    Config,
189    Data,
190}
191
192fn project_dir_with(
193    environment: &impl Environment,
194    prefix: &str,
195    suffix: &str,
196    project: ProjectIdentity<'_>,
197    directory: ProjectDirectory,
198) -> Option<PathBuf> {
199    let env_key = format!("{prefix}_{suffix}");
200
201    if let Ok(path) = environment.var(&env_key) {
202        return Some(PathBuf::from(path));
203    }
204
205    let directories =
206        ProjectDirs::from(project.qualifier, project.organization, project.application)?;
207    let path = match directory {
208        ProjectDirectory::Config => directories.config_dir(),
209        ProjectDirectory::Data => directories.data_dir(),
210    };
211
212    Some(PathBuf::from(path))
213}
214
215/// Return the platform-specific config directory, checking an env override first.
216#[must_use]
217pub fn config_dir(prefix: &str, project: ProjectIdentity<'_>) -> Option<PathBuf> {
218    project_dir_with(
219        &ProcessEnvironment,
220        prefix,
221        "CONFIG_DIR",
222        project,
223        ProjectDirectory::Config,
224    )
225}
226
227/// Return the platform-specific data directory, checking an env override first.
228#[must_use]
229pub fn data_dir(prefix: &str, project: ProjectIdentity<'_>) -> Option<PathBuf> {
230    project_dir_with(
231        &ProcessEnvironment,
232        prefix,
233        "DATA_DIR",
234        project,
235        ProjectDirectory::Data,
236    )
237}
238
239/// Append a query parameter to a URL if it is not already present.
240#[must_use]
241pub fn ensure_query_param(url: &str, key: &str, value: &str) -> String {
242    if url.contains(&format!("{key}=")) {
243        return url.to_string();
244    }
245
246    let sep = if url.contains('?') { '&' } else { '?' };
247
248    format!("{url}{sep}{key}={value}")
249}
250
251#[cfg(test)]
252mod tests {
253    use std::error::Error as _;
254
255    use googletest::prelude::*;
256    use rstest::rstest;
257
258    use super::*;
259
260    // #t(fn: rust_hardcoded_url) test fixture URLs, not runtime configuration
261    #[gtest]
262    #[rstest]
263    #[case::no_query("http://h/p", "a", "1", "http://h/p?a=1")]
264    #[case::has_query("http://h/p?x=9", "a", "1", "http://h/p?x=9&a=1")]
265    #[case::key_present("http://h/p?a=old", "a", "1", "http://h/p?a=old")]
266    #[case::substring_guard("http://h/p?ab=9", "a", "1", "http://h/p?ab=9&a=1")]
267    fn ensure_query_param_cases(
268        #[case] url: &str,
269        #[case] key: &str,
270        #[case] value: &str,
271        #[case] expected: &str,
272    ) -> Result<()> {
273        verify_that!(ensure_query_param(url, key, value), eq(expected))
274    }
275
276    #[gtest]
277    fn require_env_missing_message() -> Result<()> {
278        let err = require_env("__WOWLAB_DEFINITELY_UNSET__").err().or_fail()?;
279
280        verify_that!(
281            err.to_string(),
282            eq("required environment variable __WOWLAB_DEFINITELY_UNSET__ not set")
283        )?;
284
285        Ok(())
286    }
287
288    enum FakeValue {
289        Missing,
290        Value(&'static str),
291        #[cfg(unix)]
292        NotUnicode,
293    }
294
295    struct FakeEnvironment(FakeValue);
296
297    impl Environment for FakeEnvironment {
298        fn var(&self, _key: &str) -> Result<String, std::env::VarError> {
299            match self.0 {
300                FakeValue::Missing => Err(std::env::VarError::NotPresent),
301                FakeValue::Value(value) => Ok(value.to_string()),
302                #[cfg(unix)]
303                FakeValue::NotUnicode => {
304                    use std::os::unix::ffi::OsStringExt as _;
305
306                    Err(std::env::VarError::NotUnicode(
307                        std::ffi::OsString::from_vec(b"top-secret-\xff".to_vec()),
308                    ))
309                }
310            }
311        }
312    }
313
314    #[gtest]
315    fn injected_environment_preserves_value_and_default_precedence() -> Result<()> {
316        verify_that!(
317            env_or_with(
318                &FakeEnvironment(FakeValue::Value("configured")),
319                "KEY",
320                "default"
321            ),
322            eq("configured")
323        )?;
324        verify_that!(
325            env_or_with(&FakeEnvironment(FakeValue::Value("")), "KEY", "default"),
326            eq("")
327        )?;
328        verify_that!(
329            env_or_with(&FakeEnvironment(FakeValue::Missing), "KEY", "default"),
330            eq("default")
331        )?;
332
333        verify_that!(
334            require_env_with(&FakeEnvironment(FakeValue::Value("")), "KEY"),
335            ok(eq(""))
336        )
337    }
338
339    #[gtest]
340    fn project_directory_override_uses_shared_path_vocabulary() -> Result<()> {
341        let directory = project_dir_with(
342            &FakeEnvironment(FakeValue::Value("configured/project")),
343            "WOWLAB",
344            "CONFIG_DIR",
345            ProjectIdentity {
346                qualifier: "gg",
347                organization: "wowlab",
348                application: "wowlab",
349            },
350            ProjectDirectory::Config,
351        )
352        .or_fail()?;
353
354        verify_that!(
355            directory.as_path(),
356            eq(wowlab_fs::path::Path::new("configured/project"))
357        )
358    }
359
360    #[gtest]
361    fn required_environment_error_preserves_key_and_var_source() -> Result<()> {
362        let error = require_env_with(&FakeEnvironment(FakeValue::Missing), "API_TOKEN")
363            .err()
364            .or_fail()?;
365
366        verify_that!(error.key(), eq("API_TOKEN"))?;
367        verify_that!(
368            error.to_string(),
369            eq("required environment variable API_TOKEN not set")
370        )?;
371
372        verify_true!(
373            error
374                .source()
375                .and_then(std::error::Error::source)
376                .is_some_and(<dyn std::error::Error>::is::<std::env::VarError>)
377        )
378    }
379
380    #[cfg(unix)]
381    #[gtest]
382    fn non_unicode_secret_value_is_redacted_from_error_debug() -> Result<()> {
383        let error = require_env_with(&FakeEnvironment(FakeValue::NotUnicode), "API_TOKEN")
384            .err()
385            .or_fail()?;
386        let debug = format!("{error:?}");
387
388        verify_that!(error.key(), eq("API_TOKEN"))?;
389        verify_that!(debug, not(contains_substring("top-secret")))?;
390        verify_that!(debug, contains_substring("API_TOKEN"))?;
391
392        verify_true!(
393            error
394                .source()
395                .and_then(std::error::Error::source)
396                .is_some_and(<dyn std::error::Error>::is::<std::env::VarError>)
397        )
398    }
399}