haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! `OutputSpec`: a path pattern declared as a task output.
//!
//! A distinct newtype wrapping [`PathPattern`] so that the type
//! system distinguishes "this pattern declares an output" from
//! "this pattern declares an input" (see [`crate::path::InputSpec`]).
//! The two types share the same parsing logic but live in
//! different domain roles: an output pattern is a *production*
//! claim (files this task writes), and downstream code, in
//! particular DAG construction, treats it differently from inputs.

use std::fmt;

use crate::path::pattern::{PathPattern, PathPatternError};

/// A validated output declaration: a path pattern attached to a
/// task's `outputs` field.
///
/// Construction via [`OutputSpec::parse`]; the [`fmt::Display`]
/// implementation produces a string that round-trips through
/// [`OutputSpec::parse`].
///
/// # Examples
///
/// ```
/// use haz_domain::path::OutputSpec;
///
/// let s = OutputSpec::parse("dist/bundle.js").unwrap();
/// assert_eq!(s.to_string(), "dist/bundle.js");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct OutputSpec(PathPattern);

impl OutputSpec {
    /// Parse `s` as an output declaration.
    ///
    /// # Errors
    ///
    /// Returns the same [`PathPatternError`] as
    /// [`PathPattern::parse`].
    pub fn parse(s: &str) -> Result<Self, PathPatternError> {
        PathPattern::parse(s).map(Self)
    }

    /// Borrow the underlying pattern.
    #[must_use]
    pub fn pattern(&self) -> &PathPattern {
        &self.0
    }

    /// Consume `self`, returning the underlying pattern.
    #[must_use]
    pub fn into_pattern(self) -> PathPattern {
        self.0
    }
}

impl fmt::Display for OutputSpec {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

#[cfg(test)]
mod tests {
    use crate::path::OutputSpec;

    #[test]
    fn parses_literal_output() {
        let s = OutputSpec::parse("dist/bundle.js").unwrap();
        assert!(s.pattern().is_literal());
    }

    #[test]
    fn parses_glob_output() {
        let s = OutputSpec::parse("dist/**/*.js").unwrap();
        assert!(s.pattern().is_glob());
    }

    #[test]
    fn rejects_invalid_output() {
        assert!(OutputSpec::parse("../escape").is_err());
    }

    #[test]
    fn round_trip() {
        let original = "dist/**/*.js";
        let s = OutputSpec::parse(original).unwrap();
        assert_eq!(s.to_string(), original);
        let reparsed = OutputSpec::parse(&s.to_string()).unwrap();
        assert_eq!(s, reparsed);
    }

    #[test]
    fn input_and_output_specs_are_distinct_types() {
        // Same canonical text, different domain role: must not be
        // interchangeable in generic code.
        use crate::path::InputSpec;
        let same_text = "src/lib.rs";
        let _i = InputSpec::parse(same_text).unwrap();
        let _o = OutputSpec::parse(same_text).unwrap();
        // The fact that this compiles is the assertion; an
        // accidental `From<InputSpec> for OutputSpec` would let
        // code freely convert between them.
    }
}