just_a_tag/
tag_union.rs

1//! Provides the [`TagUnion`] type, a union of tags.
2
3// SPDX-FileCopyrightText: Copyright 2023 Markus Mayer
4// SPDX-License-Identifier: EUPL-1.2
5// SPDX-FileType: SOURCE
6
7use crate::{Tag, TagFromStringError};
8#[cfg(feature = "serde")]
9use serde::{de, Deserialize, Deserializer};
10use std::borrow::Borrow;
11use std::collections::HashSet;
12use std::error::Error;
13use std::fmt::{Display, Formatter};
14use std::hash::{Hash, Hasher};
15use std::iter::FromIterator;
16use std::ops::Deref;
17use std::str::FromStr;
18
19/// A tag union, e.g. `foo` or `foo+bar+baz` (i.e. `foo` _and_ `bar` _and_ `baz`).
20///
21/// ```
22/// use just_a_tag::{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/// assert!(TagUnion::from_str("foo bar").is_err());
34/// ```
35#[derive(Debug, Default, Clone, Eq, PartialEq)]
36pub struct TagUnion(HashSet<Tag>);
37
38impl TagUnion {
39    /// Returns `true` if this tag union matches the value presented in the set.
40    ///
41    /// ```
42    /// use std::collections::HashSet;
43    /// use just_a_tag::{MatchesAnyTagUnion, Tag, TagUnion};
44    ///
45    /// let unions = vec![
46    ///     TagUnion::from_str("foo").unwrap(),
47    ///     TagUnion::from_str("bar+baz").unwrap()
48    /// ];
49    ///
50    /// // foo, and bar+baz matches
51    /// let set_1 = HashSet::from_iter([Tag::new("foo"), Tag::new("bar"), Tag::new("baz")]);
52    /// assert!(unions.matches_set(&set_1));
53    ///
54    /// // bar+baz matches
55    /// let set_2 = HashSet::from_iter([Tag::new("fubar"), Tag::new("bar"), Tag::new("baz")]);
56    /// assert!(unions.matches_set(&set_2));
57    ///
58    /// // foo matches
59    /// let set_3 = HashSet::from_iter([Tag::new("foo"), Tag::new("bar")]);
60    /// assert!(unions.matches_set(&set_3));
61    ///
62    /// // none match
63    /// let set_4 = HashSet::from_iter([Tag::new("fubar"), Tag::new("bar")]);
64    /// assert!(!unions.matches_set(&set_4));
65    /// ```
66    pub fn matches_set(&self, values: &HashSet<Tag>) -> bool {
67        self.0.is_subset(values)
68    }
69
70    /// Inserts a tag into this union.
71    /// Returns whether the tag was inserted; that is:
72    ///
73    /// * If the tag was not previously inserted, `true` is returned,
74    /// * If the tag was previously inserted, `false` is returned.
75    pub fn insert(&mut self, tag: Tag) -> bool {
76        self.0.insert(tag)
77    }
78
79    /// Removes a tag from this union.
80    /// Returns whether the tag was removed; that is:
81    ///
82    /// * If the tag was previously inserted, `true` is returned,
83    /// * If the tag was not previously inserted, `false` is returned.
84    pub fn remove<T: Borrow<Tag>>(&mut self, tag: T) -> bool {
85        self.0.remove(tag.borrow())
86    }
87
88    /// Returns whether this union contains the specified tag. That is:
89    ///
90    /// * If the tag was previously inserted, `true` is returned,
91    /// * If the tag was not previously inserted, `false` is returned.
92    pub fn contains<T: Borrow<Tag>>(&self, tag: &T) -> bool {
93        self.0.contains(tag.borrow())
94    }
95
96    /// Attempts to parse a [`TagUnion`] from a string-like input.
97    pub fn from_str<S: AsRef<str>>(value: S) -> Result<TagUnion, TagUnionFromStringError> {
98        let value = value.as_ref();
99        if value.is_empty() {
100            return Ok(TagUnion::default());
101        }
102
103        let parts = value.split('+');
104        let names: HashSet<String> = parts
105            .filter(|&c| !c.contains('+'))
106            .filter(|&c| !c.is_empty())
107            .map(|c| c.into())
108            .collect();
109
110        if names.is_empty() {
111            return Ok(TagUnion::default());
112        }
113
114        let mut tags = HashSet::new();
115        for name in names.into_iter() {
116            tags.insert(Tag::from_str(&name)?);
117        }
118
119        Ok(Self(tags))
120    }
121}
122
123/// Implements
124pub trait MatchesAnyTagUnion {
125    /// Returns `true` if this tag union matches the value presented in the set.
126    ///
127    /// ```
128    /// use std::collections::HashSet;
129    /// use just_a_tag::{MatchesAnyTagUnion, Tag, TagUnion};
130    ///
131    /// let unions = vec![
132    ///     TagUnion::from_str("foo").unwrap(),
133    ///     TagUnion::from_str("bar+baz").unwrap()
134    /// ];
135    ///
136    /// // foo, and bar+baz matches
137    /// let set_1 = HashSet::from_iter([Tag::new("foo"), Tag::new("bar"), Tag::new("baz")]);
138    /// assert!(unions.matches_set(&set_1));
139    ///
140    /// // bar+baz matches
141    /// let set_2 = HashSet::from_iter([Tag::new("fubar"), Tag::new("bar"), Tag::new("baz")]);
142    /// assert!(unions.matches_set(&set_2));
143    ///
144    /// // foo matches
145    /// let set_3 = HashSet::from_iter([Tag::new("foo"), Tag::new("bar")]);
146    /// assert!(unions.matches_set(&set_3));
147    ///
148    /// // none match
149    /// let set_4 = HashSet::from_iter([Tag::new("fubar"), Tag::new("bar")]);
150    /// assert!(!unions.matches_set(&set_4));
151    /// ```
152    fn matches_set(&self, values: &HashSet<Tag>) -> bool;
153}
154
155impl MatchesAnyTagUnion for Vec<TagUnion> {
156    fn matches_set(&self, values: &HashSet<Tag>) -> bool {
157        self.iter().any(|s| s.matches_set(&values))
158    }
159}
160
161impl Deref for TagUnion {
162    type Target = HashSet<Tag>;
163
164    fn deref(&self) -> &Self::Target {
165        &self.0
166    }
167}
168
169impl Hash for TagUnion {
170    fn hash<H: Hasher>(&self, state: &mut H) {
171        let mut vec = Vec::from_iter(self.0.iter());
172        vec.sort();
173        for tag in vec {
174            tag.hash(state);
175        }
176    }
177}
178
179impl FromIterator<Tag> for TagUnion {
180    fn from_iter<T: IntoIterator<Item = Tag>>(iter: T) -> Self {
181        Self(iter.into_iter().collect())
182    }
183}
184
185impl FromStr for TagUnion {
186    type Err = TagUnionFromStringError;
187
188    fn from_str(value: &str) -> Result<Self, Self::Err> {
189        TagUnion::from_str(value)
190    }
191}
192
193#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
194#[cfg(feature = "serde")]
195impl<'de> Deserialize<'de> for TagUnion {
196    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197    where
198        D: Deserializer<'de>,
199    {
200        let input = String::deserialize(deserializer)?;
201        match TagUnion::from_str(&input) {
202            Ok(tags) => Ok(tags),
203            Err(e) => Err(de::Error::custom(e)),
204        }
205    }
206}
207
208#[derive(Debug, Eq, PartialEq)]
209pub enum TagUnionFromStringError {
210    InvalidTag(TagFromStringError),
211}
212
213impl Display for TagUnionFromStringError {
214    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
215        match self {
216            TagUnionFromStringError::InvalidTag(e) => write!(f, "Invalid tag: {e}"),
217        }
218    }
219}
220
221impl From<TagFromStringError> for TagUnionFromStringError {
222    fn from(value: TagFromStringError) -> Self {
223        Self::InvalidTag(value)
224    }
225}
226
227impl Error for TagUnionFromStringError {}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_empty() {
235        let tags: TagUnion = TagUnion::from_str("").unwrap();
236        assert!(tags.is_empty());
237    }
238
239    #[test]
240    fn test_add_remove() {
241        let mut tags = TagUnion::from_str(r#"foo"#).unwrap();
242        assert!(tags.contains(&Tag::new("foo")));
243        assert_eq!(tags.len(), 1);
244
245        tags.insert(Tag::new("bar"));
246        assert_eq!(tags.len(), 2);
247        tags.contains(&Tag::new("bar"));
248
249        tags.remove(&Tag::new("foo"));
250        assert!(!tags.contains(&Tag::new("foo")));
251        assert_eq!(tags.len(), 1);
252
253        tags.remove(&Tag::new("bar"));
254        assert_eq!(tags.len(), 0);
255        assert!(tags.is_empty());
256    }
257
258    #[test]
259    #[cfg(feature = "serde")]
260    fn test_trivial() {
261        let tags: TagUnion = serde_json::from_str(r#""foo""#).unwrap();
262        assert!(tags.contains(&Tag::new("foo")));
263    }
264
265    #[test]
266    #[cfg(feature = "serde")]
267    fn test_complex() {
268        let tags: TagUnion = serde_json::from_str(r#""foo+bar+++baz++""#).unwrap();
269        assert_eq!(tags.len(), 3);
270        assert!(tags.contains(&Tag::new("foo")));
271        assert!(tags.contains(&Tag::new("bar")));
272        assert!(tags.contains(&Tag::new("baz")));
273    }
274
275    #[test]
276    fn test_invalid() {
277        let tags = TagUnion::from_str(r#"foo+#baz"#);
278        assert_eq!(
279            tags,
280            Err(TagUnionFromStringError::InvalidTag(
281                crate::TagFromStringError::MustStartAlphabetic('#')
282            ))
283        );
284    }
285
286    #[test]
287    fn test_matches() {
288        let selections = vec![
289            TagUnion::from_str("foo+bar").unwrap(),
290            TagUnion::from_str("baz").unwrap(),
291        ];
292
293        // foo+bar are present, so is baz
294        assert!(selections.matches_set(&HashSet::from_iter([
295            Tag::new("foo"),
296            Tag::new("bar"),
297            Tag::new("baz"),
298        ])));
299
300        // baz is present
301        assert!(selections.matches_set(&HashSet::from_iter([Tag::new("baz"),])));
302
303        // foo+bar are present
304        assert!(selections.matches_set(&HashSet::from_iter([Tag::new("foo"), Tag::new("bar"),])));
305
306        // baz present
307        assert!(selections.matches_set(&HashSet::from_iter([Tag::new("foo"), Tag::new("baz"),])));
308
309        // neither foo+bar, nor baz are present.
310        assert!(!selections.matches_set(&HashSet::from_iter([Tag::new("foo"), Tag::new("bang"),])));
311    }
312}