use snafu::{Snafu, ensure};
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum NameError {
#[snafu(display("name is empty"))]
Empty,
#[snafu(display("name is {length} bytes long; maximum is 64"))]
TooLong {
length: usize,
},
#[snafu(display(
"name starts with invalid character {c:?}; \
must start with an ASCII alphanumeric character"
))]
InvalidLeadingChar {
c: char,
},
#[snafu(display(
"name contains invalid character {c:?}; \
allowed: ASCII alphanumeric, '_', '-'"
))]
InvalidChar {
c: char,
},
}
pub(crate) fn validate(s: &str) -> Result<(), NameError> {
validate_with_max(s, 64)
}
pub(crate) fn validate_short(s: &str) -> Result<(), NameError> {
validate_with_max(s, 32)
}
fn validate_with_max(s: &str, max: usize) -> Result<(), NameError> {
ensure!(!s.is_empty(), EmptySnafu);
ensure!(s.len() <= max, TooLongSnafu { length: s.len() });
let mut chars = s.chars();
let first = chars.next().expect("len >= 1, checked above");
ensure!(is_leading_char(first), InvalidLeadingCharSnafu { c: first });
for c in chars {
ensure!(is_trailing_char(c), InvalidCharSnafu { c });
}
Ok(())
}
fn is_leading_char(c: char) -> bool {
c.is_ascii_alphanumeric()
}
fn is_trailing_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
#[cfg(test)]
mod tests {
use crate::name::validation::{NameError, validate};
use proptest::prelude::*;
#[test]
fn id_001_accepts_full_alphabet() {
assert!(validate("aZ09_-").is_ok());
assert!(validate("HelloWorld").is_ok());
assert!(validate("a-b-c_d_e").is_ok());
}
#[test]
fn id_001_rejects_dot() {
assert!(matches!(
validate("foo.bar"),
Err(NameError::InvalidChar { c: '.' })
));
}
#[test]
fn id_001_rejects_slash() {
assert!(matches!(
validate("foo/bar"),
Err(NameError::InvalidChar { c: '/' })
));
}
#[test]
fn id_001_rejects_space() {
assert!(matches!(
validate("foo bar"),
Err(NameError::InvalidChar { c: ' ' })
));
}
#[test]
fn id_001_rejects_non_ascii() {
assert!(matches!(
validate("caf\u{00E9}"),
Err(NameError::InvalidChar { c: '\u{00E9}' })
));
}
#[test]
fn id_001_rejects_prefix_sigils_when_internal() {
assert!(matches!(
validate("a~b"),
Err(NameError::InvalidChar { c: '~' })
));
assert!(matches!(
validate("a^b"),
Err(NameError::InvalidChar { c: '^' })
));
assert!(matches!(
validate("a:b"),
Err(NameError::InvalidChar { c: ':' })
));
assert!(matches!(
validate("a[b"),
Err(NameError::InvalidChar { c: '[' })
));
}
#[test]
fn id_002_accepts_leading_letter() {
assert!(validate("a").is_ok());
assert!(validate("Z").is_ok());
}
#[test]
fn id_002_accepts_leading_digit() {
assert!(validate("0snapshot").is_ok());
assert!(validate("9").is_ok());
}
#[test]
fn id_002_rejects_leading_hyphen() {
assert!(matches!(
validate("-foo"),
Err(NameError::InvalidLeadingChar { c: '-' })
));
}
#[test]
fn id_002_rejects_leading_underscore() {
assert!(matches!(
validate("_foo"),
Err(NameError::InvalidLeadingChar { c: '_' })
));
}
#[test]
fn id_003_rejects_empty() {
assert!(matches!(validate(""), Err(NameError::Empty)));
}
#[test]
fn id_003_accepts_one_byte() {
assert!(validate("a").is_ok());
}
#[test]
fn id_003_accepts_64_bytes() {
let exactly_64 = "a".repeat(64);
assert!(validate(&exactly_64).is_ok());
}
#[test]
fn id_003_rejects_65_bytes() {
let too_long = "a".repeat(65);
assert!(matches!(
validate(&too_long),
Err(NameError::TooLong { length: 65 })
));
}
proptest! {
#[test]
fn prop_alphabet_strategy_always_validates(
s in "[A-Za-z0-9][A-Za-z0-9_-]{0,63}"
) {
prop_assert!(validate(&s).is_ok());
}
}
}