dotenvor 0.2.0

Small, fast `.env` parser and loader for Rust
Documentation
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};

use dotenvor::{Error, load};

#[load(path = "tests/fixtures/macro-basic.env", override_existing = false)]
fn load_without_override() -> Result<(), Error> {
    Ok(())
}

#[load(path = "tests/fixtures/macro-basic.env", override_existing = true)]
fn load_with_override() -> Result<(), Error> {
    Ok(())
}

#[load(path = "tests/fixtures/not-found.env", required = false)]
fn load_missing_optional() -> Result<(), Error> {
    Ok(())
}

#[load(path = "tests/fixtures/not-found.env", required = true)]
fn load_missing_required() -> Result<(), Error> {
    Ok(())
}

#[load(
    path = ".env.macro-upward",
    search_upward = true,
    override_existing = true
)]
fn load_with_search_upward() -> Result<(), Error> {
    Ok(())
}

#[load(path = "tests/fixtures/macro-async.env", override_existing = true)]
async fn load_plain_async() -> Result<(), Error> {
    Ok(())
}

#[load(path = "tests/fixtures/macro-async.env", override_existing = true)]
#[tokio::main(flavor = "current_thread")]
async fn load_before_tokio_runtime() -> Result<(), Error> {
    Ok(())
}

#[load(path = "tests/fixtures/macro-async.env", override_existing = true)]
#[tokio::main(flavor = "current_thread")]
async fn load_before_tokio_runtime_generic<T>() -> Result<(), Error>
where
    T: Default,
{
    let _ = T::default();
    Ok(())
}

#[load(path = "tests/fixtures/macro-async.env", override_existing = true)]
#[cfg_attr(all(), tokio::main(flavor = "current_thread"))]
async fn load_before_tokio_runtime_cfg_attr() -> Result<(), Error> {
    Ok(())
}

#[test]
fn load_attribute_respects_override_existing() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    unsafe {
        std::env::set_var("DOTENVOR_MACRO_OVERRIDE", "existing");
    }

    unsafe { load_without_override() }.expect("macro load should succeed");
    assert_eq!(
        std::env::var("DOTENVOR_MACRO_OVERRIDE").expect("env var should be set"),
        "existing"
    );

    unsafe { load_with_override() }.expect("macro load should succeed");
    assert_eq!(
        std::env::var("DOTENVOR_MACRO_OVERRIDE").expect("env var should be set"),
        "from_file"
    );

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_OVERRIDE");
    }
}

#[test]
fn load_attribute_optional_missing_file_is_ok() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    unsafe { load_missing_optional() }.expect("optional missing file should be ignored");
}

#[test]
fn load_attribute_required_missing_file_returns_not_found() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    let err = unsafe { load_missing_required() }.expect_err("required missing file should fail");

    match err {
        Error::Io(io_err) => assert_eq!(io_err.kind(), std::io::ErrorKind::NotFound),
        other => panic!("unexpected error: {other:?}"),
    }
}

#[test]
fn load_attribute_search_upward_finds_parent_file() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    let dir = make_temp_dir("macro-search-upward");
    let parent = dir.join("parent");
    let child = parent.join("child");
    std::fs::create_dir_all(&child).expect("failed to create nested directories");
    write_file(
        &parent.join(".env.macro-upward"),
        "DOTENVOR_MACRO_UPWARD=from_parent\n",
    );

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_UPWARD");
    }

    with_current_dir(&child, || unsafe {
        load_with_search_upward().expect("macro load should succeed");
    });

    assert_eq!(
        std::env::var("DOTENVOR_MACRO_UPWARD").expect("env var should be set"),
        "from_parent"
    );

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_UPWARD");
    }
}

#[test]
fn load_attribute_plain_async_runs_before_future_is_polled() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }

    let future = unsafe { load_plain_async() };
    assert_eq!(
        std::env::var("DOTENVOR_MACRO_ASYNC").expect("env var should be set"),
        "from_async_file"
    );

    tokio::runtime::Builder::new_current_thread()
        .build()
        .expect("runtime should build")
        .block_on(future)
        .expect("macro-wrapped async fn should succeed");

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }
}

#[test]
fn load_attribute_runs_before_tokio_runtime_entry() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }

    unsafe { load_before_tokio_runtime() }.expect("macro-wrapped tokio entry should succeed");
    assert_eq!(
        std::env::var("DOTENVOR_MACRO_ASYNC").expect("env var should be set"),
        "from_async_file"
    );

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }
}

#[test]
fn load_attribute_runs_before_cfg_attr_tokio_runtime_entry() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }

    unsafe { load_before_tokio_runtime_cfg_attr() }
        .expect("macro-wrapped cfg_attr tokio entry should succeed");
    assert_eq!(
        std::env::var("DOTENVOR_MACRO_ASYNC").expect("env var should be set"),
        "from_async_file"
    );

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }
}

#[test]
fn load_attribute_runtime_wrapper_forwards_generics() {
    let _guard = env_lock().lock().expect("env lock poisoned");

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }

    unsafe { load_before_tokio_runtime_generic::<()>() }
        .expect("macro-wrapped generic tokio entry should succeed");
    assert_eq!(
        std::env::var("DOTENVOR_MACRO_ASYNC").expect("env var should be set"),
        "from_async_file"
    );

    unsafe {
        std::env::remove_var("DOTENVOR_MACRO_ASYNC");
    }
}

fn env_lock() -> &'static Mutex<()> {
    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    ENV_LOCK.get_or_init(|| Mutex::new(()))
}

fn with_current_dir<T>(path: &Path, f: impl FnOnce() -> T) -> T {
    let current = std::env::current_dir().expect("failed to read current dir");
    std::env::set_current_dir(path).expect("failed to set current dir");

    let result = f();

    std::env::set_current_dir(current).expect("failed to restore current dir");
    result
}

fn make_temp_dir(name: &str) -> PathBuf {
    let mut path = std::env::temp_dir();
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock should be after unix epoch")
        .as_nanos();
    path.push(format!("dotenvor-{name}-{}-{nanos}", std::process::id()));
    std::fs::create_dir_all(&path).expect("failed to create temp dir");
    path
}

fn write_file(path: &Path, content: &str) {
    std::fs::write(path, content).expect("failed to write file");
}