use crossterm::style::Color;
use super::ACTIVE_INPUT_CT;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Role {
Accent,
Text,
Dim,
Muted,
Emphasis,
Added,
AddedBackground,
Removed,
RemovedBackground,
Thinking,
Narration,
CommandBackground,
CommandBackgroundInactive,
CommandBang,
CommandEx,
GaugeOk,
GaugeWarn,
GaugeCritical,
SelectedLabel,
SelectedValue,
ActiveModel,
LoadedModel,
Ok,
Identity,
ModalBorder,
HumanText,
AgentText,
MarkdownHeading,
InlineCode,
MarkdownStrong,
MarkdownItalic,
Spill,
MarkdownLink,
MarkdownStrike,
ModalTitle,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Theme {
pub name: String,
attributes: std::collections::BTreeMap<Role, crossterm::style::Attributes>,
accent: Color,
text: Color,
dim: Color,
muted: Color,
emphasis: Color,
added: Color,
added_background: Color,
removed: Color,
removed_background: Color,
thinking: Color,
narration: Color,
command_background: Color,
command_background_inactive: Color,
command_bang: Color,
command_ex: Color,
gauge_ok: Color,
gauge_warn: Color,
gauge_critical: Color,
selected_label: Color,
selected_value: Color,
active_model: Color,
loaded_model: Color,
ok: Color,
identity: Color,
modal_border: Color,
human_text: Color,
agent_text: Color,
markdown_heading: Color,
inline_code: Color,
markdown_strong: Color,
markdown_italic: Color,
spill: Color,
markdown_link: Color,
markdown_strike: Color,
modal_title: Color,
}
impl Theme {
pub fn builtin() -> Self {
Self {
human_text: Color::Cyan,
agent_text: Color::Grey,
markdown_heading: Color::Blue,
inline_code: Color::White,
markdown_strong: Color::White,
markdown_italic: Color::Grey,
spill: Color::DarkGrey,
markdown_link: Color::Grey,
markdown_strike: Color::Grey,
name: "newt".to_string(),
attributes: Default::default(),
accent: ACTIVE_INPUT_CT,
text: Color::White,
dim: Color::DarkGrey,
muted: Color::Grey,
emphasis: Color::Yellow,
added: Color::Green,
added_background: Color::Rgb {
r: 24,
g: 56,
b: 37,
},
removed: Color::Red,
removed_background: Color::Rgb {
r: 65,
g: 29,
b: 32,
},
thinking: Color::DarkGrey,
narration: Color::Magenta,
command_background: Color::Rgb {
r: 82,
g: 82,
b: 82,
},
command_background_inactive: Color::Rgb {
r: 45,
g: 45,
b: 45,
},
command_bang: Color::Magenta,
command_ex: Color::Yellow,
gauge_ok: Color::DarkGreen,
gauge_warn: Color::Rgb {
r: 200,
g: 140,
b: 0,
},
gauge_critical: Color::DarkRed,
selected_label: Color::DarkCyan,
selected_value: Color::DarkYellow,
active_model: Color::Cyan,
loaded_model: Color::DarkMagenta,
ok: Color::DarkGreen,
identity: Color::DarkMagenta,
modal_border: ACTIVE_INPUT_CT,
modal_title: Color::Yellow,
}
}
pub fn color(&self, role: Role) -> Color {
match role {
Role::Accent => self.accent,
Role::Text => self.text,
Role::Dim => self.dim,
Role::Muted => self.muted,
Role::Emphasis => self.emphasis,
Role::Added => self.added,
Role::AddedBackground => self.added_background,
Role::Removed => self.removed,
Role::RemovedBackground => self.removed_background,
Role::Thinking => self.thinking,
Role::Narration => self.narration,
Role::CommandBackground => self.command_background,
Role::CommandBackgroundInactive => self.command_background_inactive,
Role::CommandBang => self.command_bang,
Role::CommandEx => self.command_ex,
Role::GaugeOk => self.gauge_ok,
Role::GaugeWarn => self.gauge_warn,
Role::GaugeCritical => self.gauge_critical,
Role::SelectedLabel => self.selected_label,
Role::SelectedValue => self.selected_value,
Role::ActiveModel => self.active_model,
Role::LoadedModel => self.loaded_model,
Role::Ok => self.ok,
Role::Identity => self.identity,
Role::ModalBorder => self.modal_border,
Role::HumanText => self.human_text,
Role::AgentText => self.agent_text,
Role::MarkdownHeading => self.markdown_heading,
Role::InlineCode => self.inline_code,
Role::MarkdownStrong => self.markdown_strong,
Role::MarkdownItalic => self.markdown_italic,
Role::Spill => self.spill,
Role::MarkdownLink => self.markdown_link,
Role::MarkdownStrike => self.markdown_strike,
Role::ModalTitle => self.modal_title,
}
}
pub fn style(&self, role: Role) -> crossterm::style::ContentStyle {
use crossterm::style::{Attribute, Attributes, ContentStyle};
let attributes = self.attributes.get(&role).copied().unwrap_or_else(|| {
let mut a = Attributes::default();
match role {
Role::Ok
| Role::Identity
| Role::MarkdownHeading
| Role::MarkdownStrong
| Role::InlineCode
| Role::SelectedLabel
| Role::SelectedValue
| Role::ModalTitle => a.set(Attribute::Bold),
Role::MarkdownLink => a.set(Attribute::Underlined),
Role::MarkdownStrike => a.set(Attribute::CrossedOut),
Role::MarkdownItalic => a.set(Attribute::Italic),
_ => {}
}
a
});
ContentStyle {
foreground_color: Some(self.color(role)),
attributes,
..ContentStyle::default()
}
}
pub fn ansi(&self, role: Role) -> String {
ansi_style(self.style(role))
}
pub fn set_style(&mut self, role: Role, style: crossterm::style::ContentStyle) {
if let Some(color) = style.foreground_color {
*self = self.clone().overlaid(&self.name.clone(), &[(role, color)]);
}
self.attributes.insert(role, style.attributes);
}
pub fn overlaid(mut self, name: &str, overrides: &[(Role, Color)]) -> Self {
self.name = name.to_string();
for (role, color) in overrides {
match role {
Role::Accent => self.accent = *color,
Role::Text => self.text = *color,
Role::Dim => self.dim = *color,
Role::Muted => self.muted = *color,
Role::Emphasis => self.emphasis = *color,
Role::Added => self.added = *color,
Role::AddedBackground => self.added_background = *color,
Role::Removed => self.removed = *color,
Role::RemovedBackground => self.removed_background = *color,
Role::Thinking => self.thinking = *color,
Role::Narration => self.narration = *color,
Role::CommandBackground => self.command_background = *color,
Role::CommandBackgroundInactive => self.command_background_inactive = *color,
Role::CommandBang => self.command_bang = *color,
Role::CommandEx => self.command_ex = *color,
Role::GaugeOk => self.gauge_ok = *color,
Role::GaugeWarn => self.gauge_warn = *color,
Role::GaugeCritical => self.gauge_critical = *color,
Role::SelectedLabel => self.selected_label = *color,
Role::SelectedValue => self.selected_value = *color,
Role::ActiveModel => self.active_model = *color,
Role::LoadedModel => self.loaded_model = *color,
Role::Ok => self.ok = *color,
Role::Identity => self.identity = *color,
Role::ModalBorder => self.modal_border = *color,
Role::HumanText => self.human_text = *color,
Role::AgentText => self.agent_text = *color,
Role::MarkdownHeading => self.markdown_heading = *color,
Role::InlineCode => self.inline_code = *color,
Role::MarkdownStrong => self.markdown_strong = *color,
Role::MarkdownItalic => self.markdown_italic = *color,
Role::Spill => self.spill = *color,
Role::MarkdownLink => self.markdown_link = *color,
Role::MarkdownStrike => self.markdown_strike = *color,
Role::ModalTitle => self.modal_title = *color,
}
}
self
}
}
pub fn ansi_color(color: Color) -> String {
let index = match color {
Color::Reset => return "\x1b[39m".into(),
Color::Rgb { r, g, b } => return format!("\x1b[38;2;{r};{g};{b}m"),
Color::AnsiValue(n) => return format!("\x1b[38;5;{n}m"),
Color::Black => 0,
Color::DarkRed => 1,
Color::DarkGreen => 2,
Color::DarkYellow => 3,
Color::DarkBlue => 4,
Color::DarkMagenta => 5,
Color::DarkCyan => 6,
Color::Grey => 7,
Color::DarkGrey => 8,
Color::Red => 9,
Color::Green => 10,
Color::Yellow => 11,
Color::Blue => 12,
Color::Magenta => 13,
Color::Cyan => 14,
Color::White => 15,
};
format!("\x1b[38;5;{index}m")
}
pub fn ansi_style(style: crossterm::style::ContentStyle) -> String {
use crossterm::style::{Attribute, SetAttribute};
let mut text = String::new();
for attr in [
Attribute::Bold,
Attribute::Dim,
Attribute::Italic,
Attribute::Underlined,
Attribute::Reverse,
Attribute::CrossedOut,
] {
if style.attributes.has(attr) {
text.push_str(&format!("{}", SetAttribute(attr)));
}
}
if let Some(color) = style.foreground_color {
text.push_str(&ansi_color(color));
}
text
}
pub fn role_from_name(name: &str) -> Option<Role> {
Some(match name.trim().to_ascii_lowercase().as_str() {
"accent" => Role::Accent,
"text" => Role::Text,
"dim" => Role::Dim,
"muted" => Role::Muted,
"emphasis" => Role::Emphasis,
"added" => Role::Added,
"added-background" => Role::AddedBackground,
"removed" => Role::Removed,
"removed-background" => Role::RemovedBackground,
"thinking" => Role::Thinking,
"narration" => Role::Narration,
"command-background" => Role::CommandBackground,
"command-background-inactive" => Role::CommandBackgroundInactive,
"command-bang" => Role::CommandBang,
"command-ex" => Role::CommandEx,
"gauge-ok" => Role::GaugeOk,
"gauge-warn" => Role::GaugeWarn,
"gauge-critical" => Role::GaugeCritical,
"selected-label" => Role::SelectedLabel,
"selected-value" => Role::SelectedValue,
"active-model" => Role::ActiveModel,
"loaded-model" => Role::LoadedModel,
"ok" => Role::Ok,
"identity" => Role::Identity,
"modal-border" => Role::ModalBorder,
"human-text" => Role::HumanText,
"agent-text" => Role::AgentText,
"markdown-heading" => Role::MarkdownHeading,
"inline-code" => Role::InlineCode,
"markdown-strong" => Role::MarkdownStrong,
"markdown-italic" => Role::MarkdownItalic,
"spill" => Role::Spill,
"markdown-link" => Role::MarkdownLink,
"markdown-strike" => Role::MarkdownStrike,
"modal-title" => Role::ModalTitle,
_ => return None,
})
}
pub const ALL_ROLES: &[Role] = &[
Role::Accent,
Role::Text,
Role::Dim,
Role::Muted,
Role::Emphasis,
Role::Added,
Role::AddedBackground,
Role::Removed,
Role::RemovedBackground,
Role::Thinking,
Role::Narration,
Role::CommandBackground,
Role::CommandBackgroundInactive,
Role::CommandBang,
Role::CommandEx,
Role::GaugeOk,
Role::GaugeWarn,
Role::GaugeCritical,
Role::SelectedLabel,
Role::SelectedValue,
Role::ActiveModel,
Role::LoadedModel,
Role::Ok,
Role::Identity,
Role::ModalBorder,
Role::HumanText,
Role::AgentText,
Role::MarkdownHeading,
Role::InlineCode,
Role::MarkdownStrong,
Role::MarkdownItalic,
Role::Spill,
Role::MarkdownLink,
Role::MarkdownStrike,
Role::ModalTitle,
];
pub fn role_name(role: Role) -> &'static str {
match role {
Role::Accent => "accent",
Role::Text => "text",
Role::Dim => "dim",
Role::Muted => "muted",
Role::Emphasis => "emphasis",
Role::Added => "added",
Role::AddedBackground => "added-background",
Role::Removed => "removed",
Role::RemovedBackground => "removed-background",
Role::Thinking => "thinking",
Role::Narration => "narration",
Role::CommandBackground => "command-background",
Role::CommandBackgroundInactive => "command-background-inactive",
Role::CommandBang => "command-bang",
Role::CommandEx => "command-ex",
Role::GaugeOk => "gauge-ok",
Role::GaugeWarn => "gauge-warn",
Role::GaugeCritical => "gauge-critical",
Role::SelectedLabel => "selected-label",
Role::SelectedValue => "selected-value",
Role::ActiveModel => "active-model",
Role::LoadedModel => "loaded-model",
Role::Ok => "ok",
Role::Identity => "identity",
Role::ModalBorder => "modal-border",
Role::HumanText => "human-text",
Role::AgentText => "agent-text",
Role::MarkdownHeading => "markdown-heading",
Role::InlineCode => "inline-code",
Role::MarkdownStrong => "markdown-strong",
Role::MarkdownItalic => "markdown-italic",
Role::Spill => "spill",
Role::MarkdownLink => "markdown-link",
Role::MarkdownStrike => "markdown-strike",
Role::ModalTitle => "modal-title",
}
}
pub fn parse_color(value: &str) -> Result<Color, String> {
let value = value.trim();
if let Some(hex) = value.strip_prefix('#') {
if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("`{value}` is not #rrggbb"));
}
let byte = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).unwrap_or(0);
return Ok(Color::Rgb {
r: byte(0),
g: byte(2),
b: byte(4),
});
}
if let Ok(index) = value.parse::<u8>() {
return Ok(Color::AnsiValue(index));
}
Ok(match value.to_ascii_lowercase().as_str() {
"default" => Color::Reset,
"black" => Color::Black,
"red" => Color::DarkRed,
"green" => Color::DarkGreen,
"yellow" => Color::DarkYellow,
"blue" => Color::DarkBlue,
"magenta" => Color::DarkMagenta,
"cyan" => Color::DarkCyan,
"gray" | "grey" => Color::Grey,
"dark-gray" | "dark-grey" => Color::DarkGrey,
"light-red" => Color::Red,
"light-green" => Color::Green,
"light-yellow" => Color::Yellow,
"light-blue" => Color::Blue,
"light-magenta" => Color::Magenta,
"light-cyan" => Color::Cyan,
"white" => Color::White,
other => {
return Err(format!(
"`{other}` is not a colour name, an ANSI index (0-255), or #rrggbb"
))
}
})
}
pub fn from_env(raw: Option<&str>) -> (Theme, Vec<String>) {
overlay_env(Theme::builtin(), raw)
}
fn overlay_env(base: Theme, raw: Option<&str>) -> (Theme, Vec<String>) {
let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
return (base, Vec::new());
};
let (mut overrides, mut complaints) = (Vec::new(), Vec::new());
for pair in raw.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let Some((name, value)) = pair.split_once('=') else {
complaints.push(format!("`{pair}` is not `role=colour`"));
continue;
};
let Some(role) = role_from_name(name) else {
let known = ALL_ROLES
.iter()
.map(|r| role_name(*r))
.collect::<Vec<_>>()
.join(", ");
complaints.push(format!(
"`{}` is not a theme role (have: {known})",
name.trim()
));
continue;
};
match parse_color(value) {
Ok(color) => overrides.push((role, color)),
Err(why) => complaints.push(format!("{}: {why}", name.trim())),
}
}
(base.overlaid("custom", &overrides), complaints)
}
pub fn active() -> std::sync::Arc<Theme> {
runtime().read().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn set_active(theme: Theme) {
*runtime().write().unwrap_or_else(|e| e.into_inner()) = std::sync::Arc::new(theme);
}
fn runtime() -> &'static std::sync::RwLock<std::sync::Arc<Theme>> {
static ACTIVE: std::sync::OnceLock<std::sync::RwLock<std::sync::Arc<Theme>>> =
std::sync::OnceLock::new();
ACTIVE.get_or_init(|| std::sync::RwLock::new(std::sync::Arc::new(resolved().0.clone())))
}
pub fn complaints() -> &'static [String] {
&resolved().1
}
fn resolved() -> &'static (Theme, Vec<String>) {
static THEME: std::sync::OnceLock<(Theme, Vec<String>)> = std::sync::OnceLock::new();
THEME.get_or_init(default_snapshot)
}
fn default_snapshot() -> (Theme, Vec<String>) {
from_env(std::env::var("NEWT_THEME").ok().as_deref())
}
pub fn restore_preferences() -> Vec<String> {
let (theme, warnings) = restored_theme();
set_active(theme);
warnings
}
fn restored_theme() -> (Theme, Vec<String>) {
let (persisted, warnings) = preferences::restore();
let (theme, _) = overlay_env(persisted, std::env::var("NEWT_THEME").ok().as_deref());
(theme, warnings)
}
pub mod preferences;
#[cfg(test)]
mod tests {
use super::*;
use crate::tty::NEWT_ORANGE_CT;
#[test]
fn env_overrides_preserve_other_saved_styles() {
use crossterm::style::Attribute;
let mut saved = Theme::builtin().overlaid(
"saved",
&[
(
Role::Thinking,
Color::Rgb {
r: 12,
g: 34,
b: 56,
},
),
(
Role::AgentText,
Color::Rgb {
r: 78,
g: 90,
b: 123,
},
),
],
);
let mut thinking = saved.style(Role::Thinking);
thinking.attributes.set(Attribute::Italic);
saved.set_style(Role::Thinking, thinking);
let (overlaid, warnings) = overlay_env(saved.clone(), Some("accent=cyan"));
assert!(warnings.is_empty());
assert_eq!(overlaid.color(Role::Accent), Color::DarkCyan);
assert_eq!(overlaid.style(Role::Thinking), saved.style(Role::Thinking));
assert_eq!(
overlaid.style(Role::AgentText),
saved.style(Role::AgentText)
);
}
#[test]
#[serial_test::serial(real_fs)]
fn a_persisted_theme_reaches_active_only_through_restore_preferences() {
let dir = tempfile::tempdir().expect("temp config dir");
std::fs::create_dir_all(dir.path().join("themes")).unwrap();
std::fs::write(
dir.path().join("themes/active.toml"),
"name = \"daylight\"\n[roles.agent-text]\ncolor = \"black\"\n",
)
.unwrap();
let prev = std::env::var_os(crate::config::NEWT_CONFIG_DIR_ENV);
std::env::set_var(crate::config::NEWT_CONFIG_DIR_ENV, dir.path());
let (default_snapshot, _) = default_snapshot();
let (restored, warnings) = restored_theme();
match prev {
Some(v) => std::env::set_var(crate::config::NEWT_CONFIG_DIR_ENV, v),
None => std::env::remove_var(crate::config::NEWT_CONFIG_DIR_ENV),
}
assert_ne!(
default_snapshot.name, "daylight",
"the default snapshot read the config dir"
);
assert_ne!(default_snapshot.color(Role::AgentText), Color::Black);
assert_eq!(restored.name, "daylight");
assert_eq!(restored.color(Role::AgentText), Color::Black);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn file_change_foregrounds_and_backgrounds_are_independently_tunable() {
let (theme, complaints) = from_env(Some(
"added=light-green,removed=light-red,added-background=#183825,removed-background=#411d20",
));
assert!(complaints.is_empty(), "{complaints:?}");
for (name, expected) in [
("added", Color::Green),
("removed", Color::Red),
(
"added-background",
Color::Rgb {
r: 24,
g: 56,
b: 37,
},
),
(
"removed-background",
Color::Rgb {
r: 65,
g: 29,
b: 32,
},
),
] {
assert_eq!(theme.color(role_from_name(name).unwrap()), expected);
}
assert_eq!(theme.color(Role::Text), Theme::builtin().color(Role::Text));
}
#[test]
fn the_builtin_theme_preserves_every_colour_in_use() {
let t = Theme::builtin();
assert_eq!(
t.color(Role::Accent),
Color::Rgb {
r: 255,
g: 165,
b: 90
}
);
assert_eq!(
t.color(Role::Accent),
ACTIVE_INPUT_CT,
"the accent IS the active-input colour; two sources would drift"
);
assert_ne!(
t.color(Role::Accent),
NEWT_ORANGE_CT,
"the activity row is the ACTIVE-INPUT accent, not the darker brand \
orange; `tty::mod` documents the two as deliberately distinct"
);
assert_eq!(t.color(Role::Dim), Color::DarkGrey);
assert_eq!(t.color(Role::Muted), Color::Grey);
assert_eq!(t.color(Role::Emphasis), Color::Yellow);
assert_eq!(
t.color(Role::CommandBackground),
Color::Rgb {
r: 82,
g: 82,
b: 82
}
);
assert_eq!(
t.color(Role::CommandBackgroundInactive),
Color::Rgb {
r: 45,
g: 45,
b: 45
}
);
assert_eq!(t.color(Role::CommandBang), Color::Magenta);
assert_eq!(t.color(Role::CommandEx), Color::Yellow);
assert_eq!(
t.color(Role::GaugeWarn),
Color::Rgb {
r: 200,
g: 140,
b: 0
}
);
assert_eq!(t.color(Role::GaugeCritical), Color::DarkRed);
assert_eq!(t.color(Role::SelectedLabel), Color::DarkCyan);
assert_eq!(t.color(Role::SelectedValue), Color::DarkYellow);
assert_eq!(t.color(Role::Identity), Color::DarkMagenta);
}
#[test]
fn narration_is_distinct_from_reply_thinking_and_human_text() {
let t = Theme::builtin();
assert_eq!(t.color(Role::Narration), Color::Magenta);
assert_ne!(t.color(Role::Narration), t.color(Role::AgentText));
assert_ne!(t.color(Role::Narration), t.color(Role::Thinking));
assert_ne!(t.color(Role::Narration), t.color(Role::HumanText));
assert_ne!(t.color(Role::Narration), t.color(Role::Dim));
}
#[test]
fn thinking_and_activity_keep_separate_default_colors() {
assert_eq!(Theme::builtin().color(Role::Thinking), Color::DarkGrey);
assert_eq!(Theme::builtin().color(Role::Accent), ACTIVE_INPUT_CT);
}
#[test]
fn every_role_round_trips_through_its_name() {
for role in ALL_ROLES {
let name = role_name(*role);
assert_eq!(
role_from_name(name),
Some(*role),
"`{name}` does not parse back to the role it names"
);
assert_eq!(name, name.to_ascii_lowercase(), "names are kebab-case");
}
assert_eq!(ALL_ROLES.len(), 35, "add the new role to ALL_ROLES too");
assert_eq!(role_from_name("nonsense"), None);
}
#[test]
fn an_override_touches_only_what_it_names() {
let base = Theme::builtin();
let themed = Theme::builtin().overlaid("high-contrast", &[(Role::Dim, Color::Grey)]);
assert_eq!(themed.name, "high-contrast");
assert_eq!(
themed.color(Role::Dim),
Color::Grey,
"the stated role moved"
);
for role in ALL_ROLES.iter().filter(|r| **r != Role::Dim) {
assert_eq!(
themed.color(*role),
base.color(*role),
"`{}` was not named and must not have moved",
role_name(*role)
);
}
}
#[test]
fn the_environment_can_retheme_any_role() {
let (themed, complaints) = from_env(Some("dim=gray, accent=#00d7ff,thinking=214"));
assert!(complaints.is_empty(), "{complaints:?}");
assert_eq!(themed.color(Role::Dim), Color::Grey);
assert_eq!(
themed.color(Role::Accent),
Color::Rgb {
r: 0,
g: 215,
b: 255
}
);
assert_eq!(themed.color(Role::Thinking), Color::AnsiValue(214));
assert_eq!(
themed.color(Role::Emphasis),
Theme::builtin().color(Role::Emphasis)
);
for nothing in [None, Some(""), Some(" ")] {
let (t, c) = from_env(nothing);
assert_eq!(t, Theme::builtin());
assert!(c.is_empty());
}
}
#[test]
fn a_bad_pair_is_named_and_the_rest_still_applies() {
let (themed, complaints) = from_env(Some("dim=gray,nonsense=red,accent=puce,bare-word"));
assert_eq!(
themed.color(Role::Dim),
Color::Grey,
"the good pair applied"
);
assert_eq!(complaints.len(), 3, "{complaints:?}");
assert!(complaints.iter().any(|c| c.contains("nonsense")));
assert!(complaints.iter().any(|c| c.contains("puce")));
assert!(complaints.iter().any(|c| c.contains("bare-word")));
}
#[test]
fn colours_are_written_the_three_ways_people_have_them() {
assert_eq!(
parse_color("#ffa55a"),
Ok(Color::Rgb {
r: 255,
g: 165,
b: 90
})
);
assert_eq!(
parse_color("#FFA55A"),
Ok(Color::Rgb {
r: 255,
g: 165,
b: 90
})
);
assert_eq!(parse_color("214"), Ok(Color::AnsiValue(214)));
assert_eq!(parse_color("0"), Ok(Color::AnsiValue(0)));
assert_eq!(parse_color("light-yellow"), Ok(Color::Yellow));
assert_eq!(parse_color(" cyan "), Ok(Color::DarkCyan), "trimmed");
assert_eq!(parse_color("dark-gray"), parse_color("dark-grey"));
assert_eq!(parse_color("gray"), parse_color("grey"));
for bad in ["#fff", "#gggggg", "puce", "256", ""] {
let err = parse_color(bad).expect_err("`{bad}` is not a colour");
assert!(err.contains(bad) || bad.is_empty(), "{err}");
}
}
}