1use 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
27pub fn load() -> Result<()> {
29 load_from(".")
30}
31
32pub 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 if cfg!(test) {
65 "test".into()
66 } else if cfg!(debug_assertions) {
67 "development".into()
68 } else {
69 "production".into()
70 }
71 })
72}
73
74fn 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 out.push(format!(".env.{short}"));
90 if short != mode {
91 out.push(format!(".env.{mode}"));
92 }
93 if !skip_local {
95 out.push(".env.local".into());
96 }
97 out.push(".env".into());
99 out
100}
101
102pub 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
110pub 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}