use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use crate::{
load::{load_cascade, load_cascade_with, load_files, LoadError, SkipReason},
Dialect, ExpandOptions, ExpandScope, Unresolved,
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"adotenv-load-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
Self { path }
}
fn write(&self, name: &str, content: &str) {
fs::write(self.path.join(name), content).unwrap();
}
fn write_bytes(&self, name: &str, content: &[u8]) {
fs::write(self.path.join(name), content).unwrap();
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn cascade_order_origins_and_overrides_are_explicit() {
let dir = TempDir::new();
dir.write(".env", "A=base\nB=base\n");
dir.write(".env.local", "B=local\nC=local\n");
dir.write(".env.production", "C=mode\nD=mode\n");
dir.write(".env.production.local", "D=mode-local\n");
let loaded = load_cascade(dir.path(), "production").unwrap();
assert_eq!(loaded.get("A"), Some("base"));
assert_eq!(loaded.get("B"), Some("local"));
assert_eq!(loaded.get("C"), Some("mode"));
assert_eq!(loaded.get("D"), Some("mode-local"));
assert_eq!(loaded.entries().len(), 7);
assert_eq!(loaded.overrides().len(), 3);
assert_eq!(
loaded
.overrides()
.iter()
.map(|entry| entry.key.as_str())
.collect::<Vec<_>>(),
["B", "C", "D"]
);
assert_eq!(
loaded.origin("D").unwrap().path,
dir.path().join(".env.production.local")
);
assert_eq!(loaded.origin("D").unwrap().line, 1);
assert_eq!(loaded.origin("A").unwrap().path, dir.path().join(".env"));
}
#[test]
fn missing_cascade_files_are_skipped() {
let dir = TempDir::new();
dir.write(".env", "A=1\n");
let loaded = load_cascade(dir.path(), "production").unwrap();
assert_eq!(loaded.get("A"), Some("1"));
assert_eq!(loaded.len(), 1);
assert_eq!(loaded.entries().len(), 1);
let empty = TempDir::new();
let loaded = load_cascade(empty.path(), "production").unwrap();
assert!(loaded.is_empty());
assert_eq!(loaded.len(), 0);
assert_eq!(loaded.entries().len(), 0);
}
#[cfg(feature = "serde")]
#[test]
fn loaded_environment_deserializes_final_values_with_origins() {
#[derive(Debug, serde::Deserialize, PartialEq)]
struct Config {
host: String,
port: u16,
}
let dir = TempDir::new();
dir.write(".env", "host=localhost\nport=8080\n");
dir.write(".env.local", "port=9090\n");
let loaded = load_cascade(dir.path(), "").unwrap();
let config: Config = loaded.deserialize().unwrap();
assert_eq!(
config,
Config {
host: "localhost".to_string(),
port: 9090,
}
);
let invalid = TempDir::new();
invalid.write(".env", "host=localhost\nport=not-a-number\n");
let loaded = load_cascade(invalid.path(), "").unwrap();
let error = loaded.deserialize::<Config>().unwrap_err();
assert_eq!(error.line(), 2);
assert_eq!(error.key(), Some("port"));
}
#[test]
fn explicit_file_load_requires_each_file() {
let dir = TempDir::new();
dir.write("one.env", "A=1\n");
let loaded = load_files([dir.path().join("one.env")], Dialect::PORTABLE).unwrap();
assert_eq!(loaded.get("A"), Some("1"));
assert!(matches!(
load_files([dir.path().join("missing.env")], Dialect::PORTABLE),
Err(LoadError::Io { .. })
));
}
#[test]
fn invalid_syntax_invalidates_the_whole_load() {
let dir = TempDir::new();
dir.write(".env", "A=1\n");
dir.write(".env.local", "BAD KEY=value\n");
assert!(matches!(
load_cascade(dir.path(), ""),
Err(LoadError::Invalid { .. })
));
}
#[test]
fn invalid_utf8_is_reported_without_lossy_decoding() {
let dir = TempDir::new();
dir.write_bytes(".env", b"A=\xff\n");
assert!(matches!(
load_cascade(dir.path(), ""),
Err(LoadError::InvalidUtf8 { .. })
));
}
#[test]
fn duplicate_and_bare_declarations_are_reported() {
let dir = TempDir::new();
dir.write(".env", "A=1\nA=2\nBARE\nX=3\n");
let loaded = load_cascade(dir.path(), "").unwrap();
assert_eq!(loaded.get("A"), Some("2"));
assert_eq!(loaded.get("X"), Some("3"));
assert_eq!(loaded.repeated().len(), 1);
assert_eq!(loaded.repeated()[0].key, "A");
assert_eq!(loaded.repeated()[0].origins.len(), 2);
assert_eq!(loaded.skipped().len(), 1);
assert_eq!(loaded.skipped()[0].key.as_deref(), Some("BARE"));
assert_eq!(loaded.skipped()[0].reason, SkipReason::Bare);
}
#[test]
fn bare_empty_policy_loads_an_effective_empty_value() {
let dir = TempDir::new();
dir.write(".env", "A\nX=1\n");
let dialect = Dialect {
bare_keys: crate::BareKeys::Empty,
..Dialect::PORTABLE
};
let loaded = load_cascade_with(dir.path(), "", dialect).unwrap();
assert_eq!(loaded.get("A"), Some(""));
assert_eq!(loaded.get("X"), Some("1"));
}
#[test]
fn nonparticipating_declarations_are_skipped() {
let dir = TempDir::new();
dir.write(".env", "A\nX=1\n");
let dialect = Dialect {
bare_keys: crate::BareKeys::Ignore,
..Dialect::PORTABLE
};
let loaded = load_cascade_with(dir.path(), "", dialect).unwrap();
assert_eq!(loaded.get("X"), Some("1"));
assert!(loaded.skipped().iter().any(|skipped| {
skipped.key.as_deref() == Some("A") && skipped.reason == SkipReason::NonParticipating
}));
}
#[test]
fn invalid_modes_cannot_escape_the_cascade() {
let dir = TempDir::new();
for mode in ["..", "../prod", "prod/file", "prod\\file"] {
assert!(matches!(
load_cascade(dir.path(), mode),
Err(LoadError::InvalidMode)
));
}
}
#[test]
fn empty_mode_loads_only_base_and_local_files() {
let dir = TempDir::new();
dir.write(".env", "A=base\n");
dir.write(".env.local", "B=local\n");
dir.write(".env.production", "C=mode\n");
let loaded = load_cascade(dir.path(), "").unwrap();
assert_eq!(loaded.get("A"), Some("base"));
assert_eq!(loaded.get("B"), Some("local"));
assert_eq!(loaded.get("C"), None);
}
#[test]
fn origin_spans_address_the_original_file() {
let dir = TempDir::new();
let source = "# comment\nKEY=value\n";
dir.write(".env", source);
let loaded = load_cascade(dir.path(), "").unwrap();
let origin = loaded.origin("KEY").unwrap();
assert_eq!(&source[origin.span.clone()], "value");
assert_eq!(origin.line, 2);
}
#[test]
fn loaded_resolution_uses_final_values_and_origins() {
let dir = TempDir::new();
dir.write(".env", "A=base\n");
dir.write(".env.local", "B=${A}-suffix\n");
let loaded = load_cascade(dir.path(), "").unwrap();
let options = ExpandOptions {
scope: ExpandScope::WholeFile,
..ExpandOptions::portable()
};
let resolved = loaded.resolve_decoded(|_| None, &options).unwrap();
assert_eq!(resolved.get("A"), Some("base"));
assert_eq!(resolved.get("B"), Some("base-suffix"));
assert_eq!(resolved.len(), 2);
assert_eq!(
resolved.origin("B").unwrap().path,
dir.path().join(".env.local")
);
assert_eq!(resolved.origin("B").unwrap().line, 1);
let resolved = loaded
.resolve_decoded(
|name| (name == "EXTERNAL").then(|| "outside".to_string()),
&options,
)
.unwrap();
assert_eq!(resolved.get("A"), Some("base"));
}
#[test]
fn loaded_resolution_reports_scope_and_origin_failures() {
let dir = TempDir::new();
dir.write(".env", "A=${MISSING}\n");
let loaded = load_cascade(dir.path(), "").unwrap();
let error = loaded
.resolve_decoded(|_| None, &ExpandOptions::portable())
.unwrap_err();
assert!(error.message().contains("whole-file"));
let options = ExpandOptions {
scope: ExpandScope::WholeFile,
unresolved: Unresolved::Error,
..ExpandOptions::portable()
};
let error = loaded.resolve_decoded(|_| None, &options).unwrap_err();
assert_eq!(error.key(), Some("A"));
assert_eq!(error.origin().unwrap().path, dir.path().join(".env"));
assert_eq!(error.origin().unwrap().line, 1);
}
#[test]
fn contextual_resolution_preserves_quote_and_escape_context() {
let dir = TempDir::new();
dir.write(".env", "A='${B}'\nB=value\nC=\"\\$B\"\nD=${B}\n");
let loaded = load_cascade(dir.path(), "").unwrap();
let options = ExpandOptions {
scope: ExpandScope::WholeFile,
..ExpandOptions::portable()
};
let resolved = loaded.resolve_contextual(|_| None, &options).unwrap();
assert_eq!(resolved.get("A"), Some("${B}"));
assert_eq!(resolved.get("B"), Some("value"));
assert_eq!(resolved.get("C"), Some("$B"));
assert_eq!(resolved.get("D"), Some("value"));
assert_eq!(resolved.origin("D").unwrap().line, 4);
}