stasis/application/config/
env.rs1use std::path::{Path, PathBuf};
2
3use crate::application::config::secrets::{
4 ChainedSecretsSource, FileSecretsSource, OsEnvSource, default_secrets_dir, install_resolver,
5 resolve,
6};
7
8const DEFAULT_DOTENV_FILE: &str = ".env";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct EnvError {
13 message: String,
14}
15
16impl EnvError {
17 pub fn missing(key: &str) -> Self {
18 Self {
19 message: format!("missing required environment variable: {key}"),
20 }
21 }
22
23 pub fn bootstrap(message: impl Into<String>) -> Self {
24 Self {
25 message: message.into(),
26 }
27 }
28}
29
30impl std::fmt::Display for EnvError {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 write!(f, "{}", self.message)
33 }
34}
35
36impl std::error::Error for EnvError {}
37
38#[derive(Debug, Clone, Default)]
40pub struct EnvBootstrapOptions {
41 pub dotenv_path: Option<PathBuf>,
42 pub secrets_dir: Option<PathBuf>,
43 pub skip_dotenv: bool,
44 pub skip_secrets_dir: bool,
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct EnvBootstrapReport {
50 pub dotenv_loaded: bool,
51 pub dotenv_path: Option<PathBuf>,
52 pub secrets_dir_loaded: bool,
53 pub secrets_dir: Option<PathBuf>,
54 pub secrets_keys_loaded: usize,
55}
56
57pub fn bootstrap() -> Result<EnvBootstrapReport, EnvError> {
65 bootstrap_with(EnvBootstrapOptions::default())
66}
67
68pub fn bootstrap_with(options: EnvBootstrapOptions) -> Result<EnvBootstrapReport, EnvError> {
69 let mut report = EnvBootstrapReport::default();
70
71 if !options.skip_dotenv {
72 let dotenv_path = options
73 .dotenv_path
74 .clone()
75 .or_else(|| non_empty("STASIS_ENV_FILE").map(PathBuf::from))
76 .unwrap_or_else(|| PathBuf::from(DEFAULT_DOTENV_FILE));
77
78 match dotenvy::from_path(&dotenv_path) {
79 Ok(()) => {
80 report.dotenv_loaded = true;
81 report.dotenv_path = Some(dotenv_path);
82 }
83 Err(err) if err.not_found() => {}
84 Err(err) => {
85 return Err(EnvError::bootstrap(format!(
86 "failed to load dotenv file {}: {err}",
87 dotenv_path.display()
88 )));
89 }
90 }
91 }
92
93 let secrets_dir = if options.skip_secrets_dir {
94 None
95 } else {
96 options.secrets_dir.or_else(default_secrets_dir)
97 };
98
99 let file_source = secrets_dir
100 .as_ref()
101 .map(FileSecretsSource::from_dir)
102 .unwrap_or_default();
103 if let Some(dir) = secrets_dir {
104 report.secrets_dir_loaded = !file_source.is_empty() || dir.is_dir();
105 report.secrets_dir = Some(dir);
106 report.secrets_keys_loaded = file_source.len();
107 }
108
109 let resolver = ChainedSecretsSource::new()
110 .with_source(OsEnvSource)
111 .with_source(file_source);
112 install_resolver(resolver);
113
114 Ok(report)
115}
116
117pub fn non_empty(key: &str) -> Option<String> {
119 resolve(key)
120}
121
122pub fn with_default(key: &str, default: &str) -> String {
124 non_empty(key).unwrap_or_else(|| default.to_string())
125}
126
127pub fn first_non_empty(keys: &[&str]) -> Option<String> {
129 keys.iter().find_map(|key| non_empty(key))
130}
131
132pub fn required(key: &str) -> Result<String, EnvError> {
134 non_empty(key).ok_or_else(|| EnvError::missing(key))
135}
136
137pub fn truthy(key: &str) -> bool {
139 non_empty(key)
140 .map(|value| value.to_ascii_lowercase())
141 .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on"))
142}
143
144pub fn load_dotenv_from(path: impl AsRef<Path>) -> Result<(), EnvError> {
146 dotenvy::from_path(path.as_ref()).map_err(|err| {
147 EnvError::bootstrap(format!(
148 "failed to load dotenv file {}: {err}",
149 path.as_ref().display()
150 ))
151 })
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::fs;
158 use std::sync::{Mutex, OnceLock};
159 use std::time::{SystemTime, UNIX_EPOCH};
160
161 fn test_lock() -> std::sync::MutexGuard<'static, ()> {
162 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
163 LOCK.get_or_init(|| Mutex::new(()))
164 .lock()
165 .expect("env test lock should be available")
166 }
167
168 fn temp_dir(prefix: &str) -> PathBuf {
169 let nanos = SystemTime::now()
170 .duration_since(UNIX_EPOCH)
171 .expect("clock should be after epoch")
172 .as_nanos();
173 std::env::temp_dir().join(format!("{prefix}-{nanos}"))
174 }
175
176 #[test]
177 fn bootstrap_loads_dotenv_without_overriding_existing_env() {
178 let _guard = test_lock();
179 let dir = temp_dir("stasis-dotenv");
180 fs::create_dir_all(&dir).expect("temp dir should be created");
181 fs::write(
182 dir.join(".env"),
183 "STASIS_BOOTSTRAP_TEST=from-dotenv\nSTASIS_BOOTSTRAP_EXISTING=from-dotenv\n",
184 )
185 .expect(".env should be written");
186
187 unsafe {
188 std::env::set_var("STASIS_BOOTSTRAP_EXISTING", "from-os");
189 }
190 let report = bootstrap_with(EnvBootstrapOptions {
191 dotenv_path: Some(dir.join(".env")),
192 skip_secrets_dir: true,
193 ..Default::default()
194 })
195 .expect("bootstrap should succeed");
196
197 assert!(report.dotenv_loaded);
198 assert_eq!(non_empty("STASIS_BOOTSTRAP_TEST"), Some("from-dotenv".to_string()));
199 assert_eq!(
200 non_empty("STASIS_BOOTSTRAP_EXISTING"),
201 Some("from-os".to_string())
202 );
203
204 unsafe {
205 std::env::remove_var("STASIS_BOOTSTRAP_TEST");
206 std::env::remove_var("STASIS_BOOTSTRAP_EXISTING");
207 }
208 let _ = fs::remove_dir_all(&dir);
209 }
210
211 #[test]
212 fn required_reports_missing_key_without_value() {
213 let _guard = test_lock();
214 let key = "STASIS_REQUIRED_MISSING_TEST";
215 unsafe {
216 std::env::remove_var(key);
217 }
218
219 let err = required(key).expect_err("missing key should fail");
220 assert_eq!(err.to_string(), format!("missing required environment variable: {key}"));
221 }
222
223 #[test]
224 fn truthy_parses_common_values() {
225 let _guard = test_lock();
226 unsafe {
227 std::env::set_var("STASIS_TRUTHY_TEST", "YeS");
228 }
229 assert!(truthy("STASIS_TRUTHY_TEST"));
230 unsafe {
231 std::env::remove_var("STASIS_TRUTHY_TEST");
232 }
233 }
234}