1use std::{fmt, str::FromStr};
2
3use secrecy::SecretString;
4
5pub trait Environment {
7 fn var(&self, key: &str) -> Result<String, std::env::VarError>;
13}
14
15#[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 std::env::var(key)
23 }
24}
25
26wowlab_engine_macros::define_error! {
27#[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 #[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#[must_use]
71pub fn env_or(key: &str, default: &str) -> String {
72 env_or_with(&ProcessEnvironment, key, default)
73}
74
75pub fn env_or_with(env: &impl Environment, key: &str, default: &str) -> String {
77 env.var(key).unwrap_or_else(|_| default.to_string())
78}
79
80pub fn require_env(key: &str) -> Result<String, EnvError> {
86 require_env_with(&ProcessEnvironment, key)
87}
88
89pub 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#[must_use]
101pub fn get_raw_env(name: &str) -> Option<String> {
102 ProcessEnvironment.var(name).ok()
103}
104
105#[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 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 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#[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#[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#[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#[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 #[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}