just_a_tag/
lib.rs

1//! # Just a tag
2//!
3//! This crate contains the [`Tag`] type, an [RFC 1035](https://datatracker.ietf.org/doc/html/rfc1035)
4//! DNS label compatible string, with parsing [`FromStr`] and optional [serde](https://serde.rs/) support.
5//!
6//! ## Tag examples
7//!
8//! ```
9//! # use just_a_tag::Tag;
10//! assert_eq!(Tag::new("some-tag"), "some-tag");
11//! assert_eq!(Tag::from_str("some-tag").unwrap(), "some-tag");
12//! assert!(Tag::from_str("invalid-").is_err());
13//! ```
14//!
15//! ## Unions of tags
16//!
17//! A bit untrue to the crate's name, it also provides the [`TagUnion`] type, which represents
18//! (unsurprisingly, this time) a union of tags.
19//!
20//! ```
21//! use std::collections::HashSet;
22//! use just_a_tag::{MatchesAnyTagUnion, Tag, TagUnion};
23//!
24//! let union = TagUnion::from_str("foo").unwrap();
25//! assert!(union.contains(&Tag::new("foo")));
26//! assert_eq!(union.len(), 1);
27//!
28//! let union = TagUnion::from_str("foo+bar").unwrap();
29//! assert!(union.contains(&Tag::new("foo")));
30//! assert!(union.contains(&Tag::new("bar")));
31//! assert_eq!(union.len(), 2);
32//!
33//! // TagUnions are particularly interesting when bundled up.
34//! let unions = vec![
35//!     TagUnion::from_str("bar+baz").unwrap(),
36//!     TagUnion::from_str("foo").unwrap()
37//! ];
38//!
39//! // foo matches
40//! let set_1 = HashSet::from_iter([Tag::new("foo"), Tag::new("bar")]);
41//! assert!(unions.matches_set(&set_1));
42//!
43//! // bar+baz matches
44//! let set_2 = HashSet::from_iter([Tag::new("fubar"), Tag::new("bar"), Tag::new("baz")]);
45//! assert!(unions.matches_set(&set_2));
46//!
47//! // none match
48//! let set_3 = HashSet::from_iter([Tag::new("fubar"), Tag::new("bar")]);
49//! assert!(!unions.matches_set(&set_3));
50//! ```
51
52// SPDX-FileCopyrightText: Copyright 2023 Markus Mayer
53// SPDX-License-Identifier: EUPL-1.2
54// SPDX-FileType: SOURCE
55
56// Only enable the `doc_cfg` feature when the `docsrs` configuration attribute is defined.
57#![cfg_attr(docsrs, feature(doc_cfg))]
58#![cfg_attr(feature = "unsafe", allow(unsafe_code))]
59#![cfg_attr(not(feature = "unsafe"), forbid(unsafe_code))]
60
61mod tag_union;
62
63#[cfg(feature = "serde")]
64use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
65use std::error::Error;
66use std::fmt::{Display, Formatter};
67use std::ops::Deref;
68use std::str::FromStr;
69
70pub use tag_union::{MatchesAnyTagUnion, TagUnion, TagUnionFromStringError};
71
72/// A tag name.
73///
74/// Tag names [RFC 1035](https://datatracker.ietf.org/doc/html/rfc1035) DNS label compatible,
75/// in other words they must
76///
77/// - not be longer than 63 characters,,
78/// - only lowercase alphanumeric characters or '-',
79/// - start with an alphabetic character, and
80/// - end with an alphanumeric character.
81#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
82pub struct Tag(String);
83
84impl Tag {
85    /// An empty tag.
86    pub const EMPTY: Tag = Tag(String::new());
87
88    /// The maximum length of a tag.
89    pub const MAX_LEN: usize = 63;
90
91    /// Constructs a new tag.
92    ///
93    /// ## Panics
94    ///
95    /// This method panics if the input is not a valid tag. If you want to avoid a panic,
96    /// use [`Tag::from_str`](Self::from_str) instead.
97    ///
98    /// ## Example
99    ///
100    /// ```
101    /// use just_a_tag::Tag;
102    /// assert_eq!(Tag::new("foo"), "foo");
103    /// ```
104    pub fn new<V: AsRef<str>>(value: V) -> Self {
105        value.as_ref().parse().expect("invalid input")
106    }
107
108    /// Constructs a new tag without checking for validity.
109    ///
110    /// ## Example
111    ///
112    /// ```
113    /// # use just_a_tag::Tag;
114    /// /// // Constructs a Tag without verifying the input.
115    /// assert_eq!(unsafe { Tag::new_unchecked("foo") }, "foo");
116    /// assert_eq!(unsafe { Tag::new_unchecked("@") }, "@"); // NOTE: invalid input
117    /// ```
118    #[cfg_attr(docsrs, doc(cfg(feature = "unsafe")))]
119    #[cfg(feature = "unsafe")]
120    pub unsafe fn new_unchecked<V: Into<String>>(value: V) -> Self {
121        Self(value.into())
122    }
123
124    /// Parses a [`Tag`] from a string-like value.
125    ///
126    /// ```
127    /// # use just_a_tag::Tag;
128    /// assert_eq!(Tag::from_str("some-tag").unwrap(), "some-tag");
129    /// assert!(Tag::from_str("invalid-").is_err());
130    /// ```
131    pub fn from_str<S: AsRef<str>>(value: S) -> Result<Self, TagFromStringError> {
132        let value = value.as_ref();
133        if value.is_empty() {
134            return Ok(Tag::EMPTY.clone());
135        }
136
137        if value.len() > Tag::MAX_LEN {
138            return Err(TagFromStringError::LimitExceeded(value.len()));
139        }
140
141        let mut chars = value.chars();
142        let first = chars.next().expect("tag is not empty");
143        if !first.is_ascii_lowercase() {
144            return Err(TagFromStringError::MustStartAlphabetic(first));
145        }
146
147        let mut previous = first;
148        while let Some(c) = chars.next() {
149            if !c.is_ascii_digit() && !c.is_ascii_lowercase() && c != '-' {
150                return Err(TagFromStringError::InvalidCharacter(c));
151            }
152
153            previous = c;
154        }
155
156        if !previous.is_ascii_lowercase() {
157            return Err(TagFromStringError::MustEndAlphanumeric(previous));
158        }
159
160        Ok(Self(value.into()))
161    }
162}
163
164impl Display for Tag {
165    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
166        write!(f, "{}", self.0)
167    }
168}
169
170impl Deref for Tag {
171    type Target = str;
172
173    #[inline(always)]
174    fn deref(&self) -> &Self::Target {
175        self.0.deref()
176    }
177}
178
179impl PartialEq<str> for Tag {
180    #[inline(always)]
181    fn eq(&self, other: &str) -> bool {
182        self.0.eq(other)
183    }
184}
185
186impl PartialEq<&str> for Tag {
187    #[inline(always)]
188    fn eq(&self, other: &&str) -> bool {
189        self.0.eq(other)
190    }
191}
192
193impl FromStr for Tag {
194    type Err = TagFromStringError;
195
196    #[inline(always)]
197    fn from_str(value: &str) -> Result<Self, Self::Err> {
198        Tag::from_str(value)
199    }
200}
201
202impl TryFrom<&str> for Tag {
203    type Error = TagFromStringError;
204
205    fn try_from(value: &str) -> Result<Self, Self::Error> {
206        value.parse()
207    }
208}
209
210impl TryFrom<String> for Tag {
211    type Error = TagFromStringError;
212
213    fn try_from(value: String) -> Result<Self, Self::Error> {
214        value.parse()
215    }
216}
217
218impl TryFrom<&String> for Tag {
219    type Error = TagFromStringError;
220
221    fn try_from(value: &String) -> Result<Self, Self::Error> {
222        value.parse()
223    }
224}
225
226#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
227#[cfg(feature = "serde")]
228impl<'de> Deserialize<'de> for Tag {
229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230    where
231        D: Deserializer<'de>,
232    {
233        let tag = String::deserialize(deserializer)?;
234        match Tag::from_str(&tag) {
235            Ok(tag) => Ok(tag),
236            Err(e) => Err(de::Error::custom(e)),
237        }
238    }
239}
240
241#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
242#[cfg(feature = "serde")]
243impl Serialize for Tag {
244    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
245    where
246        S: Serializer,
247    {
248        serializer.serialize_str(&self.0)
249    }
250}
251
252#[derive(Debug, Eq, PartialEq)]
253pub enum TagFromStringError {
254    MustStartAlphabetic(char),
255    MustEndAlphanumeric(char),
256    InvalidCharacter(char),
257    LimitExceeded(usize),
258}
259
260impl Display for TagFromStringError {
261    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
262        match self {
263            TagFromStringError::MustStartAlphabetic(c) => write!(
264                f,
265                "Tag name must begin with a lowercase alphabetic character, got '{c}'"
266            ),
267            TagFromStringError::MustEndAlphanumeric(c) => write!(
268                f,
269                "Tag name must end with a lowercase alphanumeric character, got '{c}'"
270            ),
271            TagFromStringError::InvalidCharacter(c) => write!(
272                f,
273                "Tag name must only contain lowercase alphanumeric characters or '-', got '{c}'"
274            ),
275            TagFromStringError::LimitExceeded(len) => write!(
276                f,
277                "Tag name must be not longer than 63 characters, got '{len}'"
278            ),
279        }
280    }
281}
282
283impl Error for TagFromStringError {}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_trivial() {
291        assert_eq!(Tag::from_str("test").unwrap(), "test");
292        assert_eq!(Tag::from_str("test-case").unwrap(), "test-case");
293        assert_eq!(Tag::from_str("test---12e").unwrap(), "test---12e");
294        assert!(
295            Tag::from_str("a123456789a123456789a123456789a123456789a123456789a12345678901a")
296                .is_ok()
297        );
298    }
299
300    #[test]
301    fn test_invalid() {
302        assert!(Tag::from_str("1").is_err());
303        assert!(Tag::from_str("-").is_err());
304        assert!(Tag::from_str("a-").is_err());
305        assert!(Tag::from_str("a1").is_err());
306        assert!(Tag::from_str("a_b_c").is_err());
307        assert!(
308            Tag::from_str("a123456789a123456789a123456789a123456789a123456789a123456789012a")
309                .is_err()
310        );
311    }
312
313    #[test]
314    #[cfg(feature = "serde")]
315    fn test_serde_de_trivial() {
316        assert_eq!(serde_json::from_str::<Tag>(r#""test""#).unwrap(), "test");
317        assert_eq!(
318            serde_json::from_str::<Tag>(r#""test-case""#).unwrap(),
319            "test-case"
320        );
321        assert_eq!(
322            serde_json::from_str::<Tag>(r#""test---12e""#).unwrap(),
323            "test---12e"
324        );
325        assert!(serde_json::from_str::<Tag>(
326            r#""a123456789a123456789a123456789a123456789a123456789a12345678901a""#
327        )
328        .is_ok());
329    }
330
331    #[test]
332    #[cfg(feature = "serde")]
333    fn test_serde_de_invalid() {
334        assert!(serde_json::from_str::<Tag>(r#""1""#).is_err());
335        assert!(serde_json::from_str::<Tag>(r#""-""#).is_err());
336        assert!(serde_json::from_str::<Tag>(r#""a-""#).is_err());
337        assert!(serde_json::from_str::<Tag>(r#""a1""#).is_err());
338        assert!(serde_json::from_str::<Tag>(r#""a_b_c""#).is_err());
339        assert!(serde_json::from_str::<Tag>(
340            r#""a123456789a123456789a123456789a123456789a123456789a123456789012a""#
341        )
342        .is_err());
343    }
344
345    #[test]
346    #[cfg(feature = "serde")]
347    fn test_serde_ser_invalid() {
348        let json = serde_json::to_string(&Tag::new("foo")).unwrap();
349        assert_eq!(json, r#""foo""#);
350    }
351}