use std::path::{Path, PathBuf};
use crate::config::{Env, OsEnv, resolve_profile};
#[derive(Debug)]
pub struct DotenvError {
pub path: PathBuf,
pub line: usize,
pub message: String,
}
impl std::fmt::Display for DotenvError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}: {}", self.path.display(), self.line, self.message)
}
}
impl std::error::Error for DotenvError {}
const PROFILE_SELECTOR_KEYS: [&str; 3] = ["AUTUMN_ENV", "AUTUMN_PROFILE", "AUTUMN_IS_DEBUG"];
fn is_profile_selector_key(key: &str) -> bool {
PROFILE_SELECTOR_KEYS.contains(&key)
}
fn candidate_files(profile: &str) -> Vec<String> {
vec![
".env".to_owned(),
".env.local".to_owned(),
format!(".env.{profile}"),
format!(".env.{profile}.local"),
]
}
fn should_load(profile: &str, env: &dyn Env) -> bool {
if let Ok(raw) = env.var("AUTUMN_DOTENV") {
match raw.trim() {
"1" | "true" => return true,
"0" | "false" => return false,
_ => {}
}
}
matches!(profile, "dev" | "test")
}
fn parse_dotenv(path: &Path, contents: &str) -> Result<Vec<(String, String)>, DotenvError> {
let mut out = Vec::new();
for (idx, raw_line) in contents.lines().enumerate() {
let line_no = idx + 1;
let trimmed = raw_line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let body = trimmed
.strip_prefix("export ")
.unwrap_or(trimmed)
.trim_start();
let Some(eq) = body.find('=') else {
return Err(DotenvError {
path: path.to_path_buf(),
line: line_no,
message: format!("expected KEY=VALUE, found `{raw_line}`"),
});
};
let key = body[..eq].trim();
if !is_valid_key(key) {
return Err(DotenvError {
path: path.to_path_buf(),
line: line_no,
message: format!("invalid variable name `{key}` in `{raw_line}`"),
});
}
let value_raw = body[eq + 1..].trim();
let value = parse_value(value_raw);
out.push((key.to_owned(), value));
}
Ok(out)
}
fn is_valid_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn parse_value(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let bytes = raw.as_bytes();
let quote = bytes[0];
if quote == b'"' || quote == b'\'' {
let mut escaped = false;
let mut end_idx = None;
for (i, &b) in bytes.iter().enumerate().skip(1) {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == quote {
end_idx = Some(i);
break;
}
}
if let Some(end) = end_idx {
return raw[1..end].to_owned();
}
}
let comment_start = (0..bytes.len())
.find(|&i| bytes[i] == b'#' && (i == 0 || bytes[i - 1].is_ascii_whitespace()));
comment_start.map_or_else(|| raw.to_owned(), |idx| raw[..idx].trim().to_owned())
}
pub(crate) fn resolve_dotenv_vars(
dir: &Path,
profile: &str,
env: &dyn Env,
) -> Result<Vec<(String, String)>, DotenvError> {
if !should_load(profile, env) {
return Ok(Vec::new());
}
let mut merged: Vec<(String, String)> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for filename in candidate_files(profile) {
let path = dir.join(&filename);
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(DotenvError {
path,
line: 0,
message: format!("failed to read: {e}"),
});
}
};
for (key, value) in parse_dotenv(&path, &contents)? {
if is_profile_selector_key(&key) {
continue;
}
if env.var(&key).is_ok() {
continue;
}
if seen.insert(key.clone()) {
merged.push((key, value));
}
}
}
Ok(merged)
}
pub fn resolve_dotenv_vars_in(
dir: &Path,
profile: &str,
base: &dyn Env,
) -> Result<Vec<(String, String)>, DotenvError> {
resolve_dotenv_vars(dir, profile, base)
}
pub(crate) struct DotenvEnv<'a> {
base: &'a dyn Env,
overlay: std::collections::BTreeMap<String, String>,
}
impl<'a> DotenvEnv<'a> {
pub(crate) fn new(base: &'a dyn Env, vars: Vec<(String, String)>) -> Self {
Self {
base,
overlay: vars.into_iter().collect(),
}
}
}
impl Env for DotenvEnv<'_> {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.base.var(key).or_else(|_| {
self.overlay
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
})
}
}
pub struct DotenvOsEnv {
overlay: std::collections::BTreeMap<String, String>,
}
impl Env for DotenvOsEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
OsEnv.var(key).or_else(|_| {
self.overlay
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
})
}
}
pub(crate) fn dotenv_base_dir(env: &dyn Env) -> PathBuf {
if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
return PathBuf::from(manifest_dir);
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub fn resolve_process_dotenv() -> Result<Vec<(String, String)>, DotenvError> {
let base = OsEnv;
let profile = resolve_profile(&base);
let dir = dotenv_base_dir(&base);
resolve_dotenv_vars(&dir, &profile, &base)
}
pub fn os_env_with_dotenv() -> Result<DotenvOsEnv, DotenvError> {
Ok(DotenvOsEnv {
overlay: resolve_process_dotenv()?.into_iter().collect(),
})
}
pub fn os_env_with_dotenv_for_profile(profile: &str) -> Result<DotenvOsEnv, DotenvError> {
let base = OsEnv;
let dir = dotenv_base_dir(&base);
Ok(DotenvOsEnv {
overlay: resolve_dotenv_vars(&dir, profile, &base)?
.into_iter()
.collect(),
})
}
pub fn os_env_with_dotenv_for_profile_using(
base: &dyn Env,
profile: &str,
) -> Result<DotenvOsEnv, DotenvError> {
let dir = dotenv_base_dir(base);
Ok(DotenvOsEnv {
overlay: resolve_dotenv_vars(&dir, profile, base)?
.into_iter()
.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MockEnv;
use std::path::PathBuf;
#[test]
fn dotenv_profile_selection_reproduces_offsite_wrong_profile_bug() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env.test"),
"AUTUMN_BACKUP__OFFSITE__S3__BUCKET=from-dot-env-test\n\
AUTUMN_OFFSITE_SECRET=sekret\n",
)
.unwrap();
let ambient = MockEnv::new();
let dev: std::collections::HashMap<_, _> = resolve_dotenv_vars(dir.path(), "dev", &ambient)
.unwrap()
.into_iter()
.collect();
assert!(
!dev.contains_key("AUTUMN_BACKUP__OFFSITE__S3__BUCKET"),
"reproduction: dev profile must miss the .env.test offsite override"
);
assert!(!dev.contains_key("AUTUMN_OFFSITE_SECRET"));
let test: std::collections::HashMap<_, _> =
resolve_dotenv_vars(dir.path(), "test", &ambient)
.unwrap()
.into_iter()
.collect();
assert_eq!(
test.get("AUTUMN_BACKUP__OFFSITE__S3__BUCKET")
.map(String::as_str),
Some("from-dot-env-test")
);
assert_eq!(
test.get("AUTUMN_OFFSITE_SECRET").map(String::as_str),
Some("sekret")
);
}
fn p() -> PathBuf {
PathBuf::from("/tmp/.env")
}
#[test]
fn parses_simple_pairs() {
let out = parse_dotenv(&p(), "FOO=bar\nBAZ=qux\n").unwrap();
assert_eq!(
out,
vec![
("FOO".to_owned(), "bar".to_owned()),
("BAZ".to_owned(), "qux".to_owned()),
]
);
}
#[test]
fn strips_matching_quotes() {
let out = parse_dotenv(
&p(),
"A=\"double quoted\"\nB='single quoted'\nC=unquoted value\n",
)
.unwrap();
assert_eq!(out[0], ("A".to_owned(), "double quoted".to_owned()));
assert_eq!(out[1], ("B".to_owned(), "single quoted".to_owned()));
assert_eq!(out[2], ("C".to_owned(), "unquoted value".to_owned()));
}
#[test]
fn no_interpolation_of_dollar_vars() {
let out = parse_dotenv(&p(), "A=${FOO}/literal\n").unwrap();
assert_eq!(out[0], ("A".to_owned(), "${FOO}/literal".to_owned()));
}
#[test]
fn strips_unquoted_inline_comment() {
let out = parse_dotenv(&p(), "KEY=val # c\n").unwrap();
assert_eq!(out[0], ("KEY".to_owned(), "val".to_owned()));
}
#[test]
fn strips_trailing_comment_after_quoted_value() {
let out = parse_dotenv(&p(), "KEY=\"a b\" # c\n").unwrap();
assert_eq!(out[0], ("KEY".to_owned(), "a b".to_owned()));
}
#[test]
fn hash_inside_quotes_is_preserved() {
let out = parse_dotenv(&p(), "KEY=\"a#b\"\n").unwrap();
assert_eq!(out[0], ("KEY".to_owned(), "a#b".to_owned()));
}
#[test]
fn hash_not_preceded_by_whitespace_is_not_a_comment() {
let out = parse_dotenv(&p(), "KEY=a#b\n").unwrap();
assert_eq!(out[0], ("KEY".to_owned(), "a#b".to_owned()));
}
#[test]
fn hash_in_unquoted_connection_string_is_preserved() {
let out = parse_dotenv(&p(), "KEY=postgres://u:p#w@h/db\n").unwrap();
assert_eq!(
out[0],
("KEY".to_owned(), "postgres://u:p#w@h/db".to_owned())
);
}
#[test]
fn empty_value_is_empty_string() {
let out = parse_dotenv(&p(), "KEY=\n").unwrap();
assert_eq!(out[0], ("KEY".to_owned(), String::new()));
}
#[test]
fn skips_comments_blanks_and_handles_export() {
let contents = "\
# a comment
# indented comment
FOO=bar
export BAZ=qux
export SPACED=value
";
let out = parse_dotenv(&p(), contents).unwrap();
assert_eq!(
out,
vec![
("FOO".to_owned(), "bar".to_owned()),
("BAZ".to_owned(), "qux".to_owned()),
("SPACED".to_owned(), "value".to_owned()),
]
);
}
#[test]
fn malformed_line_reports_correct_line_and_path() {
let contents = "FOO=bar\nNOEQUALS\nBAZ=qux\n";
let err = parse_dotenv(&p(), contents).unwrap_err();
assert_eq!(err.line, 2);
assert_eq!(err.path, p());
assert!(err.message.contains("KEY=VALUE"), "got: {}", err.message);
assert!(err.to_string().contains("/tmp/.env:2:"), "{err}");
}
#[test]
fn invalid_key_is_an_error() {
let contents = "FOO=ok\n1BAD=nope\n";
let err = parse_dotenv(&p(), contents).unwrap_err();
assert_eq!(err.line, 2);
assert!(
err.message.contains("invalid variable name"),
"got: {}",
err.message
);
}
#[test]
fn missing_files_are_ok_and_empty() {
let dir = tempfile::tempdir().unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
assert!(out.is_empty());
}
#[test]
fn file_order_earlier_wins() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "KEY=from_env\nONLY_BASE=base\n").unwrap();
std::fs::write(
dir.path().join(".env.local"),
"KEY=from_local\nONLY_LOCAL=local\n",
)
.unwrap();
std::fs::write(dir.path().join(".env.dev"), "KEY=from_dev\nONLY_DEV=dev\n").unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
let map: std::collections::HashMap<_, _> = out.into_iter().collect();
assert_eq!(map.get("KEY").map(String::as_str), Some("from_env"));
assert_eq!(map.get("ONLY_BASE").map(String::as_str), Some("base"));
assert_eq!(map.get("ONLY_LOCAL").map(String::as_str), Some("local"));
assert_eq!(map.get("ONLY_DEV").map(String::as_str), Some("dev"));
}
#[test]
fn real_env_always_wins() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"AUTUMN_DATABASE__URL=postgres://from-dotenv\nOTHER=x\n",
)
.unwrap();
let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://from-real-env");
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
let map: std::collections::HashMap<_, _> = out.into_iter().collect();
assert!(
!map.contains_key("AUTUMN_DATABASE__URL"),
"real env var must win and be excluded from the .env result"
);
assert_eq!(map.get("OTHER").map(String::as_str), Some("x"));
}
#[test]
fn bare_database_url_key_is_returned() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"DATABASE_URL=postgres://localhost/app\n",
)
.unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
let map: std::collections::HashMap<_, _> = out.into_iter().collect();
assert_eq!(
map.get("DATABASE_URL").map(String::as_str),
Some("postgres://localhost/app")
);
}
#[test]
fn read_error_becomes_dotenv_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".env")).unwrap();
let env = MockEnv::new();
let err = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap_err();
assert_eq!(err.line, 0);
assert!(
err.message.contains("failed to read"),
"got: {}",
err.message
);
}
#[test]
fn gating_prod_does_not_load() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\n").unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "prod", &env).unwrap();
assert!(out.is_empty(), "prod must not auto-load .env");
}
#[test]
fn gating_prod_loads_with_opt_in() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\n").unwrap();
let env = MockEnv::new().with("AUTUMN_DOTENV", "1");
let out = resolve_dotenv_vars(dir.path(), "prod", &env).unwrap();
assert_eq!(out, vec![("FOO".to_owned(), "bar".to_owned())]);
}
#[test]
fn gating_dev_loads() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\n").unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
assert_eq!(out, vec![("FOO".to_owned(), "bar".to_owned())]);
}
#[test]
fn gating_dev_disabled_with_opt_out() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\n").unwrap();
let env = MockEnv::new().with("AUTUMN_DOTENV", "0");
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
assert!(out.is_empty(), "AUTUMN_DOTENV=0 must disable loading");
}
#[test]
fn gating_test_profile_loads() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\n").unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "test", &env).unwrap();
assert_eq!(out, vec![("FOO".to_owned(), "bar".to_owned())]);
}
#[test]
fn gating_custom_profile_does_not_load_by_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\n").unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "staging", &env).unwrap();
assert!(
out.is_empty(),
"a custom profile must not auto-load .env by default"
);
}
#[test]
fn gating_custom_profile_loads_with_opt_in() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\n").unwrap();
let env = MockEnv::new().with("AUTUMN_DOTENV", "1");
let out = resolve_dotenv_vars(dir.path(), "staging", &env).unwrap();
assert_eq!(out, vec![("FOO".to_owned(), "bar".to_owned())]);
}
#[test]
fn candidate_file_order() {
assert_eq!(
candidate_files("dev"),
vec![".env", ".env.local", ".env.dev", ".env.dev.local"]
);
}
#[test]
fn dotenv_env_base_wins_over_overlay() {
let base = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://from-real-env");
let env = DotenvEnv::new(
&base,
vec![(
"AUTUMN_DATABASE__URL".to_owned(),
"postgres://from-dotenv".to_owned(),
)],
);
assert_eq!(
env.var("AUTUMN_DATABASE__URL").unwrap(),
"postgres://from-real-env"
);
}
#[test]
fn dotenv_env_overlay_fills_unset_key() {
let base = MockEnv::new();
let env = DotenvEnv::new(
&base,
vec![(
"AUTUMN_DATABASE__URL".to_owned(),
"postgres://from-dotenv".to_owned(),
)],
);
assert_eq!(
env.var("AUTUMN_DATABASE__URL").unwrap(),
"postgres://from-dotenv"
);
}
#[test]
fn dotenv_env_miss_is_not_present() {
let base = MockEnv::new();
let env = DotenvEnv::new(&base, vec![("FOO".to_owned(), "bar".to_owned())]);
assert_eq!(env.var("MISSING"), Err(std::env::VarError::NotPresent));
}
#[test]
fn dotenv_os_env_base_wins_over_overlay() {
let overlay = std::collections::BTreeMap::from([(
"AUTUMN_TEST_DOTENV_OVERLAY_KEY".to_owned(),
"from-dotenv".to_owned(),
)]);
let env = DotenvOsEnv { overlay };
temp_env::with_var(
"AUTUMN_TEST_DOTENV_OVERLAY_KEY",
Some("from-real-env"),
|| {
assert_eq!(
env.var("AUTUMN_TEST_DOTENV_OVERLAY_KEY").unwrap(),
"from-real-env"
);
},
);
}
#[test]
fn dotenv_os_env_overlay_fills_unset_key() {
let overlay = std::collections::BTreeMap::from([(
"AUTUMN_TEST_DOTENV_OVERLAY_UNSET".to_owned(),
"from-dotenv".to_owned(),
)]);
let env = DotenvOsEnv { overlay };
temp_env::with_var_unset("AUTUMN_TEST_DOTENV_OVERLAY_UNSET", || {
assert_eq!(
env.var("AUTUMN_TEST_DOTENV_OVERLAY_UNSET").unwrap(),
"from-dotenv"
);
});
}
#[test]
fn dotenv_env_prod_does_not_flip_resolved_profile() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"AUTUMN_ENV=prod\nAUTUMN_DATABASE__URL=postgres://localhost/app\n",
)
.unwrap();
let base = MockEnv::new();
let vars = resolve_dotenv_vars(dir.path(), "dev", &base).unwrap();
let env = DotenvEnv::new(&base, vars);
assert_eq!(crate::config::resolve_profile(&env), "dev");
}
#[test]
fn dotenv_resolves_into_config_via_overlay() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"AUTUMN_DATABASE__URL=postgres://localhost/from-dotenv\n",
)
.unwrap();
let base = MockEnv::new();
let vars = resolve_dotenv_vars(dir.path(), "dev", &base).unwrap();
let env = DotenvEnv::new(&base, vars);
let config = crate::config::AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(
config.database.url.as_deref(),
Some("postgres://localhost/from-dotenv")
);
}
#[test]
fn profile_selector_keys_are_dropped_from_dotenv() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"AUTUMN_ENV=prod\n\
AUTUMN_PROFILE=prod\n\
AUTUMN_IS_DEBUG=0\n\
AUTUMN_DATABASE__URL=postgres://localhost/app\n",
)
.unwrap();
let env = MockEnv::new();
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
let map: std::collections::HashMap<_, _> = out.into_iter().collect();
assert!(
!map.contains_key("AUTUMN_ENV"),
"AUTUMN_ENV must not be honored from .env"
);
assert!(
!map.contains_key("AUTUMN_PROFILE"),
"AUTUMN_PROFILE must not be honored from .env"
);
assert!(
!map.contains_key("AUTUMN_IS_DEBUG"),
"AUTUMN_IS_DEBUG must not be honored from .env"
);
assert_eq!(
map.get("AUTUMN_DATABASE__URL").map(String::as_str),
Some("postgres://localhost/app")
);
}
#[test]
fn real_env_profile_selector_is_unaffected_by_exclusion() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "AUTUMN_ENV=prod\nOTHER=x\n").unwrap();
let env = MockEnv::new().with("AUTUMN_ENV", "dev");
let out = resolve_dotenv_vars(dir.path(), "dev", &env).unwrap();
let map: std::collections::HashMap<_, _> = out.into_iter().collect();
assert!(!map.contains_key("AUTUMN_ENV"));
assert_eq!(env.var("AUTUMN_ENV").unwrap(), "dev");
assert_eq!(map.get("OTHER").map(String::as_str), Some("x"));
}
#[test]
fn dotenv_base_dir_prefers_manifest_dir() {
let dir = tempfile::tempdir().unwrap();
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
assert_eq!(dotenv_base_dir(&env), dir.path().to_path_buf());
}
#[test]
fn dotenv_base_dir_falls_back_to_cwd_when_unset() {
let env = MockEnv::new();
let expected = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
assert_eq!(dotenv_base_dir(&env), expected);
}
#[test]
fn resolve_reads_env_from_manifest_dir_not_cwd() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FROM_MANIFEST=yes\n").unwrap();
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let base = dotenv_base_dir(&env);
assert_eq!(base, dir.path().to_path_buf());
let out = resolve_dotenv_vars(&base, "dev", &env).unwrap();
let map: std::collections::HashMap<_, _> = out.into_iter().collect();
assert_eq!(map.get("FROM_MANIFEST").map(String::as_str), Some("yes"));
}
#[test]
fn real_env_wins_over_dotenv_end_to_end() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"AUTUMN_DATABASE__URL=postgres://localhost/from-dotenv\n",
)
.unwrap();
let base =
MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://localhost/from-real-env");
let vars = resolve_dotenv_vars(dir.path(), "dev", &base).unwrap();
let env = DotenvEnv::new(&base, vars);
let config = crate::config::AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(
config.database.url.as_deref(),
Some("postgres://localhost/from-real-env")
);
}
}