use std::collections::{BTreeMap, BTreeSet};
use nutype::nutype;
use snafu::{Snafu, ensure};
pub const ENV_VAR_NAME_MAX_BYTES: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum EnvVarNameError {
#[snafu(display("environment variable name is empty"))]
Empty,
#[snafu(display(
"environment variable name is {length} bytes long; maximum is {max}",
max = ENV_VAR_NAME_MAX_BYTES,
))]
TooLong {
length: usize,
},
#[snafu(display(
"environment variable name starts with invalid character {c:?}; \
must start with an ASCII letter or '_'"
))]
InvalidLeadingChar {
c: char,
},
#[snafu(display(
"environment variable name contains invalid character {c:?}; \
allowed: ASCII letters, digits, '_'"
))]
InvalidChar {
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 == '_'
}
#[nutype(
validate(with = validate_env_var_name, error = EnvVarNameError),
derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Display,
FromStr,
AsRef,
),
)]
pub struct EnvVarName(String);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvSettings {
pub from_host: BTreeSet<EnvVarName>,
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() {
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")
);
}
}