Skip to main content

ephemeris_forge/
label.rs

1//! Repository label policy shared by every forge.
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use facet::Facet;
7
8use crate::Mode;
9
10/// A label color of six hexadecimal digits, stored in lowercase and without a
11/// leading hash.
12#[derive(Clone, Debug, Eq, Facet, Ord, PartialEq, PartialOrd)]
13#[facet(proxy = String)]
14pub struct LabelColor(String);
15
16/// A label color was not six hexadecimal digits.
17#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
18#[error("label color {value:?} is not six hexadecimal digits")]
19pub struct InvalidLabelColor {
20    /// The rejected color.
21    pub value: String,
22}
23
24impl LabelColor {
25    /// Borrow the color as six lowercase hexadecimal digits.
26    #[must_use]
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30
31    /// Whether a color reported by a forge is this color.
32    ///
33    /// A leading hash and the letter case are ignored.
34    #[must_use]
35    pub fn matches(&self, reported: &str) -> bool {
36        reported
37            .trim_start_matches('#')
38            .eq_ignore_ascii_case(&self.0)
39    }
40}
41
42impl TryFrom<String> for LabelColor {
43    type Error = InvalidLabelColor;
44
45    fn try_from(value: String) -> Result<Self, Self::Error> {
46        if value.len() == 6 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
47            Ok(Self(value.to_ascii_lowercase()))
48        } else {
49            Err(InvalidLabelColor { value })
50        }
51    }
52}
53
54impl TryFrom<&LabelColor> for String {
55    type Error = InvalidLabelColor;
56
57    fn try_from(value: &LabelColor) -> Result<Self, Self::Error> {
58        Ok(value.0.clone())
59    }
60}
61
62impl fmt::Display for LabelColor {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        self.0.fmt(f)
65    }
66}
67
68/// A label with the fields every forge has.
69#[derive(Clone, Debug, Eq, Facet, PartialEq)]
70#[facet(deny_unknown_fields)]
71pub struct Label {
72    /// The label color.
73    pub color: LabelColor,
74    /// The label description. Null and empty mean the same.
75    pub description: Option<String>,
76}
77
78/// Repository label policy.
79///
80/// `L` is the forge's label type.
81#[derive(Clone, Debug, Eq, Facet, PartialEq)]
82#[facet(deny_unknown_fields)]
83pub struct Labels<L: 'static> {
84    /// Whether undeclared labels are deleted.
85    pub mode: Mode,
86    /// The desired labels, keyed by name.
87    pub labels: BTreeMap<String, L>,
88}
89
90#[cfg(test)]
91mod tests {
92    use super::{Label, LabelColor, Labels};
93
94    #[test]
95    fn colors_are_six_hex_digits_in_lowercase() {
96        let color = LabelColor::try_from("D73A4A".to_owned()).expect("valid");
97        assert_eq!(color.as_str(), "d73a4a");
98        assert!(color.matches("#D73A4A"));
99        for bad in ["", "fff", "#d73a4a", "d73a4g", "d73a4a0"] {
100            LabelColor::try_from(bad.to_owned()).expect_err(bad);
101        }
102    }
103
104    #[test]
105    fn labels_decode_and_reject_unknown_keys() {
106        let labels: Labels<Label> = facet_json::from_str(
107            r#"{"mode":"owned","labels":{"bug":{"color":"d73a4a","description":null}}}"#,
108        )
109        .expect("decodes");
110        assert_eq!(labels.labels.len(), 1);
111        facet_json::from_str::<Labels<Label>>(
112            r#"{"mode":"owned","labels":{"bug":{"color":"d73a4a","description":null,"x":1}}}"#,
113        )
114        .expect_err("unknown key");
115    }
116}