bool-tag-expr 0.1.0-beta.2

Parse boolean expressions of tags for filtering and selecting
Documentation
//!
//! All functionality related to [`Tag]`(s)
//!

use serde::{Deserialize, Deserializer, Serialize};
use std::{collections::BTreeSet, fmt::Display};
use thiserror::Error;

/// Errors that can arise in relation to a [`Tag`]
#[derive(Error, Debug, Clone, Hash, PartialEq, Eq)]
pub enum TagError {
    #[error("Tag component cannot be empty")]
    Empty,

    #[error("Tag component '{0}' starts with a hyphen")]
    StartsWithHyphen(String),

    #[error("Tag component '{0}' ends with a hyphen")]
    EndsWithHyphen(String),

    #[error("Tag component '{0}' contains a double hyphen")]
    ContainsDoubleHyphen(String),

    #[error("Tag component '{1}' contains disallowed char '{0}'")]
    DisallowedChar(char, String),
}

/// An alias of `BTreeSet<Tag>`
pub type Tags = BTreeSet<Tag>;

/// A [`Tag`] may optionally have a name, but must have a value
#[derive(Clone, Debug, Deserialize, Serialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Tag {
    pub name: Option<TagName>,
    pub value: TagValue,
}

impl Tag {
    /// Create a [`Tag`] using a function rather than a struct literal
    #[must_use]
    pub const fn from(name: Option<TagName>, value: TagValue) -> Self {
        Self { name, value }
    }
}

impl Display for Tag {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self.name {
            Some(name) => {
                write!(f, "{}={}", name.as_str(), self.value.as_str())
            }
            None => write!(f, "={}", self.value.as_str()),
        }
    }
}

/// An alias of [`TagComponent`]
pub type TagName = TagComponent;

/// An alias of [`TagComponent`]
pub type TagValue = TagComponent;

/// Represents both tag names and values (see [`TagName`] & [`TagValue`]).
/// 
/// See the [`TagComponent::from`] associated method for information on what
/// constitutes a valid tag name/value.
#[rustfmt::skip]
#[derive( derive_more::Display, Clone, Debug, Serialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
#[cfg_attr(feature = "sqlx", sqlx(transparent))]
pub struct TagComponent(String);

impl TagComponent {
    /// Create a [`TagComponent`], or return a [`TagError`]
    ///
    /// First the input string is converted to lowercase and leading and trailing
    /// whitespace is removed
    ///
    /// What is left:
    ///
    /// - Must only contain lowercase ASCII letters or `-`s
    /// - Must not start with `-`
    /// - Must not end with `-`
    /// - Must not contain consecutive hyphens
    ///
    /// ## Valid inputs
    ///
    /// - `  My-tag  `
    /// - `tag-component-here`
    ///
    /// ## Invalid inputs
    ///
    /// - `-my-tag`
    /// - `another-tag-`
    /// - `tag--component`
    ///
    pub fn from<S: ToString>(value: S) -> Result<Self, TagError> {
        let original = value.to_string();
        let modified = original.to_lowercase();
        let modified = modified.trim();
        if modified.is_empty() {
            return Err(TagError::Empty);
        }
        if modified.starts_with('-') {
            return Err(TagError::StartsWithHyphen(original));
        }
        if modified.ends_with('-') {
            return Err(TagError::EndsWithHyphen(original));
        }
        if modified.contains("--") {
            return Err(TagError::ContainsDoubleHyphen(original));
        }
        for char in modified.chars() {
            if !(char.is_ascii_alphabetic() || char == '-') {
                return Err(TagError::DisallowedChar(char, original));
            }
        }
        if original == modified {
            Ok(Self(original))
        } else {
            Ok(Self(modified.to_string()))
        }
    }

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

// TODO: this really needs testing
impl<'de> Deserialize<'de> for TagComponent {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let tag_component: String = Deserialize::deserialize(deserializer)?;
        Self::from(&tag_component)
            .map_err(|error| serde::de::Error::custom(format!("Error: {error}")))
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn from() {
        // Should fail
        let strs = vec![
            "",
            "-fail",
            "fail-",
            "fail--here",
            "fail here",
            "=fail",
            "fail=me",
        ];
        for str in strs {
            assert!(TagName::from(str).is_err());
            assert!(TagValue::from(str).is_err());
        }

        // Should pass
        let strs = vec!["PASS", "pass", "pass-here", "  pass-me  ", "pass-me-too"];
        for str in strs {
            assert!(TagName::from(str).is_ok());
            assert!(TagValue::from(str).is_ok());
        }
    }
}