env-required 0.1.0

Tiny macro to validate required environment variables (and parse via FromStr).
Documentation
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]

#![doc = include_str!("../README.md")]

/// Read and validate required environment variables.
///
/// ## Single env var (String)
///
/// ```rust,no_run
/// use env_required::required;
///
/// let database_url = required!("DATABASE_URL");
/// ```
///
/// ## Parse via `FromStr`
///
/// ```rust,no_run
/// use env_required::required;
///
/// let port: u16 = required!("PORT" => u16);
/// let workers: usize = required!("WORKERS" => _); // `_` lets the compiler infer the type.
/// ```
///
/// ## Validate multiple variables (no boilerplate)
///
/// ```rust,no_run
/// use env_required::required;
///
/// required!(["DATABASE_URL", "PORT", "RUST_LOG"]);
/// ```
///
/// ## Custom message
///
/// ```rust,no_run
/// use env_required::required;
///
/// let token = required!("API_TOKEN", "API_TOKEN is required to call Example API");
/// required!(["DATABASE_URL", "PORT"], "missing configuration for my-service");
/// ```
#[macro_export]
macro_rules! required {
    // --- Single variable (String)
    ($key:literal $(,)?) => {
        $crate::__private::required_string($key, None)
    };
    ($key:literal, $msg:expr $(,)?) => {
        $crate::__private::required_string($key, Some(($msg).to_string()))
    };

    // --- Single variable (parse via FromStr)
    ($key:literal => $t:ty $(,)?) => {
        $crate::__private::required_parse::<$t>($key, None)
    };
    ($key:literal => $t:ty, $msg:expr $(,)?) => {
        $crate::__private::required_parse::<$t>($key, Some(($msg).to_string()))
    };

    // --- Validate many (no values returned)
    ([$($key:literal),+ $(,)?] $(,)?) => {
        $crate::__private::validate_required(&[$($key),+], None)
    };
    ([$($key:literal),+ $(,)?], $msg:expr $(,)?) => {
        $crate::__private::validate_required(&[$($key),+], Some(($msg).to_string()))
    };

    // --- Misuse help (compile-time errors)
    () => {
        ::core::compile_error!(
            "env-required: expected input. Example: required!(\"PORT\") or required!([\"A\", \"B\"])"
        )
    };
    ($($anything:tt)+) => {
        ::core::compile_error!(
            "env-required: invalid syntax. Use string literals, e.g. required!(\"PORT\"), required!(\"PORT\" => u16), required!([\"A\", \"B\"])."
        )
    };
}

#[doc(hidden)]
pub mod __private {
    #![allow(missing_docs)]

    use core::fmt;
    use core::str::FromStr;

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum VarIssueKind {
        Missing,
        Empty,
        NotUnicode,
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct VarIssue {
        pub key: &'static str,
        pub kind: VarIssueKind,
    }

    pub fn required_string(key: &'static str, msg: Option<String>) -> String {
        match raw_required(key) {
            Ok(v) => v,
            Err(issue) => panic_issues(&[issue], msg.as_deref()),
        }
    }

    pub fn required_parse<T>(key: &'static str, msg: Option<String>) -> T
    where
        T: FromStr,
        T::Err: fmt::Display,
    {
        let raw = match raw_required(key) {
            Ok(v) => v,
            Err(issue) => panic_issues(&[issue], msg.as_deref()),
        };

        match raw.parse::<T>() {
            Ok(v) => v,
            Err(err) => panic_parse::<T>(key, &err, msg.as_deref()),
        }
    }

    pub fn validate_required(keys: &[&'static str], msg: Option<String>) {
        let mut issues = Vec::new();
        for &key in keys {
            if let Err(issue) = raw_required(key) {
                issues.push(issue);
            }
        }
        if !issues.is_empty() {
            panic_issues(&issues, msg.as_deref());
        }
    }

    fn raw_required(key: &'static str) -> Result<String, VarIssue> {
        let os = match std::env::var_os(key) {
            Some(v) => v,
            None => {
                return Err(VarIssue {
                    key,
                    kind: VarIssueKind::Missing,
                })
            }
        };

        let s = match os.into_string() {
            Ok(v) => v,
            Err(_) => {
                return Err(VarIssue {
                    key,
                    kind: VarIssueKind::NotUnicode,
                })
            }
        };

        #[cfg(not(feature = "allow-empty"))]
        if s.is_empty() {
            return Err(VarIssue {
                key,
                kind: VarIssueKind::Empty,
            });
        }

        Ok(s)
    }

    fn panic_parse<T>(key: &'static str, err: &T::Err, msg: Option<&str>) -> !
    where
        T: FromStr,
        T::Err: fmt::Display,
    {
        let mut out = String::new();
        if let Some(msg) = msg {
            out.push_str("env-required: ");
            out.push_str(msg);
            out.push_str("\n\n");
        }

        out.push_str("env-required: failed to parse required environment variable\n");
        out.push_str("\n");
        out.push_str("Key: ");
        out.push_str(key);
        out.push_str("\n");
        out.push_str("Expected type: ");
        out.push_str(core::any::type_name::<T>());
        out.push_str("\n");
        out.push_str("Parse error: ");
        out.push_str(&err.to_string());
        out.push_str("\n\n");

        out.push_str("How to fix:\n");
        out.push_str("- Ensure the value matches the expected type (see the message above).\n");
        out.push_str("- Tip: print the env var before parsing to inspect its contents.\n");

        panic!("{}", out);
    }

    fn panic_issues(issues: &[VarIssue], msg: Option<&str>) -> ! {
        let mut out = String::new();
        if let Some(msg) = msg {
            out.push_str("env-required: ");
            out.push_str(msg);
            out.push_str("\n\n");
        }

        if issues.len() == 1 {
            let i = issues[0];
            out.push_str("env-required: missing required environment variable\n\n");
            out.push_str("Key: ");
            out.push_str(i.key);
            out.push_str("\n");
            out.push_str("Problem: ");
            out.push_str(kind_human(i.kind));
            out.push_str("\n\n");
        } else {
            out.push_str("env-required: missing required environment variables\n\n");
            out.push_str("Missing count: ");
            out.push_str(&issues.len().to_string());
            out.push_str("\n\n");
            for i in issues {
                out.push_str("- ");
                out.push_str(i.key);
                out.push_str(": ");
                out.push_str(kind_human(i.kind));
                out.push_str("\n");
            }
            out.push_str("\n");
        }

        out.push_str("How to fix:\n");
        out.push_str("- Set the env var(s) before running this program.\n");
        out.push_str("- Example (bash/zsh): export KEY=\"value\"\n");
        out.push_str("- Example (PowerShell): $Env:KEY = \"value\"\n");

        #[cfg(not(feature = "allow-empty"))]
        {
            out.push_str("- Note: empty strings (KEY=\"\") are treated as missing by default.\n");
            out.push_str("  Enable feature `allow-empty` if you want to accept empty values.\n");
        }

        panic!("{}", out);
    }

    fn kind_human(kind: VarIssueKind) -> &'static str {
        match kind {
            VarIssueKind::Missing => "not set",
            VarIssueKind::Empty => "set but empty",
            VarIssueKind::NotUnicode => "set but not valid UTF-8",
        }
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
    }

    #[test]
    fn reads_string_env_var() {
        let _guard = lock_env();
        std::env::set_var("ENV_REQUIRED_TEST_URL", "postgres://localhost/db");

        let v = required!("ENV_REQUIRED_TEST_URL");
        assert_eq!(v, "postgres://localhost/db");

        std::env::remove_var("ENV_REQUIRED_TEST_URL");
    }

    #[test]
    fn parses_fromstr_type() {
        let _guard = lock_env();
        std::env::set_var("ENV_REQUIRED_TEST_PORT", "5432");

        let port: u16 = required!("ENV_REQUIRED_TEST_PORT" => u16);
        assert_eq!(port, 5432);

        std::env::remove_var("ENV_REQUIRED_TEST_PORT");
    }

    #[test]
    fn validates_many() {
        let _guard = lock_env();
        std::env::set_var("ENV_REQUIRED_TEST_A", "a");
        std::env::set_var("ENV_REQUIRED_TEST_B", "b");

        required!(["ENV_REQUIRED_TEST_A", "ENV_REQUIRED_TEST_B"]);

        std::env::remove_var("ENV_REQUIRED_TEST_A");
        std::env::remove_var("ENV_REQUIRED_TEST_B");
    }

    #[test]
    fn missing_env_panics_with_key_name() {
        let _guard = lock_env();
        std::env::remove_var("ENV_REQUIRED_TEST_MISSING");

        let panic_msg = std::panic::catch_unwind(|| {
            let _ = required!("ENV_REQUIRED_TEST_MISSING");
        })
        .expect_err("expected panic");

        let msg = panic_to_string(panic_msg);
        assert!(msg.contains("ENV_REQUIRED_TEST_MISSING"));
        assert!(msg.contains("missing required environment variable"));
    }

    #[test]
    fn parse_error_includes_type_name() {
        let _guard = lock_env();
        std::env::set_var("ENV_REQUIRED_TEST_BAD_U16", "not-a-number");

        let panic_msg = std::panic::catch_unwind(|| {
            let _: u16 = required!("ENV_REQUIRED_TEST_BAD_U16" => u16);
        })
        .expect_err("expected panic");

        let msg = panic_to_string(panic_msg);
        assert!(msg.contains("ENV_REQUIRED_TEST_BAD_U16"));
        assert!(msg.contains("Expected type"));
        assert!(msg.contains("u16"));

        std::env::remove_var("ENV_REQUIRED_TEST_BAD_U16");
    }

    #[test]
    fn validate_many_reports_all_missing_keys() {
        let _guard = lock_env();
        std::env::set_var("ENV_REQUIRED_TEST_PRESENT", "x");
        std::env::remove_var("ENV_REQUIRED_TEST_MISSING_1");
        std::env::remove_var("ENV_REQUIRED_TEST_MISSING_2");

        let panic_msg = std::panic::catch_unwind(|| {
            required!([
                "ENV_REQUIRED_TEST_PRESENT",
                "ENV_REQUIRED_TEST_MISSING_1",
                "ENV_REQUIRED_TEST_MISSING_2",
            ]);
        })
        .expect_err("expected panic");

        let msg = panic_to_string(panic_msg);
        assert!(msg.contains("ENV_REQUIRED_TEST_MISSING_1"));
        assert!(msg.contains("ENV_REQUIRED_TEST_MISSING_2"));

        std::env::remove_var("ENV_REQUIRED_TEST_PRESENT");
    }

    fn panic_to_string(p: Box<dyn std::any::Any + Send>) -> String {
        if let Some(s) = p.downcast_ref::<&'static str>() {
            s.to_string()
        } else if let Some(s) = p.downcast_ref::<String>() {
            s.clone()
        } else {
            "<non-string panic payload>".to_string()
        }
    }
}