Skip to main content

sova_env/
lib.rs

1//! Explicit `.env` cascade for Sova applications.
2//!
3//! Call [`load`] at the top of `main` before reading configuration.
4//! Real process environment variables always win over file values.
5//!
6//! File order (later overrides earlier):
7//! 1. `.env.{dev|prod|test}` (short alias of the active mode)
8//! 2. `.env.{mode}` when mode is the long name (`development` / `production`)
9//! 3. `.env.local` (skipped in `test`)
10//! 4. `.env` (final overlay)
11
12use std::path::Path;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum EnvError {
17    #[error("required environment variable `{0}` is not set")]
18    Missing(&'static str),
19    #[error("dotenv: {0}")]
20    Dotenv(#[from] dotenvy::Error),
21    #[error("io: {0}")]
22    Io(#[from] std::io::Error),
23}
24
25pub type Result<T> = std::result::Result<T, EnvError>;
26
27/// Load the standard cascade into the process environment (no override of existing vars).
28pub fn load() -> Result<()> {
29    load_from(".")
30}
31
32/// Load cascade relative to `root` (typically `"."`).
33pub fn load_from(root: impl AsRef<Path>) -> Result<()> {
34    let root = root.as_ref();
35    let mode = resolve_mode();
36    let frozen: std::collections::HashSet<String> =
37        std::env::vars().map(|(k, _)| k).collect();
38
39    let files = cascade_files(&mode);
40    let mut merged = std::collections::HashMap::new();
41    for name in files {
42        let path = root.join(name);
43        if !path.is_file() {
44            continue;
45        }
46        for item in dotenvy::from_path_iter(&path)? {
47            let (k, v) = item?;
48            merged.insert(k, v);
49        }
50    }
51    for (k, v) in merged {
52        if !frozen.contains(&k) {
53            std::env::set_var(&k, v);
54        }
55    }
56    Ok(())
57}
58
59fn resolve_mode() -> String {
60    std::env::var("SOVA_ENV")
61        .or_else(|_| std::env::var("APP_ENV"))
62        .unwrap_or_else(|_| {
63            // Unit-test builds of this crate default to `test`; otherwise debug → development.
64            if cfg!(test) {
65                "test".into()
66            } else if cfg!(debug_assertions) {
67                "development".into()
68            } else {
69                "production".into()
70            }
71        })
72}
73
74/// Short file suffix for mode (`development` → `dev`).
75fn mode_short(mode: &str) -> &str {
76    match mode {
77        "development" | "debug" => "dev",
78        "production" | "release" => "prod",
79        "test" => "test",
80        other => other,
81    }
82}
83
84fn cascade_files(mode: &str) -> Vec<String> {
85    let short = mode_short(mode);
86    let skip_local = mode == "test";
87    let mut out = Vec::new();
88    // Mode-specific first…
89    out.push(format!(".env.{short}"));
90    if short != mode {
91        out.push(format!(".env.{mode}"));
92    }
93    // …optional local overlay…
94    if !skip_local {
95        out.push(".env.local".into());
96    }
97    // …then base `.env` wins over mode files.
98    out.push(".env".into());
99    out
100}
101
102/// Fail fast when a variable is unset or empty.
103pub fn require(name: &'static str) -> Result<String> {
104    match std::env::var(name) {
105        Ok(v) if !v.is_empty() => Ok(v),
106        _ => Err(EnvError::Missing(name)),
107    }
108}
109
110/// Log a warning for each key listed in `example` that is missing from the environment.
111pub fn warn_missing_example(example: impl AsRef<Path>) {
112    let path = example.as_ref();
113    let Ok(content) = std::fs::read_to_string(path) else {
114        return;
115    };
116    for line in content.lines() {
117        let line = line.trim();
118        if line.is_empty() || line.starts_with('#') {
119            continue;
120        }
121        let Some((key, _)) = line.split_once('=') else {
122            continue;
123        };
124        let key = key.trim();
125        if std::env::var(key).is_err() {
126            tracing::warn!(
127                key,
128                example = %path.display(),
129                "environment variable from example is unset"
130            );
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::sync::{Mutex, OnceLock};
139    use tempfile::TempDir;
140
141    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
142        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
143        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
144    }
145
146    #[test]
147    fn cascade_env_overrides_mode_file() {
148        let _g = lock_env();
149        let prev = std::env::var_os("SOVA_ENV");
150        std::env::set_var("SOVA_ENV", "test");
151        let dir = TempDir::new().unwrap();
152        std::fs::write(dir.path().join(".env.test"), "FOO=mode\n").unwrap();
153        std::fs::write(dir.path().join(".env"), "FOO=base\n").unwrap();
154        std::env::remove_var("FOO");
155        load_from(dir.path()).unwrap();
156        assert_eq!(std::env::var("FOO").unwrap(), "base");
157        std::env::remove_var("FOO");
158        match prev {
159            Some(v) => std::env::set_var("SOVA_ENV", v),
160            None => std::env::remove_var("SOVA_ENV"),
161        }
162    }
163
164    #[test]
165    fn real_env_beats_file() {
166        let _g = lock_env();
167        let prev = std::env::var_os("SOVA_ENV");
168        std::env::set_var("SOVA_ENV", "test");
169        let dir = TempDir::new().unwrap();
170        std::fs::write(dir.path().join(".env"), "BAR=file\n").unwrap();
171        std::env::set_var("BAR", "process");
172        load_from(dir.path()).unwrap();
173        assert_eq!(std::env::var("BAR").unwrap(), "process");
174        std::env::remove_var("BAR");
175        match prev {
176            Some(v) => std::env::set_var("SOVA_ENV", v),
177            None => std::env::remove_var("SOVA_ENV"),
178        }
179    }
180
181    #[test]
182    fn require_errors_clearly() {
183        let _g = lock_env();
184        std::env::remove_var("MISSING_TEST_VAR_X");
185        let err = require("MISSING_TEST_VAR_X").unwrap_err();
186        assert!(matches!(err, EnvError::Missing(_)));
187    }
188
189    #[test]
190    fn require_returns_value() {
191        let _g = lock_env();
192        std::env::set_var("SOVA_ENV_REQUIRE_OK", "present");
193        assert_eq!(require("SOVA_ENV_REQUIRE_OK").unwrap(), "present");
194        std::env::remove_var("SOVA_ENV_REQUIRE_OK");
195    }
196
197    #[test]
198    fn require_rejects_empty() {
199        let _g = lock_env();
200        std::env::set_var("SOVA_ENV_REQUIRE_EMPTY", "");
201        let err = require("SOVA_ENV_REQUIRE_EMPTY").unwrap_err();
202        assert!(matches!(err, EnvError::Missing(_)));
203        std::env::remove_var("SOVA_ENV_REQUIRE_EMPTY");
204    }
205
206    #[test]
207    fn cascade_merges_keys_env_last() {
208        let _g = lock_env();
209        let prev = std::env::var_os("SOVA_ENV");
210        std::env::set_var("SOVA_ENV", "test");
211        let dir = TempDir::new().unwrap();
212        std::fs::write(dir.path().join(".env.test"), "ONLY_MODE=2\nSHARED=mode\n").unwrap();
213        std::fs::write(dir.path().join(".env"), "ONLY_BASE=1\nSHARED=base\n").unwrap();
214        std::env::remove_var("ONLY_BASE");
215        std::env::remove_var("ONLY_MODE");
216        std::env::remove_var("SHARED");
217        load_from(dir.path()).unwrap();
218        assert_eq!(std::env::var("ONLY_BASE").unwrap(), "1");
219        assert_eq!(std::env::var("ONLY_MODE").unwrap(), "2");
220        assert_eq!(std::env::var("SHARED").unwrap(), "base");
221        std::env::remove_var("ONLY_BASE");
222        std::env::remove_var("ONLY_MODE");
223        std::env::remove_var("SHARED");
224        match prev {
225            Some(v) => std::env::set_var("SOVA_ENV", v),
226            None => std::env::remove_var("SOVA_ENV"),
227        }
228    }
229
230    #[test]
231    fn development_loads_env_dev_alias() {
232        let _g = lock_env();
233        let prev = std::env::var_os("SOVA_ENV");
234        let prev_app = std::env::var_os("APP_ENV");
235        std::env::set_var("SOVA_ENV", "development");
236        std::env::remove_var("APP_ENV");
237        let dir = TempDir::new().unwrap();
238        std::fs::write(dir.path().join(".env.dev"), "DEV_ONLY=1\nSHARED=dev\n").unwrap();
239        std::fs::write(dir.path().join(".env"), "SHARED=root\n").unwrap();
240        std::env::remove_var("DEV_ONLY");
241        std::env::remove_var("SHARED");
242        load_from(dir.path()).unwrap();
243        assert_eq!(std::env::var("DEV_ONLY").unwrap(), "1");
244        assert_eq!(std::env::var("SHARED").unwrap(), "root");
245        std::env::remove_var("DEV_ONLY");
246        std::env::remove_var("SHARED");
247        match prev {
248            Some(v) => std::env::set_var("SOVA_ENV", v),
249            None => std::env::remove_var("SOVA_ENV"),
250        }
251        match prev_app {
252            Some(v) => std::env::set_var("APP_ENV", v),
253            None => std::env::remove_var("APP_ENV"),
254        }
255    }
256}