use colored::{Color, ColoredString, Colorize};
#[derive(Debug, Clone, Copy)]
pub struct Symbols {
pub success: &'static str,
pub failure: &'static str,
pub warning: &'static str,
pub info: &'static str,
pub bullet_filled: &'static str,
pub bullet_empty: &'static str,
pub bullet_half: &'static str,
pub disabled: &'static str,
pub arrow_right: &'static str,
pub spinner_frames: &'static [&'static str],
pub syncing: &'static str,
pub downloading: &'static str,
pub compiling: &'static str,
pub waiting: &'static str,
pub draining: &'static str,
pub circuit_half_open: &'static str,
pub unknown: &'static str,
}
impl Symbols {
pub const UNICODE: Self = Self {
success: "✓",
failure: "✗",
warning: "⚠",
info: "ℹ",
bullet_filled: "●",
bullet_empty: "○",
bullet_half: "◐",
disabled: "⊘",
arrow_right: "→",
spinner_frames: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
syncing: "↑",
downloading: "↓",
compiling: "⚙",
waiting: "◷",
draining: "◑",
circuit_half_open: "↻",
unknown: "?",
};
pub const ASCII: Self = Self {
success: "[OK]",
failure: "[FAIL]",
warning: "[WARN]",
info: "[INFO]",
bullet_filled: "[*]",
bullet_empty: "[ ]",
bullet_half: "[~]",
disabled: "[-]",
arrow_right: "->",
spinner_frames: &["|", "/", "-", "\\"],
syncing: "[^]",
downloading: "[v]",
compiling: "[#]",
waiting: "[.]",
draining: "[>]",
circuit_half_open: "[*]",
unknown: "[?]",
};
pub fn for_unicode(supports_unicode: bool) -> Self {
if supports_unicode {
Self::UNICODE
} else {
Self::ASCII
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusIndicator {
Success,
Error,
Warning,
Info,
Pending,
InProgress,
Disabled,
}
impl StatusIndicator {
pub fn symbol(&self, symbols: &Symbols) -> &'static str {
match self {
StatusIndicator::Success => symbols.success,
StatusIndicator::Error => symbols.failure,
StatusIndicator::Warning => symbols.warning,
StatusIndicator::Info => symbols.bullet_filled,
StatusIndicator::Pending => symbols.bullet_empty,
StatusIndicator::InProgress => symbols.bullet_half,
StatusIndicator::Disabled => symbols.disabled,
}
}
pub fn unicode_symbol(&self) -> &'static str {
self.symbol(&Symbols::UNICODE)
}
pub fn ascii_symbol(&self) -> &'static str {
self.symbol(&Symbols::ASCII)
}
pub fn color(&self, colors: &SemanticColors) -> Color {
match self {
StatusIndicator::Success => colors.success,
StatusIndicator::Error => colors.error,
StatusIndicator::Warning => colors.warning,
StatusIndicator::Info => colors.info,
StatusIndicator::Pending => colors.muted,
StatusIndicator::InProgress => colors.info,
StatusIndicator::Disabled => colors.muted,
}
}
pub fn display(&self, style: &Style) -> ColoredString {
let symbol = self.symbol(&style.symbols);
if style.colors_enabled {
symbol.color(self.color(&style.colors))
} else {
symbol.normal()
}
}
pub fn with_label(&self, style: &Style, label: &str) -> String {
let symbol = self.display(style);
let styled_label = if style.colors_enabled {
label.color(self.color(&style.colors))
} else {
label.normal()
};
format!("{} {}", symbol, styled_label)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SemanticColors {
pub success: Color,
pub error: Color,
pub warning: Color,
pub info: Color,
pub muted: Color,
pub highlight: Color,
pub key: Color,
pub value: Color,
}
impl Default for SemanticColors {
fn default() -> Self {
Self {
success: Color::Green,
error: Color::Red,
warning: Color::Yellow,
info: Color::Cyan,
muted: Color::BrightBlack,
highlight: Color::White,
key: Color::Blue,
value: Color::White,
}
}
}
#[derive(Debug, Clone)]
pub struct Style {
pub colors: SemanticColors,
pub symbols: Symbols,
pub colors_enabled: bool,
supports_unicode: bool,
supports_hyperlinks: bool,
}
impl Style {
pub fn new(colors_enabled: bool, supports_unicode: bool, supports_hyperlinks: bool) -> Self {
Self {
colors: SemanticColors::default(),
symbols: Symbols::for_unicode(supports_unicode),
colors_enabled,
supports_unicode,
supports_hyperlinks,
}
}
pub fn supports_unicode(&self) -> bool {
self.supports_unicode
}
pub fn supports_hyperlinks(&self) -> bool {
self.supports_hyperlinks
}
pub fn success(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.success)
} else {
text.normal()
}
}
pub fn error(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.error).bold()
} else {
text.normal()
}
}
pub fn warning(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.warning)
} else {
text.normal()
}
}
pub fn info(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.info)
} else {
text.normal()
}
}
pub fn muted(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.muted)
} else {
text.normal()
}
}
pub fn dim(&self, text: &str) -> ColoredString {
self.muted(text)
}
pub fn highlight(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.highlight).bold()
} else {
text.normal()
}
}
pub fn key(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.key)
} else {
text.normal()
}
}
pub fn value(&self, text: &str) -> ColoredString {
if self.colors_enabled {
text.color(self.colors.value)
} else {
text.normal()
}
}
pub fn format_success(&self, msg: &str) -> String {
format!(
"{} {}",
self.success(self.symbols.success),
self.success(msg)
)
}
pub fn format_error(&self, msg: &str) -> String {
format!("{} {}", self.error(self.symbols.failure), self.error(msg))
}
pub fn format_warning(&self, msg: &str) -> String {
format!(
"{} {}",
self.warning(self.symbols.warning),
self.warning(msg)
)
}
pub fn format_info(&self, msg: &str) -> String {
format!("{} {}", self.info(self.symbols.info), self.info(msg))
}
pub fn format_key_value(&self, key: &str, value: &str, key_width: usize) -> String {
format!(
"{:width$} {} {}",
self.key(key),
self.muted(":"),
self.value(value),
width = key_width
)
}
pub fn format_header(&self, title: &str) -> String {
if self.colors_enabled {
format!(
"\n{}\n{}",
self.highlight(title),
self.muted(&"─".repeat(title.len()))
)
} else {
format!("\n{}\n{}", title, "-".repeat(title.len()))
}
}
pub fn link(&self, text: &str, url: &str) -> String {
if self.supports_hyperlinks {
format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, text)
} else if text == url {
url.to_string()
} else {
format!("{} ({})", text, url)
}
}
}
pub type Theme = Style;
#[cfg(test)]
mod tests {
use super::*;
use tracing::info;
fn log_test_start(name: &str) {
info!("TEST START: {}", name);
}
#[test]
fn test_symbols_unicode() {
log_test_start("test_symbols_unicode");
let sym = Symbols::UNICODE;
assert_eq!(sym.success, "✓");
assert_eq!(sym.failure, "✗");
}
#[test]
fn test_symbols_ascii() {
log_test_start("test_symbols_ascii");
let sym = Symbols::ASCII;
assert_eq!(sym.success, "[OK]");
assert_eq!(sym.failure, "[FAIL]");
}
#[test]
fn test_symbols_selection() {
log_test_start("test_symbols_selection");
let unicode_sym = Symbols::for_unicode(true);
assert_eq!(unicode_sym.success, "✓");
let ascii_sym = Symbols::for_unicode(false);
assert_eq!(ascii_sym.success, "[OK]");
}
#[test]
fn test_style_with_colors_disabled() {
log_test_start("test_style_with_colors_disabled");
let style = Style::new(false, true, false);
let styled = style.success("test");
let output = styled.to_string();
assert!(!output.contains("\x1b["));
}
#[test]
fn test_format_key_value() {
log_test_start("test_format_key_value");
let style = Style::new(false, true, false);
let formatted = style.format_key_value("Status", "Healthy", 10);
assert!(formatted.contains("Status"));
assert!(formatted.contains("Healthy"));
assert!(formatted.contains(":"));
}
#[test]
fn test_format_header() {
log_test_start("test_format_header");
let style = Style::new(false, true, false);
let header = style.format_header("Workers");
assert!(header.contains("Workers"));
assert!(header.contains("-------"));
}
#[test]
fn test_status_indicator_symbols_unicode() {
log_test_start("test_status_indicator_symbols_unicode");
let symbols = Symbols::UNICODE;
assert_eq!(StatusIndicator::Success.symbol(&symbols), "✓");
assert_eq!(StatusIndicator::Error.symbol(&symbols), "✗");
assert_eq!(StatusIndicator::Warning.symbol(&symbols), "⚠");
assert_eq!(StatusIndicator::Info.symbol(&symbols), "●");
assert_eq!(StatusIndicator::Pending.symbol(&symbols), "○");
assert_eq!(StatusIndicator::InProgress.symbol(&symbols), "◐");
assert_eq!(StatusIndicator::Disabled.symbol(&symbols), "⊘");
}
#[test]
fn test_status_indicator_symbols_ascii() {
log_test_start("test_status_indicator_symbols_ascii");
let symbols = Symbols::ASCII;
assert_eq!(StatusIndicator::Success.symbol(&symbols), "[OK]");
assert_eq!(StatusIndicator::Error.symbol(&symbols), "[FAIL]");
assert_eq!(StatusIndicator::Warning.symbol(&symbols), "[WARN]");
assert_eq!(StatusIndicator::Info.symbol(&symbols), "[*]");
assert_eq!(StatusIndicator::Pending.symbol(&symbols), "[ ]");
assert_eq!(StatusIndicator::InProgress.symbol(&symbols), "[~]");
assert_eq!(StatusIndicator::Disabled.symbol(&symbols), "[-]");
}
#[test]
fn test_status_indicator_colors() {
log_test_start("test_status_indicator_colors");
let colors = SemanticColors::default();
assert_eq!(StatusIndicator::Success.color(&colors), Color::Green);
assert_eq!(StatusIndicator::Error.color(&colors), Color::Red);
assert_eq!(StatusIndicator::Warning.color(&colors), Color::Yellow);
assert_eq!(StatusIndicator::Info.color(&colors), Color::Cyan);
assert_eq!(StatusIndicator::Pending.color(&colors), Color::BrightBlack);
assert_eq!(StatusIndicator::InProgress.color(&colors), Color::Cyan);
assert_eq!(StatusIndicator::Disabled.color(&colors), Color::BrightBlack);
}
#[test]
fn test_status_indicator_display_no_color() {
log_test_start("test_status_indicator_display_no_color");
let style = Style::new(false, true, false);
let display = StatusIndicator::Success.display(&style);
let output = display.to_string();
assert_eq!(output, "✓");
assert!(!output.contains("\x1b[")); }
#[test]
fn test_status_indicator_with_label() {
log_test_start("test_status_indicator_with_label");
let style = Style::new(false, true, false);
let output = StatusIndicator::Success.with_label(&style, "Running");
assert!(output.contains("✓"));
assert!(output.contains("Running"));
}
#[test]
fn test_hyperlink_formatting() {
log_test_start("test_hyperlink_formatting");
let style = Style::new(false, true, true);
let link = style.link("Click me", "https://example.com");
assert_eq!(
link,
"\x1b]8;;https://example.com\x1b\\Click me\x1b]8;;\x1b\\"
);
}
#[test]
fn test_hyperlink_fallback() {
log_test_start("test_hyperlink_fallback");
let style = Style::new(false, true, false);
let link = style.link("Click me", "https://example.com");
assert_eq!(link, "Click me (https://example.com)");
let link_same = style.link("https://example.com", "https://example.com");
assert_eq!(link_same, "https://example.com");
}
#[test]
fn test_disabled_symbol() {
log_test_start("test_disabled_symbol");
let unicode_sym = Symbols::UNICODE;
assert_eq!(unicode_sym.disabled, "⊘");
let ascii_sym = Symbols::ASCII;
assert_eq!(ascii_sym.disabled, "[-]");
}
}