1use std::{fmt, str::FromStr};
2
3use csscolorparser::Color as CssColor;
4
5#[derive(Clone, Debug, Eq, PartialEq)]
10pub enum Color {
11 Named(NamedColor),
13 Hex(String),
15 Css(String),
17 Literal(String),
19}
20
21impl Color {
22 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#[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#[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 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}