use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct PaletteEntry {
#[serde(default)]
pub matchers: Vec<Matcher>,
#[serde(default)]
pub style: Style,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Matcher {
#[serde(default)]
pub status: Option<String>,
#[serde(default, rename = "type")]
pub doc_type: Option<String>,
#[serde(default)]
pub tag: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Style {
#[serde(default)]
pub fg_color: Option<String>,
#[serde(default)]
pub bg_color: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub bold: Option<bool>,
#[serde(default)]
pub italic: Option<bool>,
#[serde(default)]
pub strikethrough: Option<bool>,
}
impl Style {
fn overlay(&mut self, other: &Style) {
if other.fg_color.is_some() {
self.fg_color = other.fg_color.clone();
}
if other.bg_color.is_some() {
self.bg_color = other.bg_color.clone();
}
if other.icon.is_some() {
self.icon = other.icon.clone();
}
if other.bold.is_some() {
self.bold = other.bold;
}
if other.italic.is_some() {
self.italic = other.italic;
}
if other.strikethrough.is_some() {
self.strikethrough = other.strikethrough;
}
}
}
impl Matcher {
fn specificity(
&self,
doc_type: Option<&str>,
status: Option<&str>,
has_tag: &dyn Fn(&str) -> bool,
) -> Option<usize> {
let mut spec = 0;
if let Some(want) = &self.doc_type {
if Some(want.as_str()) != doc_type {
return None;
}
spec += 1;
}
if let Some(want) = &self.status {
if Some(want.as_str()) != status {
return None;
}
spec += 1;
}
if let Some(want) = &self.tag {
if !has_tag(want) {
return None;
}
spec += 1;
}
Some(spec)
}
}
impl PaletteEntry {
fn specificity(
&self,
doc_type: Option<&str>,
status: Option<&str>,
has_tag: &dyn Fn(&str) -> bool,
) -> Option<usize> {
self.matchers
.iter()
.filter_map(|m| m.specificity(doc_type, status, has_tag))
.max()
}
}
pub fn resolve(
palette: &BTreeMap<String, PaletteEntry>,
doc_type: Option<&str>,
status: Option<&str>,
has_tag: &dyn Fn(&str) -> bool,
) -> Style {
let mut matched: Vec<(usize, &str, &Style)> = palette
.iter()
.filter_map(|(name, entry)| {
entry
.specificity(doc_type, status, has_tag)
.map(|spec| (spec, name.as_str(), &entry.style))
})
.collect();
matched.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
let mut out = Style::default();
for (_, _, style) in matched {
out.overlay(style);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSpec {
Rgb(u8, u8, u8),
Indexed(u8),
Named(NamedColor),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NamedColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
Gray,
DarkGray,
LightRed,
LightGreen,
LightYellow,
LightBlue,
LightMagenta,
LightCyan,
White,
}
pub fn parse_color(s: &str) -> Option<ColorSpec> {
let s = s.trim();
if let Some(hex) = s.strip_prefix('#') {
return parse_hex(hex);
}
if let Ok(idx) = s.parse::<u8>() {
return Some(ColorSpec::Indexed(idx));
}
let named = match s.to_ascii_lowercase().as_str() {
"black" => NamedColor::Black,
"red" => NamedColor::Red,
"green" => NamedColor::Green,
"yellow" => NamedColor::Yellow,
"blue" => NamedColor::Blue,
"magenta" => NamedColor::Magenta,
"cyan" => NamedColor::Cyan,
"gray" | "grey" => NamedColor::Gray,
"darkgray" | "darkgrey" => NamedColor::DarkGray,
"lightred" => NamedColor::LightRed,
"lightgreen" => NamedColor::LightGreen,
"lightyellow" => NamedColor::LightYellow,
"lightblue" => NamedColor::LightBlue,
"lightmagenta" => NamedColor::LightMagenta,
"lightcyan" => NamedColor::LightCyan,
"white" => NamedColor::White,
_ => return None,
};
Some(ColorSpec::Named(named))
}
fn parse_hex(hex: &str) -> Option<ColorSpec> {
let bytes = match hex.len() {
3 => {
let mut full = String::with_capacity(6);
for c in hex.chars() {
full.push(c);
full.push(c);
}
return parse_hex(&full);
}
6 => hex,
_ => return None,
};
let r = u8::from_str_radix(&bytes[0..2], 16).ok()?;
let g = u8::from_str_radix(&bytes[2..4], 16).ok()?;
let b = u8::from_str_radix(&bytes[4..6], 16).ok()?;
Some(ColorSpec::Rgb(r, g, b))
}
#[cfg(test)]
mod tests {
use super::*;
fn palette(toml: &str) -> BTreeMap<String, PaletteEntry> {
#[derive(Deserialize)]
struct Wrap {
#[serde(default)]
palette: BTreeMap<String, PaletteEntry>,
}
toml::from_str::<Wrap>(toml).unwrap().palette
}
#[test]
fn parses_colors() {
assert_eq!(parse_color("red"), Some(ColorSpec::Named(NamedColor::Red)));
assert_eq!(
parse_color("GREY"),
Some(ColorSpec::Named(NamedColor::Gray))
);
assert_eq!(parse_color("#fff"), Some(ColorSpec::Rgb(255, 255, 255)));
assert_eq!(parse_color("#112233"), Some(ColorSpec::Rgb(17, 34, 51)));
assert_eq!(parse_color("200"), Some(ColorSpec::Indexed(200)));
assert_eq!(parse_color("notacolor"), None);
assert_eq!(parse_color("#12"), None);
assert_eq!(parse_color("300"), None); }
#[test]
fn specificity_merge_prefers_more_specific() {
let p = palette(
"[palette.bug]\nmatchers = [ { type = \"bug\" } ]\n[palette.bug.style]\nicon = \"B\"\nfg_color = \"blue\"\n\
[palette.blocked]\nmatchers = [ { status = \"blocked\" } ]\n[palette.blocked.style]\nfg_color = \"red\"\nbold = true\n\
[palette.bug-blocked]\nmatchers = [ { type = \"bug\", status = \"blocked\" } ]\n[palette.bug-blocked.style]\nfg_color = \"magenta\"\n",
);
let style = resolve(&p, Some("bug"), Some("blocked"), &no_tags);
assert_eq!(style.icon.as_deref(), Some("B"));
assert_eq!(style.bold, Some(true));
assert_eq!(style.fg_color.as_deref(), Some("magenta"));
}
#[test]
fn empty_matcher_matches_everything() {
let p = palette(
"[palette.base]\nmatchers = [ {} ]\n[palette.base.style]\nfg_color = \"gray\"\n",
);
let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
assert_eq!(style.fg_color.as_deref(), Some("gray"));
}
#[test]
fn non_matching_doc_gets_empty_style() {
let p = palette(
"[palette.bug]\nmatchers = [ { type = \"bug\" } ]\n[palette.bug.style]\nicon = \"B\"\n",
);
let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
assert!(style.icon.is_none());
}
#[test]
fn tag_matcher_colors_by_tag_key() {
let p = palette(
"[palette.area]\nmatchers = [ { tag = \"area\" } ]\n[palette.area.style]\nfg_color = \"cyan\"\n",
);
let has = |t: &str| t == "area";
let style = resolve(&p, Some("feature"), Some("planned"), &has);
assert_eq!(style.fg_color.as_deref(), Some("cyan"));
let style = resolve(&p, Some("feature"), Some("planned"), &no_tags);
assert!(style.fg_color.is_none());
}
fn no_tags(_: &str) -> bool {
false
}
}