use-dockerignore 0.0.1

Primitive .dockerignore pattern vocabulary for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned while parsing `.dockerignore` vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerIgnoreParseError {
    /// A pattern-bearing rule had no pattern text.
    EmptyPattern,
    /// The supplied scope label was not recognized.
    UnknownScope,
}

impl fmt::Display for DockerIgnoreParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyPattern => formatter.write_str("Docker ignore pattern cannot be empty"),
            Self::UnknownScope => formatter.write_str("unknown Docker ignore scope"),
        }
    }
}

impl Error for DockerIgnoreParseError {}

/// Pattern negation vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DockerIgnoreNegation {
    /// A normal ignore pattern.
    Normal,
    /// A negated ignore pattern prefixed by `!`.
    Negated,
}

impl DockerIgnoreNegation {
    /// Returns true when the pattern is negated.
    #[must_use]
    pub const fn is_negated(self) -> bool {
        matches!(self, Self::Negated)
    }
}

/// Ignore rule classification.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DockerIgnoreScope {
    /// A blank line.
    Blank,
    /// A comment line.
    Comment,
    /// A file or path pattern.
    Pattern,
    /// A directory-only pattern.
    Directory,
}

impl DockerIgnoreScope {
    /// Returns the stable scope label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Blank => "blank",
            Self::Comment => "comment",
            Self::Pattern => "pattern",
            Self::Directory => "directory",
        }
    }
}

impl fmt::Display for DockerIgnoreScope {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for DockerIgnoreScope {
    type Err = DockerIgnoreParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "blank" => Ok(Self::Blank),
            "comment" => Ok(Self::Comment),
            "pattern" => Ok(Self::Pattern),
            "directory" | "dir" => Ok(Self::Directory),
            _ => Err(DockerIgnoreParseError::UnknownScope),
        }
    }
}

/// A non-empty `.dockerignore` pattern.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerIgnorePattern(String);

impl DockerIgnorePattern {
    /// Creates an ignore pattern from text.
    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerIgnoreParseError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            Err(DockerIgnoreParseError::EmptyPattern)
        } else {
            Ok(Self(trimmed.to_string()))
        }
    }

    /// Returns the pattern text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for DockerIgnorePattern {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for DockerIgnorePattern {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// A classified `.dockerignore` rule.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DockerIgnoreRule {
    original: String,
    pattern: Option<DockerIgnorePattern>,
    negation: DockerIgnoreNegation,
    scope: DockerIgnoreScope,
}

impl DockerIgnoreRule {
    /// Parses a `.dockerignore`-style line.
    pub fn parse(value: impl AsRef<str>) -> Result<Self, DockerIgnoreParseError> {
        let original = value.as_ref().to_string();
        let trimmed = value.as_ref().trim();

        if trimmed.is_empty() {
            return Ok(Self {
                original,
                pattern: None,
                negation: DockerIgnoreNegation::Normal,
                scope: DockerIgnoreScope::Blank,
            });
        }
        if trimmed.starts_with('#') {
            return Ok(Self {
                original,
                pattern: None,
                negation: DockerIgnoreNegation::Normal,
                scope: DockerIgnoreScope::Comment,
            });
        }

        let (negation, pattern_text) = trimmed
            .strip_prefix('!')
            .map_or((DockerIgnoreNegation::Normal, trimmed), |rest| {
                (DockerIgnoreNegation::Negated, rest)
            });
        let pattern = DockerIgnorePattern::new(pattern_text)?;
        let scope = if pattern.as_str().ends_with('/') {
            DockerIgnoreScope::Directory
        } else {
            DockerIgnoreScope::Pattern
        };

        Ok(Self {
            original,
            pattern: Some(pattern),
            negation,
            scope,
        })
    }

    /// Returns the original line text.
    #[must_use]
    pub fn original(&self) -> &str {
        &self.original
    }

    /// Returns the pattern when this is a pattern-bearing rule.
    #[must_use]
    pub const fn pattern(&self) -> Option<&DockerIgnorePattern> {
        self.pattern.as_ref()
    }

    /// Returns the negation marker.
    #[must_use]
    pub const fn negation(&self) -> DockerIgnoreNegation {
        self.negation
    }

    /// Returns the rule scope.
    #[must_use]
    pub const fn scope(&self) -> DockerIgnoreScope {
        self.scope
    }

    /// Returns true when this is a comment line.
    #[must_use]
    pub const fn is_comment(&self) -> bool {
        matches!(self.scope, DockerIgnoreScope::Comment)
    }

    /// Returns true when this is a blank line.
    #[must_use]
    pub const fn is_blank(&self) -> bool {
        matches!(self.scope, DockerIgnoreScope::Blank)
    }

    /// Returns true when the rule carries a directory-only pattern.
    #[must_use]
    pub const fn is_directory_only(&self) -> bool {
        matches!(self.scope, DockerIgnoreScope::Directory)
    }
}

#[cfg(test)]
mod tests {
    use super::{DockerIgnoreNegation, DockerIgnoreRule, DockerIgnoreScope};

    #[test]
    fn classifies_dockerignore_lines() -> Result<(), Box<dyn std::error::Error>> {
        let rule = DockerIgnoreRule::parse("!target/")?;
        let comment = DockerIgnoreRule::parse("# generated files")?;

        assert_eq!(rule.negation(), DockerIgnoreNegation::Negated);
        assert_eq!(rule.scope(), DockerIgnoreScope::Directory);
        assert!(rule.is_directory_only());
        assert!(comment.is_comment());
        Ok(())
    }
}