haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! Task-level environment-variable settings.
//!
//! Carries the two complementary mechanisms by which a task's
//! environment is composed (`CACHE-008`):
//!
//! - [`EnvSettings::from_host`]: a set of variable names whose
//!   current host values are forwarded into the task's
//!   environment.
//! - [`EnvSettings::overrides`]: a name-to-value map whose entries
//!   are written verbatim into the task's environment, taking
//!   precedence over `from_host` on name collisions.
//!
//! Both maps key on [`EnvVarName`], a validated newtype enforcing
//! the POSIX environment-variable-name alphabet
//! (`[A-Za-z_][A-Za-z0-9_]*`, max 256 bytes).

use std::collections::{BTreeMap, BTreeSet};

use nutype::nutype;
use snafu::{Snafu, ensure};

/// Maximum byte length of an [`EnvVarName`] (POSIX practice and
/// `CFG-032` / `CFG-033`).
pub const ENV_VAR_NAME_MAX_BYTES: usize = 256;

/// Errors produced when validating an [`EnvVarName`].
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum EnvVarNameError {
    /// Empty input.
    #[snafu(display("environment variable name is empty"))]
    Empty,

    /// Input exceeded the 256-byte maximum.
    #[snafu(display(
        "environment variable name is {length} bytes long; maximum is {max}",
        max = ENV_VAR_NAME_MAX_BYTES,
    ))]
    TooLong {
        /// Byte length of the rejected input.
        length: usize,
    },

    /// Leading character was not in `[A-Za-z_]`.
    #[snafu(display(
        "environment variable name starts with invalid character {c:?}; \
         must start with an ASCII letter or '_'"
    ))]
    InvalidLeadingChar {
        /// The offending leading character.
        c: char,
    },

    /// A non-leading character was not in `[A-Za-z0-9_]`.
    #[snafu(display(
        "environment variable name contains invalid character {c:?}; \
         allowed: ASCII letters, digits, '_'"
    ))]
    InvalidChar {
        /// The offending non-leading character.
        c: char,
    },
}

pub(crate) fn validate_env_var_name(s: &str) -> Result<(), EnvVarNameError> {
    ensure!(!s.is_empty(), EmptySnafu);
    ensure!(
        s.len() <= ENV_VAR_NAME_MAX_BYTES,
        TooLongSnafu { length: s.len() }
    );

    let mut chars = s.chars();
    let first = chars.next().expect("len >= 1, checked above");
    ensure!(is_env_leading(first), InvalidLeadingCharSnafu { c: first });

    for c in chars {
        ensure!(is_env_trailing(c), InvalidCharSnafu { c });
    }
    Ok(())
}

fn is_env_leading(c: char) -> bool {
    c.is_ascii_alphabetic() || c == '_'
}

fn is_env_trailing(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '_'
}

/// A validated POSIX environment-variable name.
///
/// Alphabet: `[A-Za-z_][A-Za-z0-9_]*`. Maximum length: 256 bytes.
/// Construction is via [`EnvVarName::try_new`] or
/// [`std::str::FromStr`].
///
/// # Examples
///
/// ```
/// use haz_domain::env::EnvVarName;
///
/// let path = EnvVarName::try_new("PATH").unwrap();
/// assert_eq!(path.to_string(), "PATH");
///
/// assert!(EnvVarName::try_new("").is_err());
/// assert!(EnvVarName::try_new("1BAD").is_err()); // leading digit
/// assert!(EnvVarName::try_new("WITH-DASH").is_err()); // dash not allowed
/// ```
#[nutype(
    validate(with = validate_env_var_name, error = EnvVarNameError),
    derive(
        Debug,
        Clone,
        PartialEq,
        Eq,
        PartialOrd,
        Ord,
        Hash,
        Display,
        FromStr,
        AsRef,
    ),
)]
pub struct EnvVarName(String);

/// Per-task environment-variable settings.
///
/// `Default::default()` yields the `CFG-032` / `CFG-033` defaults
/// (both collections empty); a task with no `env` mapping is
/// equivalent to a fully-defaulted [`EnvSettings`].
///
/// `from_host` and `overrides` MAY share names; on collision, the
/// `overrides` value wins at runtime (`CACHE-008`). The domain
/// layer does not enforce disjointness because the precedence rule
/// makes the overlap unambiguous.
///
/// # Examples
///
/// ```
/// use std::collections::{BTreeMap, BTreeSet};
/// use haz_domain::env::{EnvSettings, EnvVarName};
///
/// let mut from_host = BTreeSet::new();
/// from_host.insert(EnvVarName::try_new("PATH").unwrap());
///
/// let mut overrides = BTreeMap::new();
/// overrides.insert(
///     EnvVarName::try_new("RUST_LOG").unwrap(),
///     "debug".to_owned(),
/// );
///
/// let env = EnvSettings { from_host, overrides };
/// assert_eq!(env.overrides.len(), 1);
/// assert_eq!(env.from_host.len(), 1);
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvSettings {
    /// Variable names whose host values are forwarded.
    pub from_host: BTreeSet<EnvVarName>,
    /// Name-to-value overrides; precedence over `from_host`.
    pub overrides: BTreeMap<EnvVarName, String>,
}

#[cfg(test)]
mod tests {
    use std::collections::{BTreeMap, BTreeSet};

    use crate::env::{ENV_VAR_NAME_MAX_BYTES, EnvSettings, EnvVarName, EnvVarNameError};

    #[test]
    fn accepts_typical_env_var_names() {
        for s in [
            "PATH",
            "HOME",
            "RUST_LOG",
            "_HIDDEN",
            "X1",
            "lower_case",
            "MixedCase_42",
        ] {
            assert!(
                EnvVarName::try_new(s).is_ok(),
                "rejected typical name {s:?}"
            );
        }
    }

    #[test]
    fn rejects_empty() {
        assert!(matches!(
            EnvVarName::try_new(""),
            Err(EnvVarNameError::Empty)
        ));
    }

    #[test]
    fn rejects_leading_digit() {
        assert!(matches!(
            EnvVarName::try_new("1FOO"),
            Err(EnvVarNameError::InvalidLeadingChar { c: '1' })
        ));
    }

    #[test]
    fn rejects_leading_dash() {
        assert!(matches!(
            EnvVarName::try_new("-FOO"),
            Err(EnvVarNameError::InvalidLeadingChar { c: '-' })
        ));
    }

    #[test]
    fn rejects_internal_dash() {
        assert!(matches!(
            EnvVarName::try_new("WITH-DASH"),
            Err(EnvVarNameError::InvalidChar { c: '-' })
        ));
    }

    #[test]
    fn rejects_internal_dot() {
        assert!(matches!(
            EnvVarName::try_new("a.b"),
            Err(EnvVarNameError::InvalidChar { c: '.' })
        ));
    }

    #[test]
    fn rejects_internal_space() {
        assert!(matches!(
            EnvVarName::try_new("FOO BAR"),
            Err(EnvVarNameError::InvalidChar { c: ' ' })
        ));
    }

    #[test]
    fn rejects_non_ascii() {
        assert!(matches!(
            EnvVarName::try_new("caf\u{00E9}"),
            Err(EnvVarNameError::InvalidChar { c: '\u{00E9}' })
        ));
    }

    #[test]
    fn accepts_one_byte_underscore() {
        // `_` alone is a valid POSIX env var name.
        assert!(EnvVarName::try_new("_").is_ok());
    }

    #[test]
    fn accepts_at_max_byte_cap() {
        let exactly_max = "A".repeat(ENV_VAR_NAME_MAX_BYTES);
        assert!(EnvVarName::try_new(&exactly_max).is_ok());
    }

    #[test]
    fn rejects_above_max_byte_cap() {
        let too_long = "A".repeat(ENV_VAR_NAME_MAX_BYTES + 1);
        assert!(matches!(
            EnvVarName::try_new(&too_long),
            Err(EnvVarNameError::TooLong { length })
                if length == ENV_VAR_NAME_MAX_BYTES + 1
        ));
    }

    #[test]
    fn env_var_name_is_case_sensitive() {
        let upper = EnvVarName::try_new("PATH").unwrap();
        let lower = EnvVarName::try_new("path").unwrap();
        assert_ne!(upper, lower);
    }

    #[test]
    fn env_settings_default_is_empty() {
        let s = EnvSettings::default();
        assert!(s.from_host.is_empty());
        assert!(s.overrides.is_empty());
    }

    #[test]
    fn env_settings_round_trip() {
        let mut from_host = BTreeSet::new();
        from_host.insert(EnvVarName::try_new("PATH").unwrap());
        from_host.insert(EnvVarName::try_new("HOME").unwrap());

        let mut overrides = BTreeMap::new();
        overrides.insert(EnvVarName::try_new("RUST_LOG").unwrap(), "debug".to_owned());

        let env = EnvSettings {
            from_host,
            overrides,
        };
        assert_eq!(env.from_host.len(), 2);
        assert_eq!(env.overrides.len(), 1);
        assert_eq!(
            env.overrides
                .get(&EnvVarName::try_new("RUST_LOG").unwrap())
                .map(String::as_str),
            Some("debug")
        );
    }
}