haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! [`WorkspaceRootPath`]: a host-filesystem path identifying a
//! workspace root.
//!
//! `WorkspaceRootPath` is the host operating-system absolute path of
//! a workspace, validated to be lexically clean (no `.` or `..`,
//! no empty components, no trailing separator). It is the partner
//! concept to [`crate::path::CanonicalPath`]: the latter names
//! locations *inside* a workspace (rendered as
//! `/lib_core/src/main.rs`); `WorkspaceRootPath` names the workspace
//! itself on the host filesystem.
//!
//! # Validator
//!
//! [`WorkspaceRootPath::try_new`] enforces, in order:
//!
//! 1. The path is absolute (per [`std::path::Path::is_absolute`]).
//! 2. The path contains no [`std::path::Component::ParentDir`]
//!    component (no explicit `..`).
//! 3. The path is *lexically clean*: walking
//!    [`std::path::Path::components`] and rebuilding via
//!    [`std::path::PathBuf::push`] yields a byte-identical
//!    [`std::ffi::OsStr`].
//!
//! Check (3) catches the cases [`std::path::Path::components`]
//! silently normalises:
//!
//! - empty components (`/srv//work`),
//! - mid-path `.` (`/srv/./work`; `Component::CurDir` is *not*
//!   yielded for these by `Path::components`),
//! - trailing separators (`/srv/work/`).
//!
//! Together these three checks guarantee that two
//! `WorkspaceRootPath` values comparing byte-equal also identify the
//! same workspace location on the host filesystem.

use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};

use nutype::nutype;
use snafu::Snafu;

/// Validation failure for [`WorkspaceRootPath::try_new`].
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum WorkspaceRootPathError {
    /// The supplied path was not absolute.
    #[snafu(display("workspace root path is not absolute"))]
    NotAbsolute,
    /// The supplied path contained a `..` component.
    #[snafu(display("workspace root path contains a `..` component"))]
    ContainsParentDir,
    /// The supplied path was not lexically clean (has `//`,
    /// mid-path `.`, or a trailing separator).
    #[snafu(display("workspace root path is not lexically clean"))]
    NotLexicallyClean,
}

// `nutype` invokes the validator with a borrow of the inner type
// (`&PathBuf`); rebinding to `&Path` would not compile.
#[allow(clippy::ptr_arg)]
fn validate_workspace_root_path(p: &PathBuf) -> Result<(), WorkspaceRootPathError> {
    if !p.is_absolute() {
        return Err(WorkspaceRootPathError::NotAbsolute);
    }
    for c in p.components() {
        if matches!(c, Component::ParentDir) {
            return Err(WorkspaceRootPathError::ContainsParentDir);
        }
    }
    if !is_lexically_clean(p) {
        return Err(WorkspaceRootPathError::NotLexicallyClean);
    }
    Ok(())
}

fn is_lexically_clean(p: &Path) -> bool {
    let mut rebuilt = PathBuf::new();
    for c in p.components() {
        rebuilt.push(c.as_os_str());
    }
    let lhs: &OsStr = p.as_os_str();
    let rhs: &OsStr = rebuilt.as_os_str();
    lhs == rhs
}

/// A host-filesystem absolute, lexically clean path identifying a
/// workspace root.
///
/// See the module-level documentation for the precise invariant.
///
/// # Examples
///
/// ```
/// use std::path::PathBuf;
/// use haz_domain::path::WorkspaceRootPath;
///
/// // Absolute, clean: accepted.
/// let root = WorkspaceRootPath::try_new(PathBuf::from("/srv/work")).unwrap();
/// assert_eq!(root.as_path(), std::path::Path::new("/srv/work"));
///
/// // Relative: rejected.
/// assert!(WorkspaceRootPath::try_new(PathBuf::from("relative/path")).is_err());
///
/// // Contains `..`: rejected.
/// assert!(WorkspaceRootPath::try_new(PathBuf::from("/srv/../work")).is_err());
///
/// // Mid-path `.`: rejected.
/// assert!(WorkspaceRootPath::try_new(PathBuf::from("/srv/./work")).is_err());
///
/// // Empty separator: rejected.
/// assert!(WorkspaceRootPath::try_new(PathBuf::from("/srv//work")).is_err());
/// ```
#[nutype(
    validate(with = validate_workspace_root_path, error = WorkspaceRootPathError),
    derive(Debug, Clone, PartialEq, Eq, Hash, AsRef),
)]
pub struct WorkspaceRootPath(PathBuf);

impl WorkspaceRootPath {
    /// Borrow the wrapped path as a [`Path`].
    #[must_use]
    pub fn as_path(&self) -> &Path {
        self.as_ref().as_path()
    }
}

impl std::fmt::Display for WorkspaceRootPath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        std::fmt::Display::fmt(&self.as_path().display(), f)
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::{WorkspaceRootPath, WorkspaceRootPathError};

    #[cfg(unix)]
    #[test]
    fn accepts_absolute_clean_path() {
        let r = WorkspaceRootPath::try_new(PathBuf::from("/srv/work")).unwrap();
        assert_eq!(r.as_path(), std::path::Path::new("/srv/work"));
    }

    #[cfg(unix)]
    #[test]
    fn accepts_filesystem_root() {
        let r = WorkspaceRootPath::try_new(PathBuf::from("/")).unwrap();
        assert_eq!(r.as_path(), std::path::Path::new("/"));
    }

    #[test]
    fn rejects_relative_path() {
        let err = WorkspaceRootPath::try_new(PathBuf::from("relative/path")).unwrap_err();
        assert!(matches!(err, WorkspaceRootPathError::NotAbsolute));
    }

    #[test]
    fn rejects_empty_path() {
        let err = WorkspaceRootPath::try_new(PathBuf::from("")).unwrap_err();
        assert!(matches!(err, WorkspaceRootPathError::NotAbsolute));
    }

    #[cfg(unix)]
    #[test]
    fn rejects_path_with_parent_dir_component() {
        let err = WorkspaceRootPath::try_new(PathBuf::from("/srv/../work")).unwrap_err();
        assert!(matches!(err, WorkspaceRootPathError::ContainsParentDir));
    }

    #[cfg(unix)]
    #[test]
    fn rejects_mid_path_current_dir() {
        let err = WorkspaceRootPath::try_new(PathBuf::from("/srv/./work")).unwrap_err();
        assert!(matches!(err, WorkspaceRootPathError::NotLexicallyClean));
    }

    #[cfg(unix)]
    #[test]
    fn rejects_double_separator() {
        let err = WorkspaceRootPath::try_new(PathBuf::from("/srv//work")).unwrap_err();
        assert!(matches!(err, WorkspaceRootPathError::NotLexicallyClean));
    }

    #[cfg(unix)]
    #[test]
    fn rejects_trailing_separator() {
        let err = WorkspaceRootPath::try_new(PathBuf::from("/srv/work/")).unwrap_err();
        assert!(matches!(err, WorkspaceRootPathError::NotLexicallyClean));
    }

    #[cfg(unix)]
    #[test]
    fn rejects_trailing_dot() {
        let err = WorkspaceRootPath::try_new(PathBuf::from("/srv/work/.")).unwrap_err();
        assert!(matches!(err, WorkspaceRootPathError::NotLexicallyClean));
    }

    #[cfg(unix)]
    #[test]
    fn display_renders_as_path_text() {
        let r = WorkspaceRootPath::try_new(PathBuf::from("/srv/work")).unwrap();
        assert_eq!(r.to_string(), "/srv/work");
    }

    #[cfg(unix)]
    #[test]
    fn equality_on_inner_path_bytes() {
        let a = WorkspaceRootPath::try_new(PathBuf::from("/srv/work")).unwrap();
        let b = WorkspaceRootPath::try_new(PathBuf::from("/srv/work")).unwrap();
        let c = WorkspaceRootPath::try_new(PathBuf::from("/srv/other")).unwrap();
        assert_eq!(a, b);
        assert_ne!(a, c);
    }
}