Skip to main content

stasis/application/config/
env.rs

1use 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/// Bootstrap or lookup failure for environment configuration.
11#[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/// Controls optional `.env` and secrets-directory loading during bootstrap.
39#[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/// Summary of what bootstrap loaded. Never includes secret values.
48#[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
57/// Loads `.env` (when present) and optional file secrets, then installs the global resolver.
58///
59/// Resolution order for [`non_empty`] / [`required`]:
60/// 1. Process environment (explicit exports and injected runtime secrets)
61/// 2. Files under `STASIS_SECRETS_DIR` (or `options.secrets_dir`)
62///
63/// Dotenv values are merged into the process environment without overriding existing keys.
64pub 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
117/// Returns a trimmed non-empty value for `key`, or `None`.
118pub fn non_empty(key: &str) -> Option<String> {
119    resolve(key)
120}
121
122/// Returns the resolved value or `default`.
123pub fn with_default(key: &str, default: &str) -> String {
124    non_empty(key).unwrap_or_else(|| default.to_string())
125}
126
127/// Returns the first non-empty value from `keys`.
128pub fn first_non_empty(keys: &[&str]) -> Option<String> {
129    keys.iter().find_map(|key| non_empty(key))
130}
131
132/// Returns the resolved value or an [`EnvError`] that names the missing key only.
133pub fn required(key: &str) -> Result<String, EnvError> {
134    non_empty(key).ok_or_else(|| EnvError::missing(key))
135}
136
137/// Parses common truthy/falsey env strings (`1`, `true`, `yes`, `on`).
138pub 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
144/// Loads a dotenv file without installing the global resolver.
145pub 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}