adotenv 0.9.0

A tooling-grade .env parser: literal values (no variable expansion), exact byte spans, and edits that leave the rest of the file untouched.
Documentation
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);
}