Skip to main content

badge_maker_rs/
badge_color.rs

1use std::{fmt, str::FromStr};
2
3use csscolorparser::Color as CssColor;
4
5/// Strongly-typed badge color input.
6///
7/// The common path is `"brightgreen".parse::<Color>()`. [`Color::Literal`]
8/// remains available as a low-level compatibility escape hatch.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub enum Color {
11    /// One of the documented Shields named or semantic colors.
12    Named(NamedColor),
13    /// A hexadecimal color literal such as `#4c1` or `ABC123`.
14    Hex(String),
15    /// A CSS color literal such as `rgb(...)`, `hsl(...)`, or `papayawhip`.
16    Css(String),
17    /// Low-level escape hatch for raw color input compatibility.
18    Literal(String),
19}
20
21impl Color {
22    /// Creates a low-level raw color literal.
23    pub fn literal(value: impl Into<String>) -> Self {
24        Self::Literal(value.into())
25    }
26
27    pub(crate) fn to_svg_color(&self) -> Option<String> {
28        match self {
29            Self::Named(named) => Some(named.to_svg_color().to_owned()),
30            Self::Hex(value) => normalize_hex_color(value),
31            Self::Css(value) => normalize_css_color(value),
32            Self::Literal(value) => normalize_literal_color(value),
33        }
34    }
35}
36
37impl FromStr for Color {
38    type Err = ParseColorError;
39
40    fn from_str(value: &str) -> Result<Self, Self::Err> {
41        if let Some(named) = normalize_named_color(value) {
42            return Ok(Self::Named(named));
43        }
44        if is_hex_color(value) {
45            return Ok(Self::Hex(value.trim().to_owned()));
46        }
47        if normalize_css_color(value).is_some() {
48            return Ok(Self::Css(value.trim().to_owned()));
49        }
50        Err(ParseColorError)
51    }
52}
53
54impl From<NamedColor> for Color {
55    fn from(value: NamedColor) -> Self {
56        Self::Named(value)
57    }
58}
59
60/// Failed to parse a color string into [`Color`].
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct ParseColorError;
63
64impl fmt::Display for ParseColorError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str("invalid badge color: expected a named color, #rgb/#rrggbb, or a CSS color")
67    }
68}
69
70impl std::error::Error for ParseColorError {}
71
72/// Documented Shields named colors and semantic aliases.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum NamedColor {
75    Brightgreen,
76    Green,
77    Yellow,
78    Yellowgreen,
79    Orange,
80    Red,
81    Blue,
82    Grey,
83    Gray,
84    Lightgrey,
85    Lightgray,
86    Success,
87    Important,
88    Critical,
89    Informational,
90    Inactive,
91}
92
93impl NamedColor {
94    /// Returns the public-facing Shields color name for this variant.
95    pub const fn as_str(self) -> &'static str {
96        match self {
97            Self::Brightgreen => "brightgreen",
98            Self::Green => "green",
99            Self::Yellow => "yellow",
100            Self::Yellowgreen => "yellowgreen",
101            Self::Orange => "orange",
102            Self::Red => "red",
103            Self::Blue => "blue",
104            Self::Grey => "grey",
105            Self::Gray => "gray",
106            Self::Lightgrey => "lightgrey",
107            Self::Lightgray => "lightgray",
108            Self::Success => "success",
109            Self::Important => "important",
110            Self::Critical => "critical",
111            Self::Informational => "informational",
112            Self::Inactive => "inactive",
113        }
114    }
115
116    pub(crate) const fn to_svg_color(self) -> &'static str {
117        match self {
118            Self::Brightgreen | Self::Success => "#4c1",
119            Self::Green => "#97ca00",
120            Self::Yellow => "#dfb317",
121            Self::Yellowgreen => "#a4a61d",
122            Self::Orange | Self::Important => "#fe7d37",
123            Self::Red | Self::Critical => "#e05d44",
124            Self::Blue | Self::Informational => "#007ec6",
125            Self::Grey | Self::Gray => "#555",
126            Self::Lightgrey | Self::Lightgray | Self::Inactive => "#9f9f9f",
127        }
128    }
129}
130
131impl fmt::Display for NamedColor {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137impl FromStr for NamedColor {
138    type Err = ParseColorError;
139
140    fn from_str(value: &str) -> Result<Self, Self::Err> {
141        normalize_named_color(value).ok_or(ParseColorError)
142    }
143}
144
145fn normalize_literal_color(value: &str) -> Option<String> {
146    normalize_named_color(value)
147        .map(|named| named.to_svg_color().to_owned())
148        .or_else(|| normalize_literal_hex_color(value))
149        .or_else(|| normalize_literal_css_color(value))
150}
151
152fn normalize_hex_color(value: &str) -> Option<String> {
153    if is_hex_color(value) {
154        Some(format!(
155            "#{}",
156            value.trim().trim_start_matches('#').to_ascii_lowercase()
157        ))
158    } else {
159        None
160    }
161}
162
163fn normalize_literal_hex_color(value: &str) -> Option<String> {
164    if is_literal_hex_color(value) {
165        Some(format!(
166            "#{}",
167            value.trim_start_matches('#').to_ascii_lowercase()
168        ))
169    } else {
170        None
171    }
172}
173
174fn normalize_css_color(value: &str) -> Option<String> {
175    let trimmed = value.trim();
176    if trimmed.parse::<CssColor>().is_ok() {
177        Some(trimmed.to_ascii_lowercase())
178    } else {
179        None
180    }
181}
182
183fn normalize_literal_css_color(value: &str) -> Option<String> {
184    let trimmed = value.trim();
185
186    if trimmed.eq_ignore_ascii_case("transparent") {
187        return None;
188    }
189    if trimmed != trimmed.to_ascii_lowercase() {
190        return None;
191    }
192
193    if trimmed.parse::<CssColor>().is_ok() {
194        Some(trimmed.to_owned())
195    } else {
196        None
197    }
198}
199
200fn normalize_named_color(value: &str) -> Option<NamedColor> {
201    match value.trim().to_ascii_lowercase().as_str() {
202        "brightgreen" => Some(NamedColor::Brightgreen),
203        "green" => Some(NamedColor::Green),
204        "yellow" => Some(NamedColor::Yellow),
205        "yellowgreen" => Some(NamedColor::Yellowgreen),
206        "orange" => Some(NamedColor::Orange),
207        "red" => Some(NamedColor::Red),
208        "blue" => Some(NamedColor::Blue),
209        "grey" => Some(NamedColor::Grey),
210        "gray" => Some(NamedColor::Gray),
211        "lightgrey" => Some(NamedColor::Lightgrey),
212        "lightgray" => Some(NamedColor::Lightgray),
213        "success" => Some(NamedColor::Success),
214        "important" => Some(NamedColor::Important),
215        "critical" => Some(NamedColor::Critical),
216        "informational" => Some(NamedColor::Informational),
217        "inactive" => Some(NamedColor::Inactive),
218        _ => None,
219    }
220}
221
222fn is_hex_color(input: &str) -> bool {
223    let raw = input.trim().trim_start_matches('#');
224    matches!(raw.len(), 3 | 6) && raw.bytes().all(|byte| byte.is_ascii_hexdigit())
225}
226
227fn is_literal_hex_color(input: &str) -> bool {
228    let raw = input.strip_prefix('#').unwrap_or(input);
229    matches!(raw.len(), 3 | 6)
230        && !raw.starts_with('#')
231        && raw.bytes().all(|byte| byte.is_ascii_hexdigit())
232}