haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! Path types: validated [`HazPath`] (workspace-absolute or
//! project-relative) and [`PathSegment`].
//!
//! Canonicalisation lives in [`canonical`]: a [`HazPath`] resolves
//! to a [`canonical::CanonicalPath`] given the project root that
//! owns the bearing field.

pub mod canonical;
pub mod input_spec;
pub mod output_spec;
pub mod pattern;
pub mod project_root;
pub mod segment;
pub mod workspace_root;

use std::fmt;

use nonempty::NonEmpty;
use snafu::{ResultExt, Snafu, ensure};

pub use canonical::{CanonicalPath, ParseAbsoluteError};
pub use input_spec::InputSpec;
pub use output_spec::OutputSpec;
pub use pattern::{GlobPattern, GlobSegmentError, PathAnchor, PathPattern, PathPatternError};
pub use project_root::ProjectRoot;
pub use segment::{ForbiddenCategory, PathSegment, SegmentError};
pub use workspace_root::{WorkspaceRootPath, WorkspaceRootPathError};

/// Error produced when parsing a [`HazPath`].
///
/// Each variant maps to one of the normative path rules
/// (`PATH-002`, `PATH-003`).
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum PathError {
    /// Violates `PATH-002`: the input is empty.
    #[snafu(display("path is empty"))]
    Empty,

    /// Violates `PATH-003`: the input contains the empty
    /// separator `//`.
    #[snafu(display("path contains the empty segment `//`"))]
    EmptySegment,

    /// Violates `PATH-003`: the workspace-absolute path is the
    /// single character `/` and has no segments.
    #[snafu(display("workspace-absolute path has no segments"))]
    OnlySlash,

    /// Violates `PATH-003`: the input ends with `/`.
    #[snafu(display("path ends with trailing `/`"))]
    TrailingSlash,

    /// Violates `PATH-002`: a segment failed validation.
    #[snafu(display("invalid segment {segment:?} at position {position}: {source}"))]
    InvalidSegment {
        /// The offending segment text.
        segment: String,
        /// Zero-based index of the segment in the path body.
        position: usize,
        /// Underlying segment-level error.
        source: SegmentError,
    },
}

/// A validated path. Either a [workspace-absolute] form (anchored
/// at the workspace root) or a [project-relative] form (anchored
/// at the bearing field's project root).
///
/// [workspace-absolute]: HazPath::WorkspaceAbsolute
/// [project-relative]: HazPath::ProjectRelative
///
/// Construction is via [`HazPath::parse`]. The parsed value carries
/// at least one [`PathSegment`]; `.` and `..` are not valid
/// segments, so a project-relative path cannot escape its owning
/// project (see `PATH-005` and `PATH-006`).
///
/// # Examples
///
/// ```
/// use haz_domain::path::HazPath;
///
/// let p = HazPath::parse("src/lib.rs").unwrap();
/// assert!(matches!(p, HazPath::ProjectRelative(_)));
///
/// let q = HazPath::parse("/lib_core/src/main.rs").unwrap();
/// assert!(matches!(q, HazPath::WorkspaceAbsolute(_)));
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum HazPath {
    /// A path beginning with `/`, resolved against the workspace
    /// root (`PATH-004`).
    WorkspaceAbsolute(NonEmpty<PathSegment>),
    /// A path that does not begin with `/`, resolved against the
    /// owning project's root (`PATH-005`).
    ProjectRelative(NonEmpty<PathSegment>),
}

impl HazPath {
    /// Parse `s` as a [`HazPath`].
    ///
    /// # Errors
    ///
    /// Returns a [`PathError`] when `s` violates a path rule:
    ///
    /// - [`PathError::Empty`] if `s` is the empty string.
    /// - [`PathError::OnlySlash`] if `s` is `"/"` (workspace-
    ///   absolute with no segments).
    /// - [`PathError::EmptySegment`] if `s` contains `//`.
    /// - [`PathError::TrailingSlash`] if `s` ends with `/`.
    /// - [`PathError::InvalidSegment`] if any segment violates
    ///   [`PATH-002`](crate::path::SegmentError).
    ///
    /// # Panics
    ///
    /// Does not panic on any path of execution reachable from valid
    /// or invalid input: an internal invariant assertion exists
    /// (the non-emptiness of `segments` after the leading-`/`,
    /// `//`, and trailing-`/` checks have rejected the degenerate
    /// cases) but is structurally unreachable.
    ///
    /// # Examples
    ///
    /// ```
    /// use haz_domain::path::HazPath;
    ///
    /// assert!(HazPath::parse("src/main.rs").is_ok());
    /// assert!(HazPath::parse("/lib_core/src/main.rs").is_ok());
    /// assert!(HazPath::parse("./foo").is_err());
    /// assert!(HazPath::parse("a//b").is_err());
    /// assert!(HazPath::parse("foo/").is_err());
    /// ```
    pub fn parse(s: &str) -> Result<Self, PathError> {
        ensure!(!s.is_empty(), EmptySnafu);

        let (is_absolute, body) = if let Some(rest) = s.strip_prefix('/') {
            (true, rest)
        } else {
            (false, s)
        };

        ensure!(!body.is_empty(), OnlySlashSnafu);
        ensure!(!body.contains("//"), EmptySegmentSnafu);
        ensure!(!body.ends_with('/'), TrailingSlashSnafu);

        let segments: Vec<PathSegment> = body
            .split('/')
            .enumerate()
            .map(|(position, part)| {
                PathSegment::try_new(part).context(InvalidSegmentSnafu {
                    segment: part.to_owned(),
                    position,
                })
            })
            .collect::<Result<_, _>>()?;

        let nonempty = NonEmpty::from_vec(segments)
            .expect("body is non-empty and has no `//`, so segments is non-empty");

        Ok(if is_absolute {
            HazPath::WorkspaceAbsolute(nonempty)
        } else {
            HazPath::ProjectRelative(nonempty)
        })
    }

    /// Returns the segments of this path (always at least one).
    #[must_use]
    pub fn segments(&self) -> &NonEmpty<PathSegment> {
        match self {
            HazPath::WorkspaceAbsolute(s) | HazPath::ProjectRelative(s) => s,
        }
    }

    /// `true` if this is a workspace-absolute path.
    #[must_use]
    pub fn is_workspace_absolute(&self) -> bool {
        matches!(self, HazPath::WorkspaceAbsolute(_))
    }

    /// `true` if this is a project-relative path.
    #[must_use]
    pub fn is_project_relative(&self) -> bool {
        matches!(self, HazPath::ProjectRelative(_))
    }
}

impl fmt::Display for HazPath {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.is_workspace_absolute() {
            f.write_str("/")?;
        }
        let mut first = true;
        for segment in self.segments().iter() {
            if !first {
                f.write_str("/")?;
            }
            f.write_str(segment.as_str())?;
            first = false;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use crate::path::{HazPath, PathError, SegmentError};

    // PATH-001: two forms.

    #[test]
    fn path_001_recognises_project_relative() {
        let p = HazPath::parse("src/lib.rs").unwrap();
        assert!(matches!(p, HazPath::ProjectRelative(_)));
    }

    #[test]
    fn path_001_recognises_workspace_absolute() {
        let p = HazPath::parse("/lib_core/src/main.rs").unwrap();
        assert!(matches!(p, HazPath::WorkspaceAbsolute(_)));
    }

    // PATH-002: segment alphabet inherited from PathSegment.

    #[test]
    fn path_002_rejects_dot_segment() {
        assert!(matches!(
            HazPath::parse("./foo"),
            Err(PathError::InvalidSegment {
                source: SegmentError::Dot,
                ..
            })
        ));
    }

    #[test]
    fn path_002_rejects_dotdot_segment() {
        assert!(matches!(
            HazPath::parse("../shared/lib.rs"),
            Err(PathError::InvalidSegment {
                source: SegmentError::DotDot,
                ..
            })
        ));
    }

    #[test]
    fn path_002_rejects_internal_dotdot() {
        assert!(matches!(
            HazPath::parse("a/../b"),
            Err(PathError::InvalidSegment {
                source: SegmentError::DotDot,
                ..
            })
        ));
    }

    #[test]
    fn path_002_rejects_control_char_inside_segment() {
        // `\t` denotes U+0009 (TAB), Cc.
        assert!(matches!(
            HazPath::parse("src/foo\tbar.rs"),
            Err(PathError::InvalidSegment {
                source: SegmentError::ContainsForbidden {
                    c: '\t',
                    category: crate::path::ForbiddenCategory::Control,
                },
                ..
            })
        ));
    }

    #[test]
    fn path_002_rejects_zero_width_space_inside_segment() {
        // U+200B (ZERO WIDTH SPACE), Cf.
        assert!(matches!(
            HazPath::parse("src/foo\u{200B}bar.rs"),
            Err(PathError::InvalidSegment {
                source: SegmentError::ContainsForbidden {
                    category: crate::path::ForbiddenCategory::Format,
                    ..
                },
                ..
            })
        ));
    }

    // PATH-003: empty segments, trailing /, only-slash.

    #[test]
    fn path_003_rejects_double_slash() {
        assert!(matches!(
            HazPath::parse("src//lib.rs"),
            Err(PathError::EmptySegment)
        ));
    }

    #[test]
    fn path_003_rejects_trailing_slash_relative() {
        assert!(matches!(
            HazPath::parse("src/"),
            Err(PathError::TrailingSlash)
        ));
    }

    #[test]
    fn path_003_rejects_trailing_slash_absolute() {
        assert!(matches!(
            HazPath::parse("/src/"),
            Err(PathError::TrailingSlash)
        ));
    }

    #[test]
    fn path_003_rejects_only_slash() {
        assert!(matches!(HazPath::parse("/"), Err(PathError::OnlySlash)));
    }

    #[test]
    fn path_003_rejects_empty_string() {
        assert!(matches!(HazPath::parse(""), Err(PathError::Empty)));
    }

    // PATH-004 / PATH-005: variant invariants verified by parse output.

    #[test]
    fn path_004_workspace_absolute_segments_count() {
        let p = HazPath::parse("/a/b/c").unwrap();
        let segs = p.segments();
        assert_eq!(segs.len(), 3);
        assert_eq!(segs.first().as_str(), "a");
    }

    #[test]
    fn path_005_project_relative_segments_count() {
        let p = HazPath::parse("a/b/c").unwrap();
        let segs = p.segments();
        assert_eq!(segs.len(), 3);
    }

    // PATH-007: case sensitivity.

    #[test]
    fn path_007_case_sensitive_equality() {
        let upper = HazPath::parse("Src/Lib.rs").unwrap();
        let lower = HazPath::parse("src/lib.rs").unwrap();
        assert_ne!(upper, lower);
    }

    // Round-trip: parse -> Display -> parse.

    #[test]
    fn round_trip_project_relative() {
        let original = "src/lib.rs";
        let p = HazPath::parse(original).unwrap();
        assert_eq!(p.to_string(), original);
        let reparsed = HazPath::parse(&p.to_string()).unwrap();
        assert_eq!(p, reparsed);
    }

    #[test]
    fn round_trip_workspace_absolute() {
        let original = "/lib_core/src/main.rs";
        let p = HazPath::parse(original).unwrap();
        assert_eq!(p.to_string(), original);
        let reparsed = HazPath::parse(&p.to_string()).unwrap();
        assert_eq!(p, reparsed);
    }

    // Properties.

    use proptest::prelude::*;

    fn segment_strategy() -> impl Strategy<Value = String> {
        // Conservative alphabet for proptest: avoids reserved
        // segments like "." / ".." and any control characters.
        "[a-zA-Z0-9][a-zA-Z0-9._-]{0,15}"
    }

    proptest! {
        #[test]
        fn prop_valid_relative_path_round_trips(
            segs in proptest::collection::vec(segment_strategy(), 1..=5)
        ) {
            let s = segs.join("/");
            let parsed = HazPath::parse(&s).unwrap();
            prop_assert_eq!(parsed.to_string(), s);
        }

        #[test]
        fn prop_valid_absolute_path_round_trips(
            segs in proptest::collection::vec(segment_strategy(), 1..=5)
        ) {
            let s = format!("/{}", segs.join("/"));
            let parsed = HazPath::parse(&s).unwrap();
            prop_assert_eq!(parsed.to_string(), s);
        }
    }
}