#[cfg(all(feature = "rich-ui", unix))]
use rich_rust::prelude::{Color, Style};
use crate::types::WorkerStatus;
pub struct RchTheme;
impl RchTheme {
pub const PRIMARY: &'static str = "#8B5CF6";
pub const SECONDARY: &'static str = "#06B6D4";
pub const ACCENT: &'static str = "#F59E0B";
pub const SUCCESS: &'static str = "#10B981";
pub const WARNING: &'static str = "#F59E0B";
pub const ERROR: &'static str = "#EF4444";
pub const INFO: &'static str = "#3B82F6";
pub const STATUS_HEALTHY: &'static str = "#10B981";
pub const STATUS_DEGRADED: &'static str = "#F59E0B";
pub const STATUS_UNREACHABLE: &'static str = "#EF4444";
pub const STATUS_DRAINING: &'static str = "#8B5CF6";
pub const STATUS_DRAINED: &'static str = "#6366F1";
pub const STATUS_DISABLED: &'static str = "#737B8A";
pub const MUTED: &'static str = "#9CA3AF";
pub const DIM: &'static str = "#737B8A";
pub const BRIGHT: &'static str = "#F9FAFB";
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn success() -> Style {
Style::new().color(Color::parse(Self::SUCCESS).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn error() -> Style {
Style::new()
.bold()
.color(Color::parse(Self::ERROR).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn warning() -> Style {
Style::new().color(Color::parse(Self::WARNING).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn info() -> Style {
Style::new().color(Color::parse(Self::INFO).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn muted() -> Style {
Style::new().color(Color::parse(Self::MUTED).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn dim() -> Style {
Style::new()
.dim()
.color(Color::parse(Self::DIM).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn primary() -> Style {
Style::new().color(Color::parse(Self::PRIMARY).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn secondary() -> Style {
Style::new().color(Color::parse(Self::SECONDARY).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn accent() -> Style {
Style::new().color(Color::parse(Self::ACCENT).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn worker_status_str(status: &str) -> Style {
let color = match status.to_lowercase().as_str() {
"healthy" => Self::STATUS_HEALTHY,
"degraded" => Self::STATUS_DEGRADED,
"unreachable" => Self::STATUS_UNREACHABLE,
"draining" => Self::STATUS_DRAINING,
"disabled" => Self::STATUS_DISABLED,
_ => Self::MUTED,
};
Style::new().color(Color::parse(color).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn for_worker_status(status: WorkerStatus) -> Style {
let color = match status {
WorkerStatus::Healthy => Self::STATUS_HEALTHY,
WorkerStatus::Degraded => Self::STATUS_DEGRADED,
WorkerStatus::Unreachable => Self::STATUS_UNREACHABLE,
WorkerStatus::Draining => Self::STATUS_DRAINING,
WorkerStatus::Drained => Self::STATUS_DRAINED,
WorkerStatus::Disabled => Self::STATUS_DISABLED,
};
Style::new().color(Color::parse(color).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn table_header() -> Style {
Style::new()
.bold()
.color(Color::parse(Self::BRIGHT).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn table_border() -> Style {
Style::new().color(Color::parse(Self::PRIMARY).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn panel_title() -> Style {
Style::new()
.bold()
.color(Color::parse(Self::SECONDARY).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn code() -> Style {
Style::new().color(Color::parse(Self::SECONDARY).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn path() -> Style {
Style::new()
.italic()
.color(Color::parse(Self::INFO).unwrap_or_default())
}
#[cfg(all(feature = "rich-ui", unix))]
#[must_use]
pub fn number() -> Style {
Style::new().color(Color::parse(Self::ACCENT).unwrap_or_default())
}
#[must_use]
pub const fn color_for_worker_status(status: WorkerStatus) -> &'static str {
match status {
WorkerStatus::Healthy => Self::STATUS_HEALTHY,
WorkerStatus::Degraded => Self::STATUS_DEGRADED,
WorkerStatus::Unreachable => Self::STATUS_UNREACHABLE,
WorkerStatus::Draining => Self::STATUS_DRAINING,
WorkerStatus::Drained => Self::STATUS_DRAINED,
WorkerStatus::Disabled => Self::STATUS_DISABLED,
}
}
#[must_use]
pub fn color_for_status_str(status: &str) -> &'static str {
match status.to_lowercase().as_str() {
"healthy" => Self::STATUS_HEALTHY,
"degraded" => Self::STATUS_DEGRADED,
"unreachable" => Self::STATUS_UNREACHABLE,
"draining" => Self::STATUS_DRAINING,
"drained" => Self::STATUS_DRAINED,
"disabled" => Self::STATUS_DISABLED,
"success" | "ok" => Self::SUCCESS,
"warning" | "warn" => Self::WARNING,
"error" | "fail" | "failed" => Self::ERROR,
"info" => Self::INFO,
_ => Self::MUTED,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_hex_rgb(color: &str) -> (u8, u8, u8) {
let color = color.strip_prefix('#').unwrap_or(color);
assert_eq!(color.len(), 6, "Expected RRGGBB hex string, got: {color}");
let r = u8::from_str_radix(&color[0..2], 16).expect("Invalid hex for R");
let g = u8::from_str_radix(&color[2..4], 16).expect("Invalid hex for G");
let b = u8::from_str_radix(&color[4..6], 16).expect("Invalid hex for B");
(r, g, b)
}
fn srgb_channel_to_linear(channel: u8) -> f64 {
let c = f64::from(channel) / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn relative_luminance(color: &str) -> f64 {
let (r, g, b) = parse_hex_rgb(color);
let r = srgb_channel_to_linear(r);
let g = srgb_channel_to_linear(g);
let b = srgb_channel_to_linear(b);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
fn contrast_ratio(foreground: &str, background: &str) -> f64 {
let l1 = relative_luminance(foreground);
let l2 = relative_luminance(background);
let (lighter, darker) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
(lighter + 0.05) / (darker + 0.05)
}
#[test]
fn test_all_color_constants_are_valid_hex() {
let colors = [
RchTheme::PRIMARY,
RchTheme::SECONDARY,
RchTheme::ACCENT,
RchTheme::SUCCESS,
RchTheme::WARNING,
RchTheme::ERROR,
RchTheme::INFO,
RchTheme::STATUS_HEALTHY,
RchTheme::STATUS_DEGRADED,
RchTheme::STATUS_UNREACHABLE,
RchTheme::STATUS_DRAINING,
RchTheme::STATUS_DISABLED,
RchTheme::MUTED,
RchTheme::DIM,
RchTheme::BRIGHT,
];
for color in colors {
assert!(color.starts_with('#'), "Color should start with #: {color}");
assert_eq!(color.len(), 7, "Color should be 7 chars: {color}");
assert!(
color[1..].chars().all(|c| c.is_ascii_hexdigit()),
"Color should be valid hex: {color}"
);
}
}
#[test]
fn test_semantic_colors_meet_contrast_on_dark_background() {
const BACKGROUND: &str = "#000000";
const MIN_RATIO: f64 = 4.5;
let colors = [
("PRIMARY", RchTheme::PRIMARY),
("SECONDARY", RchTheme::SECONDARY),
("ACCENT", RchTheme::ACCENT),
("SUCCESS", RchTheme::SUCCESS),
("WARNING", RchTheme::WARNING),
("ERROR", RchTheme::ERROR),
("INFO", RchTheme::INFO),
("STATUS_HEALTHY", RchTheme::STATUS_HEALTHY),
("STATUS_DEGRADED", RchTheme::STATUS_DEGRADED),
("STATUS_UNREACHABLE", RchTheme::STATUS_UNREACHABLE),
("STATUS_DRAINING", RchTheme::STATUS_DRAINING),
("STATUS_DISABLED", RchTheme::STATUS_DISABLED),
("MUTED", RchTheme::MUTED),
("DIM", RchTheme::DIM),
("BRIGHT", RchTheme::BRIGHT),
];
for (name, color) in colors {
let ratio = contrast_ratio(color, BACKGROUND);
assert!(
ratio >= MIN_RATIO,
"{name} ({color}) contrast vs {BACKGROUND} too low: {ratio:.2}"
);
}
}
#[test]
fn test_color_for_worker_status() {
assert_eq!(
RchTheme::color_for_worker_status(WorkerStatus::Healthy),
RchTheme::STATUS_HEALTHY
);
assert_eq!(
RchTheme::color_for_worker_status(WorkerStatus::Degraded),
RchTheme::STATUS_DEGRADED
);
assert_eq!(
RchTheme::color_for_worker_status(WorkerStatus::Unreachable),
RchTheme::STATUS_UNREACHABLE
);
assert_eq!(
RchTheme::color_for_worker_status(WorkerStatus::Draining),
RchTheme::STATUS_DRAINING
);
assert_eq!(
RchTheme::color_for_worker_status(WorkerStatus::Drained),
RchTheme::STATUS_DRAINED
);
assert_eq!(
RchTheme::color_for_worker_status(WorkerStatus::Disabled),
RchTheme::STATUS_DISABLED
);
}
#[test]
fn test_color_for_status_str() {
assert_eq!(
RchTheme::color_for_status_str("HEALTHY"),
RchTheme::STATUS_HEALTHY
);
assert_eq!(
RchTheme::color_for_status_str("healthy"),
RchTheme::STATUS_HEALTHY
);
assert_eq!(
RchTheme::color_for_status_str("Healthy"),
RchTheme::STATUS_HEALTHY
);
assert_eq!(RchTheme::color_for_status_str("success"), RchTheme::SUCCESS);
assert_eq!(RchTheme::color_for_status_str("ok"), RchTheme::SUCCESS);
assert_eq!(RchTheme::color_for_status_str("error"), RchTheme::ERROR);
assert_eq!(RchTheme::color_for_status_str("fail"), RchTheme::ERROR);
assert_eq!(RchTheme::color_for_status_str("failed"), RchTheme::ERROR);
assert_eq!(RchTheme::color_for_status_str("unknown"), RchTheme::MUTED);
assert_eq!(RchTheme::color_for_status_str(""), RchTheme::MUTED);
}
#[test]
fn test_all_status_colors_are_distinct() {
let colors = [
RchTheme::STATUS_HEALTHY,
RchTheme::STATUS_DEGRADED,
RchTheme::STATUS_UNREACHABLE,
RchTheme::STATUS_DRAINING,
RchTheme::STATUS_DRAINED,
RchTheme::STATUS_DISABLED,
];
for (i, &c1) in colors.iter().enumerate() {
for &c2 in &colors[i + 1..] {
assert_ne!(c1, c2, "Status colors should be distinct");
}
}
}
#[test]
fn test_contrast_ratio_calculation_accuracy() {
let ratio = contrast_ratio("#FFFFFF", "#000000");
assert!(
(ratio - 21.0).abs() < 0.1,
"White/black contrast should be ~21:1, got {ratio:.2}:1"
);
let same = contrast_ratio("#FF0000", "#FF0000");
assert!(
(same - 1.0).abs() < 0.01,
"Same color contrast should be 1:1"
);
}
#[test]
fn test_colors_on_dark_gray_background() {
const DARK_GRAY: &str = "#1a1a1a";
const MIN_RATIO: f64 = 4.5;
let critical_colors = [
("ERROR", RchTheme::ERROR),
("SUCCESS", RchTheme::SUCCESS),
("WARNING", RchTheme::WARNING),
];
for (name, color) in critical_colors {
let ratio = contrast_ratio(color, DARK_GRAY);
assert!(
ratio >= MIN_RATIO,
"{name} ({color}) must be readable on dark gray: {ratio:.2}:1 < {MIN_RATIO}:1"
);
}
}
#[cfg(all(feature = "rich-ui", unix))]
mod rich_ui_tests {
use super::*;
use rich_rust::prelude::Color;
#[test]
fn test_all_colors_parse_with_rich_rust() {
assert!(Color::parse(RchTheme::PRIMARY).is_ok());
assert!(Color::parse(RchTheme::SECONDARY).is_ok());
assert!(Color::parse(RchTheme::ACCENT).is_ok());
assert!(Color::parse(RchTheme::SUCCESS).is_ok());
assert!(Color::parse(RchTheme::WARNING).is_ok());
assert!(Color::parse(RchTheme::ERROR).is_ok());
assert!(Color::parse(RchTheme::INFO).is_ok());
assert!(Color::parse(RchTheme::STATUS_HEALTHY).is_ok());
assert!(Color::parse(RchTheme::STATUS_DEGRADED).is_ok());
assert!(Color::parse(RchTheme::STATUS_UNREACHABLE).is_ok());
assert!(Color::parse(RchTheme::STATUS_DRAINING).is_ok());
assert!(Color::parse(RchTheme::STATUS_DRAINED).is_ok());
assert!(Color::parse(RchTheme::STATUS_DISABLED).is_ok());
assert!(Color::parse(RchTheme::MUTED).is_ok());
assert!(Color::parse(RchTheme::DIM).is_ok());
assert!(Color::parse(RchTheme::BRIGHT).is_ok());
}
#[test]
fn test_styles_dont_panic() {
let _ = RchTheme::success();
let _ = RchTheme::error();
let _ = RchTheme::warning();
let _ = RchTheme::info();
let _ = RchTheme::muted();
let _ = RchTheme::dim();
let _ = RchTheme::primary();
let _ = RchTheme::secondary();
let _ = RchTheme::accent();
let _ = RchTheme::table_header();
let _ = RchTheme::table_border();
let _ = RchTheme::panel_title();
let _ = RchTheme::code();
let _ = RchTheme::path();
let _ = RchTheme::number();
}
#[test]
fn test_worker_status_styles() {
let _ = RchTheme::worker_status_str("healthy");
let _ = RchTheme::worker_status_str("unknown");
let _ = RchTheme::for_worker_status(WorkerStatus::Healthy);
let _ = RchTheme::for_worker_status(WorkerStatus::Degraded);
let _ = RchTheme::for_worker_status(WorkerStatus::Unreachable);
let _ = RchTheme::for_worker_status(WorkerStatus::Draining);
let _ = RchTheme::for_worker_status(WorkerStatus::Drained);
let _ = RchTheme::for_worker_status(WorkerStatus::Disabled);
}
}
}