haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! Shared validator and error type for the identifier newtypes.
//!
//! Both the [`NameError`] type and the [`validate`] function are
//! consumed by the four sibling newtypes (`ProjectName`, `TagName`,
//! `TaskName`, `MutexName`). They live here so the dependency
//! direction is one-way: each type file imports from
//! `crate::name::validation`; nothing in this file imports back
//! from a sibling.

use snafu::{Snafu, ensure};

/// Error produced when parsing any of the identifier newtypes.
///
/// Each variant maps to one of the normative identifier rules
/// (`ID-001`, `ID-002`, `ID-003`).
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum NameError {
    /// Violates `ID-003`: the input is empty.
    #[snafu(display("name is empty"))]
    Empty,

    /// Violates `ID-003`: the input exceeds the 64-byte maximum.
    #[snafu(display("name is {length} bytes long; maximum is 64"))]
    TooLong {
        /// Byte length of the rejected input.
        length: usize,
    },

    /// Violates `ID-002`: the leading character is not in
    /// `[A-Za-z0-9]`.
    #[snafu(display(
        "name starts with invalid character {c:?}; \
         must start with an ASCII alphanumeric character"
    ))]
    InvalidLeadingChar {
        /// The offending leading character.
        c: char,
    },

    /// Violates `ID-001`: a non-leading character is not in the
    /// alphabet `[A-Za-z0-9_-]`.
    #[snafu(display(
        "name contains invalid character {c:?}; \
         allowed: ASCII alphanumeric, '_', '-'"
    ))]
    InvalidChar {
        /// The offending non-leading character.
        c: char,
    },
}

/// Validate `s` against the identifier rules `ID-001`..`ID-003`.
///
/// Returns `Ok(())` on success; the caller (typically a nutype-
/// generated constructor) wraps the validated string in the
/// appropriate newtype.
pub(crate) fn validate(s: &str) -> Result<(), NameError> {
    validate_with_max(s, 64)
}

/// Validate `s` as a short ASCII name (alphabet and leading-char
/// rules per `ID-001`/`ID-002`, length capped at 32 bytes).
///
/// Used by [`crate::action::NonEmptyAsciiName`] for shell-type
/// names.
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::*;

    // ID-001: alphabet.

    #[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() {
        // U+00E9 (LATIN SMALL LETTER E WITH ACUTE) is not 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: '[' })
        ));
    }

    // ID-002: leading character.

    #[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: '_' })
        ));
    }

    // ID-003: length.

    #[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 })
        ));
    }

    // Property: any string drawn from the valid identifier
    // strategy passes validate().
    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());
        }
    }
}