haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! [`CanonicalPath`]: the resolved form of a [`HazPath`].
//!
//! Construction is via [`HazPath::resolve`], which binds the path
//! to a project root: a workspace-absolute path resolves to itself,
//! a project-relative path is appended to the supplied project
//! root. The bootstrap case (turning a workspace-absolute path
//! into a [`CanonicalPath`] without a parent root) is handled by
//! [`CanonicalPath::from_absolute`].
//!
//! Equality on [`CanonicalPath`] is the load-bearing invariant for
//! producer/input matching: two `CanonicalPath`s compare equal iff
//! they address the same workspace location.

use std::fmt;

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

use crate::path::{HazPath, PathError, PathSegment};

/// Failure mode of [`CanonicalPath::parse_workspace_absolute`].
///
/// Distinguishes a parser-level failure (the input is not a valid
/// [`HazPath`] at all) from a shape mismatch (the input parsed
/// cleanly but is project-relative rather than workspace-absolute).
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum ParseAbsoluteError {
    /// The input failed [`HazPath::parse`] (an empty input, an
    /// empty segment, a trailing `/`, or a segment that violates
    /// `PATH-002`).
    #[snafu(display("invalid workspace-absolute path: {source}"))]
    Invalid {
        /// The originating parse error.
        source: PathError,
    },
    /// The input parsed but is project-relative; a workspace-
    /// absolute path is required (must start with `/`).
    #[snafu(display(
        "path is project-relative; expected workspace-absolute (must start with `/`)"
    ))]
    NotWorkspaceAbsolute,
}

/// A workspace-rooted, fully-resolved sequence of [`PathSegment`]s.
///
/// Two `CanonicalPath` values compare equal iff they address the
/// same workspace location.
///
/// # Examples
///
/// ```
/// use haz_domain::path::{CanonicalPath, HazPath};
///
/// // A project root is itself a workspace-absolute HazPath, lifted
/// // to a CanonicalPath via `from_absolute`.
/// let project_root = CanonicalPath::from_absolute(
///     &HazPath::parse("/lib_core").unwrap()
/// ).unwrap();
///
/// // A project-relative path resolves against the project root.
/// let resolved = HazPath::parse("src/main.rs")
///     .unwrap()
///     .resolve(&project_root);
/// assert_eq!(resolved.to_string(), "/lib_core/src/main.rs");
///
/// // A workspace-absolute path ignores the project root.
/// let absolute = HazPath::parse("/other/file.txt")
///     .unwrap()
///     .resolve(&project_root);
/// assert_eq!(absolute.to_string(), "/other/file.txt");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CanonicalPath(NonEmpty<PathSegment>);

impl CanonicalPath {
    /// Lift a workspace-absolute [`HazPath`] into a
    /// [`CanonicalPath`] without consulting a project root.
    ///
    /// Returns `None` when the input is project-relative.
    #[must_use]
    pub fn from_absolute(haz_path: &HazPath) -> Option<Self> {
        match haz_path {
            HazPath::WorkspaceAbsolute(segments) => Some(Self(segments.clone())),
            HazPath::ProjectRelative(_) => None,
        }
    }

    /// Parse a workspace-absolute path string into a
    /// [`CanonicalPath`] in one step.
    ///
    /// Convenience for callers (the cache layer, executor, CLI)
    /// that hold an unvalidated string from a manifest, config
    /// file, or user input and want the fully-validated typed
    /// value with traversal and bidi-control rejections applied.
    /// Equivalent to [`HazPath::parse`] followed by
    /// [`CanonicalPath::from_absolute`].
    ///
    /// Pure: no filesystem, environment, or clock access.
    ///
    /// # Errors
    ///
    /// Returns [`ParseAbsoluteError::Invalid`] when `s` does not
    /// parse as a [`HazPath`] at all, and
    /// [`ParseAbsoluteError::NotWorkspaceAbsolute`] when `s`
    /// parses as a project-relative path (no leading `/`).
    pub fn parse_workspace_absolute(s: &str) -> Result<Self, ParseAbsoluteError> {
        let parsed = HazPath::parse(s).context(InvalidSnafu)?;
        Self::from_absolute(&parsed).ok_or(ParseAbsoluteError::NotWorkspaceAbsolute)
    }

    /// Returns the segments composing this canonical path.
    #[must_use]
    pub fn segments(&self) -> &NonEmpty<PathSegment> {
        &self.0
    }
}

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

impl HazPath {
    /// Resolve this path against the supplied project root,
    /// returning a [`CanonicalPath`].
    ///
    /// - A workspace-absolute path resolves to itself, regardless
    ///   of `project_root`.
    /// - A project-relative path is appended to `project_root`.
    ///
    /// # Panics
    ///
    /// Does not panic on any reachable input: an internal
    /// invariant assertion exists (combining the project root's
    /// non-empty segments with the relative path's non-empty
    /// segments yields a non-empty sequence) but is structurally
    /// unreachable.
    ///
    /// # Examples
    ///
    /// ```
    /// use haz_domain::path::{CanonicalPath, HazPath};
    ///
    /// let project_root = CanonicalPath::from_absolute(
    ///     &HazPath::parse("/lib_core").unwrap()
    /// ).unwrap();
    ///
    /// let p = HazPath::parse("src/main.rs").unwrap();
    /// assert_eq!(
    ///     p.resolve(&project_root).to_string(),
    ///     "/lib_core/src/main.rs",
    /// );
    /// ```
    #[must_use]
    pub fn resolve(&self, project_root: &CanonicalPath) -> CanonicalPath {
        match self {
            HazPath::WorkspaceAbsolute(segments) => CanonicalPath(segments.clone()),
            HazPath::ProjectRelative(segments) => {
                let mut combined: Vec<PathSegment> = project_root.0.iter().cloned().collect();
                combined.extend(segments.iter().cloned());
                let nonempty =
                    NonEmpty::from_vec(combined).expect("project_root segments are non-empty");
                CanonicalPath(nonempty)
            }
        }
    }
}

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

    fn project_root(absolute: &str) -> CanonicalPath {
        let parsed = HazPath::parse(absolute).unwrap();
        CanonicalPath::from_absolute(&parsed)
            .expect("test helper expects a workspace-absolute path")
    }

    #[test]
    fn from_absolute_accepts_workspace_absolute() {
        let p = HazPath::parse("/lib_core").unwrap();
        assert!(CanonicalPath::from_absolute(&p).is_some());
    }

    #[test]
    fn from_absolute_rejects_project_relative() {
        let p = HazPath::parse("src/lib.rs").unwrap();
        assert!(CanonicalPath::from_absolute(&p).is_none());
    }

    #[test]
    fn resolve_workspace_absolute_ignores_project_root() {
        let root = project_root("/lib_core");
        let p = HazPath::parse("/other/file.txt").unwrap();
        assert_eq!(p.resolve(&root).to_string(), "/other/file.txt");
    }

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

    #[test]
    fn resolve_project_relative_deep() {
        let root = project_root("/web/frontend");
        let p = HazPath::parse("dist/index.html").unwrap();
        assert_eq!(
            p.resolve(&root).to_string(),
            "/web/frontend/dist/index.html"
        );
    }

    #[test]
    fn canonical_eq_for_same_logical_path() {
        let root = project_root("/lib_core");
        let rel = HazPath::parse("src/main.rs").unwrap().resolve(&root);
        let abs = HazPath::parse("/lib_core/src/main.rs")
            .unwrap()
            .resolve(&root);
        assert_eq!(rel, abs);
    }

    #[test]
    fn canonical_ne_for_different_paths() {
        let root = project_root("/lib_core");
        let a = HazPath::parse("src/main.rs").unwrap().resolve(&root);
        let b = HazPath::parse("src/lib.rs").unwrap().resolve(&root);
        assert_ne!(a, b);
    }

    #[test]
    fn canonical_path_display_round_trip() {
        let root = project_root("/web/frontend");
        let resolved = HazPath::parse("dist/index.html").unwrap().resolve(&root);
        assert_eq!(resolved.to_string(), "/web/frontend/dist/index.html");
    }

    // ---- parse_workspace_absolute ----

    use crate::path::{ParseAbsoluteError, PathError, SegmentError};

    #[test]
    fn parse_workspace_absolute_accepts_valid_path() {
        let p = CanonicalPath::parse_workspace_absolute("/lib_core/src/main.rs").unwrap();
        assert_eq!(p.to_string(), "/lib_core/src/main.rs");
    }

    #[test]
    fn parse_workspace_absolute_rejects_project_relative() {
        let err = CanonicalPath::parse_workspace_absolute("src/main.rs").unwrap_err();
        assert!(matches!(err, ParseAbsoluteError::NotWorkspaceAbsolute));
    }

    #[test]
    fn parse_workspace_absolute_rejects_traversal() {
        let err = CanonicalPath::parse_workspace_absolute("/foo/../bar").unwrap_err();
        assert!(matches!(
            err,
            ParseAbsoluteError::Invalid {
                source: PathError::InvalidSegment {
                    source: SegmentError::DotDot,
                    ..
                }
            }
        ));
    }

    #[test]
    fn parse_workspace_absolute_rejects_dot_segment() {
        let err = CanonicalPath::parse_workspace_absolute("/foo/./bar").unwrap_err();
        assert!(matches!(
            err,
            ParseAbsoluteError::Invalid {
                source: PathError::InvalidSegment {
                    source: SegmentError::Dot,
                    ..
                }
            }
        ));
    }

    #[test]
    fn parse_workspace_absolute_rejects_bare_root() {
        let err = CanonicalPath::parse_workspace_absolute("/").unwrap_err();
        assert!(matches!(
            err,
            ParseAbsoluteError::Invalid {
                source: PathError::OnlySlash
            }
        ));
    }

    #[test]
    fn parse_workspace_absolute_rejects_empty() {
        let err = CanonicalPath::parse_workspace_absolute("").unwrap_err();
        assert!(matches!(
            err,
            ParseAbsoluteError::Invalid {
                source: PathError::Empty
            }
        ));
    }

    #[test]
    fn parse_workspace_absolute_rejects_double_slash() {
        let err = CanonicalPath::parse_workspace_absolute("/foo//bar").unwrap_err();
        assert!(matches!(
            err,
            ParseAbsoluteError::Invalid {
                source: PathError::EmptySegment
            }
        ));
    }

    #[test]
    fn parse_workspace_absolute_rejects_trailing_slash() {
        let err = CanonicalPath::parse_workspace_absolute("/foo/bar/").unwrap_err();
        assert!(matches!(
            err,
            ParseAbsoluteError::Invalid {
                source: PathError::TrailingSlash
            }
        ));
    }
}