use-git-pathspec 0.0.1

Primitive Git pathspec 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 pathspec vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PathspecParseError {
    /// The supplied pathspec text was empty.
    Empty,
    /// The supplied magic label was not recognized.
    UnknownMagic,
    /// The supplied scope label was not recognized.
    UnknownScope,
}

impl fmt::Display for PathspecParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Git pathspec cannot be empty"),
            Self::UnknownMagic => formatter.write_str("unknown Git pathspec magic"),
            Self::UnknownScope => formatter.write_str("unknown Git pathspec scope"),
        }
    }
}

impl Error for PathspecParseError {}

fn non_empty(value: impl AsRef<str>) -> Result<String, PathspecParseError> {
    let trimmed = value.as_ref().trim();
    if trimmed.is_empty() {
        Err(PathspecParseError::Empty)
    } else {
        Ok(trimmed.to_string())
    }
}

/// Common pathspec magic labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PathspecMagic {
    /// `top` magic.
    Top,
    /// `literal` magic.
    Literal,
    /// `glob` magic.
    Glob,
    /// `icase` magic.
    Icase,
    /// `exclude` magic.
    Exclude,
}

impl PathspecMagic {
    /// Returns the stable magic label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Top => "top",
            Self::Literal => "literal",
            Self::Glob => "glob",
            Self::Icase => "icase",
            Self::Exclude => "exclude",
        }
    }
}

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

impl FromStr for PathspecMagic {
    type Err = PathspecParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "top" => Ok(Self::Top),
            "literal" => Ok(Self::Literal),
            "glob" => Ok(Self::Glob),
            "icase" | "ignore-case" => Ok(Self::Icase),
            "exclude" | "!" => Ok(Self::Exclude),
            "" => Err(PathspecParseError::Empty),
            _ => Err(PathspecParseError::UnknownMagic),
        }
    }
}

/// Pathspec scope vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PathspecScope {
    /// Worktree-facing pathspec usage.
    Worktree,
    /// Index-facing pathspec usage.
    Index,
    /// Scope is not fixed by the value itself.
    Unspecified,
}

impl PathspecScope {
    /// Returns the stable scope label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Worktree => "worktree",
            Self::Index => "index",
            Self::Unspecified => "unspecified",
        }
    }
}

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

impl FromStr for PathspecScope {
    type Err = PathspecParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "worktree" => Ok(Self::Worktree),
            "index" => Ok(Self::Index),
            "unspecified" | "unknown" => Ok(Self::Unspecified),
            "" => Err(PathspecParseError::Empty),
            _ => Err(PathspecParseError::UnknownScope),
        }
    }
}

/// A pathspec pattern string.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PathspecPattern(String);

impl PathspecPattern {
    /// Creates a pathspec pattern.
    ///
    /// # Errors
    ///
    /// Returns [`PathspecParseError::Empty`] when the pattern is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, PathspecParseError> {
        non_empty(value).map(Self)
    }

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

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

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

/// A lightweight pathspec wrapper.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitPathspec(String);

impl GitPathspec {
    /// Creates a pathspec from text.
    ///
    /// # Errors
    ///
    /// Returns [`PathspecParseError::Empty`] when the pathspec is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, PathspecParseError> {
        non_empty(value).map(Self)
    }

    /// Returns the magic labels declared in `:(...)` syntax.
    #[must_use]
    pub fn magic(&self) -> Vec<PathspecMagic> {
        parse_magic(self.as_str())
    }

    /// Returns true when a magic label is present.
    #[must_use]
    pub fn has_magic(&self, magic: PathspecMagic) -> bool {
        self.magic().contains(&magic)
    }

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

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

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

impl FromStr for GitPathspec {
    type Err = PathspecParseError;

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

fn parse_magic(value: &str) -> Vec<PathspecMagic> {
    let Some(rest) = value.strip_prefix(":(") else {
        return Vec::new();
    };
    let Some(end) = rest.find(')') else {
        return Vec::new();
    };

    rest[..end]
        .split(',')
        .filter_map(|label| label.parse::<PathspecMagic>().ok())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{GitPathspec, PathspecMagic, PathspecParseError, PathspecScope};

    #[test]
    fn parses_pathspec_magic() -> Result<(), PathspecParseError> {
        let pathspec = GitPathspec::new(":(top,literal)README.md")?;

        assert!(pathspec.has_magic(PathspecMagic::Top));
        assert!(pathspec.has_magic(PathspecMagic::Literal));
        assert_eq!(PathspecScope::Index.to_string(), "index");
        Ok(())
    }

    #[test]
    fn rejects_empty_pathspecs() {
        assert_eq!(GitPathspec::new(""), Err(PathspecParseError::Empty));
    }
}