1use std::collections::BTreeMap;
16
17use serde::Deserialize;
18
19#[derive(Debug, Clone, Deserialize)]
21pub struct PaletteEntry {
22 #[serde(default)]
23 pub matchers: Vec<Matcher>,
24 #[serde(default)]
25 pub style: Style,
26}
27
28#[derive(Debug, Clone, Default, Deserialize)]
30pub struct Matcher {
31 #[serde(default)]
32 pub status: Option<String>,
33 #[serde(default, rename = "type")]
34 pub doc_type: Option<String>,
35 #[serde(default)]
37 pub tag: Option<String>,
38}
39
40#[derive(Debug, Clone, Default, Deserialize)]
42pub struct Style {
43 #[serde(default)]
44 pub fg_color: Option<String>,
45 #[serde(default)]
46 pub bg_color: Option<String>,
47 #[serde(default)]
48 pub icon: Option<String>,
49 #[serde(default)]
50 pub bold: Option<bool>,
51 #[serde(default)]
52 pub italic: Option<bool>,
53 #[serde(default)]
54 pub strikethrough: Option<bool>,
55}
56
57impl Style {
58 fn overlay(&mut self, other: &Style) {
60 if other.fg_color.is_some() {
61 self.fg_color = other.fg_color.clone();
62 }
63 if other.bg_color.is_some() {
64 self.bg_color = other.bg_color.clone();
65 }
66 if other.icon.is_some() {
67 self.icon = other.icon.clone();
68 }
69 if other.bold.is_some() {
70 self.bold = other.bold;
71 }
72 if other.italic.is_some() {
73 self.italic = other.italic;
74 }
75 if other.strikethrough.is_some() {
76 self.strikethrough = other.strikethrough;
77 }
78 }
79}
80
81impl Matcher {
82 fn specificity(
87 &self,
88 doc_type: Option<&str>,
89 status: Option<&str>,
90 has_tag: &dyn Fn(&str) -> bool,
91 ) -> Option<usize> {
92 let mut spec = 0;
93 if let Some(want) = &self.doc_type {
94 if Some(want.as_str()) != doc_type {
95 return None;
96 }
97 spec += 1;
98 }
99 if let Some(want) = &self.status {
100 if Some(want.as_str()) != status {
101 return None;
102 }
103 spec += 1;
104 }
105 if let Some(want) = &self.tag {
106 if !has_tag(want) {
107 return None;
108 }
109 spec += 1;
110 }
111 Some(spec)
112 }
113}
114
115impl PaletteEntry {
116 fn specificity(
119 &self,
120 doc_type: Option<&str>,
121 status: Option<&str>,
122 has_tag: &dyn Fn(&str) -> bool,
123 ) -> Option<usize> {
124 self.matchers
125 .iter()
126 .filter_map(|m| m.specificity(doc_type, status, has_tag))
127 .max()
128 }
129}
130
131pub fn resolve(
134 palette: &BTreeMap<String, PaletteEntry>,
135 doc_type: Option<&str>,
136 status: Option<&str>,
137 has_tag: &dyn Fn(&str) -> bool,
138) -> Style {
139 let mut matched: Vec<(usize, &str, &Style)> = palette
141 .iter()
142 .filter_map(|(name, entry)| {
143 entry
144 .specificity(doc_type, status, has_tag)
145 .map(|spec| (spec, name.as_str(), &entry.style))
146 })
147 .collect();
148 matched.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
150
151 let mut out = Style::default();
152 for (_, _, style) in matched {
153 out.overlay(style);
154 }
155 out
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ColorSpec {
161 Rgb(u8, u8, u8),
162 Indexed(u8),
163 Named(NamedColor),
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum NamedColor {
169 Black,
170 Red,
171 Green,
172 Yellow,
173 Blue,
174 Magenta,
175 Cyan,
176 Gray,
177 DarkGray,
178 LightRed,
179 LightGreen,
180 LightYellow,
181 LightBlue,
182 LightMagenta,
183 LightCyan,
184 White,
185}
186
187pub fn parse_color(s: &str) -> Option<ColorSpec> {
190 let s = s.trim();
191 if let Some(hex) = s.strip_prefix('#') {
192 return parse_hex(hex);
193 }
194 if let Ok(idx) = s.parse::<u8>() {
195 return Some(ColorSpec::Indexed(idx));
196 }
197 let named = match s.to_ascii_lowercase().as_str() {
198 "black" => NamedColor::Black,
199 "red" => NamedColor::Red,
200 "green" => NamedColor::Green,
201 "yellow" => NamedColor::Yellow,
202 "blue" => NamedColor::Blue,
203 "magenta" => NamedColor::Magenta,
204 "cyan" => NamedColor::Cyan,
205 "gray" | "grey" => NamedColor::Gray,
206 "darkgray" | "darkgrey" => NamedColor::DarkGray,
207 "lightred" => NamedColor::LightRed,
208 "lightgreen" => NamedColor::LightGreen,
209 "lightyellow" => NamedColor::LightYellow,
210 "lightblue" => NamedColor::LightBlue,
211 "lightmagenta" => NamedColor::LightMagenta,
212 "lightcyan" => NamedColor::LightCyan,
213 "white" => NamedColor::White,
214 _ => return None,
215 };
216 Some(ColorSpec::Named(named))
217}
218
219fn parse_hex(hex: &str) -> Option<ColorSpec> {
220 let bytes = match hex.len() {
221 3 => {
222 let mut full = String::with_capacity(6);
223 for c in hex.chars() {
224 full.push(c);
225 full.push(c);
226 }
227 return parse_hex(&full);
228 }
229 6 => hex,
230 _ => return None,
231 };
232 let r = u8::from_str_radix(&bytes[0..2], 16).ok()?;
233 let g = u8::from_str_radix(&bytes[2..4], 16).ok()?;
234 let b = u8::from_str_radix(&bytes[4..6], 16).ok()?;
235 Some(ColorSpec::Rgb(r, g, b))
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn palette(toml: &str) -> BTreeMap<String, PaletteEntry> {
243 #[derive(Deserialize)]
244 struct Wrap {
245 #[serde(default)]
246 palette: BTreeMap<String, PaletteEntry>,
247 }
248 toml::from_str::<Wrap>(toml).unwrap().palette
249 }
250
251 #[test]
252 fn parses_colors() {
253 assert_eq!(parse_color("red"), Some(ColorSpec::Named(NamedColor::Red)));
254 assert_eq!(
255 parse_color("GREY"),
256 Some(ColorSpec::Named(NamedColor::Gray))
257 );
258 assert_eq!(parse_color("#fff"), Some(ColorSpec::Rgb(255, 255, 255)));
259 assert_eq!(parse_color("#112233"), Some(ColorSpec::Rgb(17, 34, 51)));
260 assert_eq!(parse_color("200"), Some(ColorSpec::Indexed(200)));
261 assert_eq!(parse_color("notacolor"), None);
262 assert_eq!(parse_color("#12"), None);
263 assert_eq!(parse_color("300"), None); }
265
266 #[test]
267 fn specificity_merge_prefers_more_specific() {
268 let p = palette(
269 "[palette.bug]\nmatchers = [ { type = \"bug\" } ]\n[palette.bug.style]\nicon = \"B\"\nfg_color = \"blue\"\n\
270[palette.blocked]\nmatchers = [ { status = \"blocked\" } ]\n[palette.blocked.style]\nfg_color = \"red\"\nbold = true\n\
271[palette.bug-blocked]\nmatchers = [ { type = \"bug\", status = \"blocked\" } ]\n[palette.bug-blocked.style]\nfg_color = \"magenta\"\n",
272 );
273 let style = resolve(&p, Some("bug"), Some("blocked"), &no_tags);
276 assert_eq!(style.icon.as_deref(), Some("B"));
277 assert_eq!(style.bold, Some(true));
278 assert_eq!(style.fg_color.as_deref(), Some("magenta"));
279 }
280
281 #[test]
282 fn empty_matcher_matches_everything() {
283 let p = palette(
284 "[palette.base]\nmatchers = [ {} ]\n[palette.base.style]\nfg_color = \"gray\"\n",
285 );
286 let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
287 assert_eq!(style.fg_color.as_deref(), Some("gray"));
288 }
289
290 #[test]
291 fn non_matching_doc_gets_empty_style() {
292 let p = palette(
293 "[palette.bug]\nmatchers = [ { type = \"bug\" } ]\n[palette.bug.style]\nicon = \"B\"\n",
294 );
295 let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
296 assert!(style.icon.is_none());
297 }
298
299 #[test]
300 fn tag_matcher_colors_by_tag_key() {
301 let p = palette(
302 "[palette.area]\nmatchers = [ { tag = \"area\" } ]\n[palette.area.style]\nfg_color = \"cyan\"\n",
303 );
304 let has = |t: &str| t == "area";
306 let style = resolve(&p, Some("feature"), Some("planned"), &has);
307 assert_eq!(style.fg_color.as_deref(), Some("cyan"));
308 let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
310 assert!(style.fg_color.is_none());
311 }
312
313 fn no_tags(_: &str) -> bool {
314 false
315 }
316}