use serde::{Deserialize, Deserializer, Serialize};
use std::{collections::BTreeSet, fmt::Display};
use thiserror::Error;
#[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),
}
pub type Tags = BTreeSet<Tag>;
#[derive(Clone, Debug, Deserialize, Serialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Tag {
pub name: Option<TagName>,
pub value: TagValue,
}
impl Tag {
#[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()),
}
}
}
pub type TagName = TagComponent;
pub type TagValue = TagComponent;
#[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 {
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()))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
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() {
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());
}
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());
}
}
}