haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! `PathSegment`: a single validated component of a path.
//!
//! A path segment is the smallest unit between `/` separators in a
//! [`crate::path::HazPath`]. Per `PATH-002`, segments cannot be
//! empty, cannot equal `.` or `..`, cannot contain `/`, and cannot
//! contain a codepoint whose Unicode General Category is one of
//! `Cc`, `Cf`, `Cn`, `Co`, `Zl`, or `Zp`. The forbidden categories
//! cover all control characters (ASCII and C1), all format
//! characters (zero-widths, BOM, bidirectional overrides, soft
//! hyphen), line / paragraph separators, unassigned codepoints,
//! and private-use codepoints.

use std::fmt;

use nutype::nutype;
use snafu::{Snafu, ensure};
use unicode_general_category::{GeneralCategory, get_general_category};

/// Categories of Unicode codepoints forbidden inside a path segment.
///
/// Each variant corresponds to a Unicode General Category (or a
/// pair of related categories) that `PATH-002` rejects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ForbiddenCategory {
    /// `Cc` (Other, Control). ASCII controls (`U+0000`..`U+001F`,
    /// `U+007F`) and C1 controls (`U+0080`..`U+009F`).
    Control,
    /// `Cf` (Other, Format). Zero-width characters
    /// (`U+200B`..`U+200D`, `U+2060`, `U+FEFF`), bidirectional
    /// overrides (`U+202A`..`U+202E`, `U+2066`..`U+2069`), the
    /// soft hyphen (`U+00AD`), and other format codepoints.
    Format,
    /// `Zl` (Separator, Line) `U+2028` or `Zp` (Separator,
    /// Paragraph) `U+2029`.
    LineSeparator,
    /// `Co` (Other, Private-Use).
    PrivateUse,
    /// `Cn` (Other, Unassigned).
    Unassigned,
}

impl fmt::Display for ForbiddenCategory {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            ForbiddenCategory::Control => "control",
            ForbiddenCategory::Format => "format",
            ForbiddenCategory::LineSeparator => "line/paragraph separator",
            ForbiddenCategory::PrivateUse => "private-use",
            ForbiddenCategory::Unassigned => "unassigned",
        };
        f.write_str(s)
    }
}

/// Error produced when constructing a [`PathSegment`].
///
/// Each variant maps to a sub-clause of `PATH-002`.
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum SegmentError {
    /// Violates `PATH-002`: the input is empty.
    #[snafu(display("path segment is empty"))]
    Empty,

    /// Violates `PATH-002`: the input is the literal `.`.
    #[snafu(display("path segment is the literal `.`"))]
    Dot,

    /// Violates `PATH-002`: the input is the literal `..`.
    #[snafu(display("path segment is the literal `..`"))]
    DotDot,

    /// Violates `PATH-002`: the input contains the path separator `/`.
    #[snafu(display("path segment contains separator '/'"))]
    ContainsSeparator,

    /// Violates `PATH-002`: the input contains a codepoint in a
    /// forbidden Unicode General Category (see
    /// [`ForbiddenCategory`]).
    #[snafu(display(
        "path segment contains forbidden {category} codepoint U+{:04X}",
        u32::from(*c)
    ))]
    ContainsForbidden {
        /// The offending codepoint.
        c: char,
        /// The Unicode General Category that disqualifies it.
        category: ForbiddenCategory,
    },
}

/// Classify `c` against the forbidden-category set of `PATH-002`.
///
/// Returns `Some` if `c` belongs to one of the forbidden Unicode
/// General Categories; returns `None` otherwise.
///
/// Re-exported at crate scope so glob-segment validation in
/// [`crate::path::pattern`] can apply the same character rule.
pub(crate) fn forbidden_category(c: char) -> Option<ForbiddenCategory> {
    match get_general_category(c) {
        GeneralCategory::Control => Some(ForbiddenCategory::Control),
        GeneralCategory::Format => Some(ForbiddenCategory::Format),
        GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator => {
            Some(ForbiddenCategory::LineSeparator)
        }
        GeneralCategory::PrivateUse => Some(ForbiddenCategory::PrivateUse),
        GeneralCategory::Unassigned => Some(ForbiddenCategory::Unassigned),
        _ => None,
    }
}

pub(crate) fn validate(s: &str) -> Result<(), SegmentError> {
    ensure!(!s.is_empty(), EmptySnafu);
    ensure!(s != ".", DotSnafu);
    ensure!(s != "..", DotDotSnafu);

    for c in s.chars() {
        ensure!(c != '/', ContainsSeparatorSnafu);
        if let Some(category) = forbidden_category(c) {
            return ContainsForbiddenSnafu { c, category }.fail();
        }
    }
    Ok(())
}

/// A validated path segment.
///
/// Constructed via [`PathSegment::try_new`] (or
/// [`std::str::FromStr`]). The full set of rejection cases is
/// captured in [`SegmentError`].
///
/// # Examples
///
/// ```
/// use haz_domain::path::PathSegment;
///
/// let s = PathSegment::try_new("lib.rs").unwrap();
/// assert_eq!(s.to_string(), "lib.rs");
/// ```
#[nutype(
    validate(with = validate, error = SegmentError),
    derive(
        Debug,
        Clone,
        PartialEq,
        Eq,
        PartialOrd,
        Ord,
        Hash,
        Display,
        FromStr,
        AsRef,
    ),
)]
pub struct PathSegment(String);

impl PathSegment {
    /// Borrow the segment as a `&str`.
    #[must_use]
    pub fn as_str(&self) -> &str {
        self.as_ref()
    }
}

#[cfg(test)]
mod tests {
    use crate::path::segment::{ForbiddenCategory, PathSegment, SegmentError, validate};

    // PATH-002: structural rejections.

    #[test]
    fn path_002_rejects_empty() {
        assert!(matches!(validate(""), Err(SegmentError::Empty)));
    }

    #[test]
    fn path_002_rejects_dot() {
        assert!(matches!(validate("."), Err(SegmentError::Dot)));
    }

    #[test]
    fn path_002_rejects_dotdot() {
        assert!(matches!(validate(".."), Err(SegmentError::DotDot)));
    }

    #[test]
    fn path_002_rejects_separator() {
        assert!(matches!(
            validate("a/b"),
            Err(SegmentError::ContainsSeparator)
        ));
    }

    // PATH-002: Cc category (control characters, ASCII + C1).

    #[test]
    fn path_002_rejects_nul() {
        // `\0` denotes U+0000 (NUL).
        assert!(matches!(
            validate("foo\u{0000}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{0000}',
                category: ForbiddenCategory::Control,
            })
        ));
    }

    #[test]
    fn path_002_rejects_tab() {
        // `\t` denotes U+0009 (TAB).
        assert!(matches!(
            validate("foo\tbar"),
            Err(SegmentError::ContainsForbidden {
                c: '\t',
                category: ForbiddenCategory::Control,
            })
        ));
    }

    #[test]
    fn path_002_rejects_del() {
        // U+007F (DEL) is ASCII control.
        assert!(matches!(
            validate("foo\u{007F}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{007F}',
                category: ForbiddenCategory::Control,
            })
        ));
    }

    #[test]
    fn path_002_rejects_c1_control() {
        // U+0085 (NEXT LINE, NEL) is a C1 control codepoint.
        assert!(matches!(
            validate("foo\u{0085}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{0085}',
                category: ForbiddenCategory::Control,
            })
        ));
    }

    // PATH-002: Cf category (format / invisible characters).

    #[test]
    fn path_002_rejects_zero_width_space() {
        // U+200B (ZERO WIDTH SPACE).
        assert!(matches!(
            validate("foo\u{200B}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{200B}',
                category: ForbiddenCategory::Format,
            })
        ));
    }

    #[test]
    fn path_002_rejects_zero_width_joiner() {
        // U+200D (ZERO WIDTH JOINER).
        assert!(matches!(
            validate("foo\u{200D}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{200D}',
                category: ForbiddenCategory::Format,
            })
        ));
    }

    #[test]
    fn path_002_rejects_byte_order_mark() {
        // U+FEFF (BOM / ZERO WIDTH NO-BREAK SPACE).
        assert!(matches!(
            validate("foo\u{FEFF}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{FEFF}',
                category: ForbiddenCategory::Format,
            })
        ));
    }

    #[test]
    fn path_002_rejects_bidirectional_override() {
        // U+202E (RIGHT-TO-LEFT OVERRIDE), Trojan Source family.
        assert!(matches!(
            validate("foo\u{202E}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{202E}',
                category: ForbiddenCategory::Format,
            })
        ));
    }

    #[test]
    fn path_002_rejects_soft_hyphen() {
        // U+00AD (SOFT HYPHEN), invisible by default.
        assert!(matches!(
            validate("foo\u{00AD}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{00AD}',
                category: ForbiddenCategory::Format,
            })
        ));
    }

    // PATH-002: Zl / Zp categories (line / paragraph separators).

    #[test]
    fn path_002_rejects_line_separator() {
        // U+2028 (LINE SEPARATOR), Zl.
        assert!(matches!(
            validate("foo\u{2028}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{2028}',
                category: ForbiddenCategory::LineSeparator,
            })
        ));
    }

    #[test]
    fn path_002_rejects_paragraph_separator() {
        // U+2029 (PARAGRAPH SEPARATOR), Zp.
        assert!(matches!(
            validate("foo\u{2029}bar"),
            Err(SegmentError::ContainsForbidden {
                c: '\u{2029}',
                category: ForbiddenCategory::LineSeparator,
            })
        ));
    }

    // PATH-002: acceptances.

    #[test]
    fn path_002_accepts_alnum_segment() {
        assert!(validate("lib").is_ok());
        assert!(validate("Makefile").is_ok());
    }

    #[test]
    fn path_002_accepts_segment_with_dot() {
        // A literal `.` is invalid; `.` inside a segment is fine.
        assert!(validate("lib.rs").is_ok());
        assert!(validate("logo.png").is_ok());
        assert!(validate(".dotfile").is_ok());
    }

    #[test]
    fn path_002_accepts_regular_space() {
        // U+0020 (SPACE), Zs, is allowed.
        assert!(validate("hello world.txt").is_ok());
    }

    #[test]
    fn path_002_accepts_nbsp() {
        // U+00A0 (NO-BREAK SPACE), Zs. Documenting an intentional
        // allow: NBSP is a confusable with regular space, but it
        // is not in the forbidden category list. Future SPEC
        // revisions may tighten this.
        assert!(validate("hello\u{00A0}world.txt").is_ok());
    }

    #[test]
    fn path_002_accepts_latin_extended() {
        // U+00E9 (LATIN SMALL LETTER E WITH ACUTE), Ll.
        assert!(validate("caf\u{00E9}.txt").is_ok());
    }

    #[test]
    fn path_002_accepts_cyrillic() {
        // U+0436 (CYRILLIC SMALL LETTER ZHE), Ll.
        assert!(validate("\u{0436}urnal.txt").is_ok());
    }

    #[test]
    fn path_002_accepts_cjk() {
        // U+65E5 (CJK UNIFIED IDEOGRAPH SUN), Lo.
        assert!(validate("\u{65E5}.txt").is_ok());
    }

    #[test]
    fn path_002_accepts_combining_mark() {
        // 'a' + U+0301 (COMBINING ACUTE ACCENT), Mn.
        assert!(validate("caf\u{0061}\u{0301}.txt").is_ok());
    }

    // Round-trip.

    #[test]
    fn round_trip_via_try_new_and_display() {
        let s = PathSegment::try_new("lib.rs").unwrap();
        assert_eq!(s.to_string(), "lib.rs");
        assert_eq!(s.as_str(), "lib.rs");
    }
}