use-git-ignore 0.0.1

Primitive gitignore 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 ignore vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitIgnoreParseError {
    /// A pattern-bearing rule had no pattern text.
    EmptyPattern,
    /// The supplied scope label was not recognized.
    UnknownScope,
}

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

impl Error for GitIgnoreParseError {}

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

impl GitIgnoreNegation {
    /// 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 GitIgnoreScope {
    /// A blank line.
    Blank,
    /// A comment line.
    Comment,
    /// A file or path pattern.
    Pattern,
    /// A directory-only pattern.
    Directory,
}

impl GitIgnoreScope {
    /// 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 GitIgnoreScope {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for GitIgnoreScope {
    type Err = GitIgnoreParseError;

    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(GitIgnoreParseError::UnknownScope),
        }
    }
}

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

impl GitIgnorePattern {
    /// Creates an ignore pattern from text.
    ///
    /// # Errors
    ///
    /// Returns [`GitIgnoreParseError::EmptyPattern`] when the pattern is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            Err(GitIgnoreParseError::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 GitIgnorePattern {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

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

/// A classified ignore rule.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitIgnoreRule {
    original: String,
    pattern: Option<GitIgnorePattern>,
    negation: GitIgnoreNegation,
    scope: GitIgnoreScope,
}

impl GitIgnoreRule {
    /// Parses a `gitignore`-style line.
    ///
    /// # Errors
    ///
    /// Returns [`GitIgnoreParseError::EmptyPattern`] when a negated line has no pattern.
    pub fn parse(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
        let original = value.as_ref().to_string();
        let trimmed = value.as_ref().trim();

        if trimmed.is_empty() {
            return Ok(Self {
                original,
                pattern: None,
                negation: GitIgnoreNegation::Normal,
                scope: GitIgnoreScope::Blank,
            });
        }

        if trimmed.starts_with('#') {
            return Ok(Self {
                original,
                pattern: None,
                negation: GitIgnoreNegation::Normal,
                scope: GitIgnoreScope::Comment,
            });
        }

        let (negation, pattern_text) = trimmed
            .strip_prefix('!')
            .map_or((GitIgnoreNegation::Normal, trimmed), |rest| {
                (GitIgnoreNegation::Negated, rest)
            });
        let pattern = GitIgnorePattern::new(pattern_text)?;
        let scope = if pattern.as_str().ends_with('/') {
            GitIgnoreScope::Directory
        } else {
            GitIgnoreScope::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<&GitIgnorePattern> {
        self.pattern.as_ref()
    }

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

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

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

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

    /// Returns true when this rule is directory-only vocabulary.
    #[must_use]
    pub const fn is_directory_only(&self) -> bool {
        matches!(self.scope, GitIgnoreScope::Directory)
    }
}

impl FromStr for GitIgnoreRule {
    type Err = GitIgnoreParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::parse(value)
    }
}

#[cfg(test)]
mod tests {
    use super::{GitIgnoreNegation, GitIgnoreParseError, GitIgnoreRule, GitIgnoreScope};

    #[test]
    fn classifies_ignore_rules() -> Result<(), GitIgnoreParseError> {
        let comment = GitIgnoreRule::parse("# target files")?;
        let blank = GitIgnoreRule::parse("  ")?;
        let rule = GitIgnoreRule::parse("!target/")?;

        assert!(comment.is_comment());
        assert!(blank.is_blank());
        assert_eq!(rule.negation(), GitIgnoreNegation::Negated);
        assert_eq!(rule.scope(), GitIgnoreScope::Directory);
        assert!(rule.is_directory_only());
        Ok(())
    }

    #[test]
    fn rejects_empty_negated_pattern() {
        assert_eq!(
            GitIgnoreRule::parse("!"),
            Err(GitIgnoreParseError::EmptyPattern)
        );
    }
}