use-git-attribute 0.0.1

Primitive gitattributes 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 attribute vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitAttributeParseError {
    /// The supplied attribute name was empty.
    EmptyName,
    /// The supplied attribute name used syntax this crate rejects.
    InvalidName,
    /// The supplied attribute value was empty.
    EmptyValue,
    /// The supplied rule pattern was empty.
    EmptyPattern,
    /// The supplied state label was not recognized.
    UnknownState,
}

impl fmt::Display for GitAttributeParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyName => formatter.write_str("Git attribute name cannot be empty"),
            Self::InvalidName => formatter.write_str("invalid Git attribute name"),
            Self::EmptyValue => formatter.write_str("Git attribute value cannot be empty"),
            Self::EmptyPattern => formatter.write_str("Git attribute rule pattern cannot be empty"),
            Self::UnknownState => formatter.write_str("unknown Git attribute state"),
        }
    }
}

impl Error for GitAttributeParseError {}

/// A validated attribute name.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitAttributeName(String);

impl GitAttributeName {
    /// Creates an attribute name from text.
    ///
    /// # Errors
    ///
    /// Returns [`GitAttributeParseError`] when the name is empty or invalid.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitAttributeParseError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            return Err(GitAttributeParseError::EmptyName);
        }
        if trimmed.chars().any(|character| {
            character.is_ascii_control()
                || character.is_ascii_whitespace()
                || matches!(character, '=' | '-' | '!')
        }) {
            return Err(GitAttributeParseError::InvalidName);
        }
        Ok(Self(trimmed.to_string()))
    }

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

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

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

impl FromStr for GitAttributeName {
    type Err = GitAttributeParseError;

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

/// A plain attribute value.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitAttributeValue(String);

impl GitAttributeValue {
    /// Creates an attribute value.
    ///
    /// # Errors
    ///
    /// Returns [`GitAttributeParseError::EmptyValue`] when the value is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitAttributeParseError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            Err(GitAttributeParseError::EmptyValue)
        } else {
            Ok(Self(trimmed.to_string()))
        }
    }

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

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

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

/// Attribute state vocabulary.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitAttributeState {
    /// Attribute is set.
    Set,
    /// Attribute is unset.
    Unset,
    /// Attribute has an explicit value.
    Value(GitAttributeValue),
    /// Attribute is explicitly unspecified.
    Unspecified,
}

impl GitAttributeState {
    /// Returns true when the state is set.
    #[must_use]
    pub const fn is_set(&self) -> bool {
        matches!(self, Self::Set)
    }

    /// Returns true when the state is unset.
    #[must_use]
    pub const fn is_unset(&self) -> bool {
        matches!(self, Self::Unset)
    }
}

impl fmt::Display for GitAttributeState {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Set => formatter.write_str("set"),
            Self::Unset => formatter.write_str("unset"),
            Self::Value(value) => formatter.write_str(value.as_str()),
            Self::Unspecified => formatter.write_str("unspecified"),
        }
    }
}

impl FromStr for GitAttributeState {
    type Err = GitAttributeParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "set" => Ok(Self::Set),
            "unset" => Ok(Self::Unset),
            "unspecified" => Ok(Self::Unspecified),
            "" => Err(GitAttributeParseError::EmptyValue),
            _ => GitAttributeValue::new(value).map(Self::Value),
        }
    }
}

/// A `gitattributes`-style rule model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitAttributeRule {
    pattern: String,
    attributes: Vec<(GitAttributeName, GitAttributeState)>,
}

impl GitAttributeRule {
    /// Creates a pattern plus attribute states.
    ///
    /// # Errors
    ///
    /// Returns [`GitAttributeParseError::EmptyPattern`] when the pattern is empty.
    pub fn new<I>(pattern: impl AsRef<str>, attributes: I) -> Result<Self, GitAttributeParseError>
    where
        I: IntoIterator<Item = (GitAttributeName, GitAttributeState)>,
    {
        let pattern = pattern.as_ref().trim();
        if pattern.is_empty() {
            return Err(GitAttributeParseError::EmptyPattern);
        }

        Ok(Self {
            pattern: pattern.to_string(),
            attributes: attributes.into_iter().collect(),
        })
    }

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

    /// Returns the attribute states.
    #[must_use]
    pub fn attributes(&self) -> &[(GitAttributeName, GitAttributeState)] {
        &self.attributes
    }
}

#[cfg(test)]
mod tests {
    use super::{
        GitAttributeName, GitAttributeParseError, GitAttributeRule, GitAttributeState,
        GitAttributeValue,
    };

    #[test]
    fn models_attribute_rules() -> Result<(), GitAttributeParseError> {
        let name = GitAttributeName::new("text")?;
        let state = GitAttributeState::Set;
        let rule = GitAttributeRule::new("*.rs", [(name, state)])?;

        assert_eq!(rule.pattern(), "*.rs");
        assert_eq!(rule.attributes().len(), 1);
        Ok(())
    }

    #[test]
    fn models_attribute_values() -> Result<(), GitAttributeParseError> {
        let value = GitAttributeValue::new("diff=rust")?;
        let state = GitAttributeState::Value(value);

        assert_eq!(state.to_string(), "diff=rust");
        Ok(())
    }

    #[test]
    fn rejects_invalid_attribute_names() {
        assert_eq!(
            GitAttributeName::new(""),
            Err(GitAttributeParseError::EmptyName)
        );
        assert_eq!(
            GitAttributeName::new("bad name"),
            Err(GitAttributeParseError::InvalidName)
        );
    }
}