use anyhow::{Context, Result};
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub integrations: IntegrationConfig,
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub columns: ColumnVisibilityConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IntegrationConfig {
#[serde(default)]
pub default_tracker: Option<String>,
#[serde(default)]
pub trackers: HashMap<String, TrackerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TrackerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub ticket_patterns: Vec<String>,
#[serde(default)]
pub browse_url: String,
#[serde(default)]
pub worklog_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnVisibilityConfig {
#[serde(default = "default_show_project")]
pub project: bool,
#[serde(default = "default_show_customer")]
pub customer: bool,
#[serde(default = "default_show_description")]
pub description: bool,
}
fn default_show_project() -> bool {
true
}
fn default_show_customer() -> bool {
false
}
fn default_show_description() -> bool {
true
}
impl Default for ColumnVisibilityConfig {
fn default() -> Self {
Self {
project: default_show_project(),
customer: default_show_customer(),
description: default_show_description(),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::get_config_path();
if config_path.exists() {
let contents = fs::read_to_string(&config_path)
.context(format!("Failed to read config file: {:?}", config_path))?;
let config: Config =
toml::from_str(&contents).context("Failed to parse config TOML")?;
Ok(config)
} else {
Ok(Config::default())
}
}
fn get_config_path() -> PathBuf {
#[cfg(unix)]
{
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(xdg_config)
.join("work-tuimer")
.join("config.toml");
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home)
.join(".config")
.join("work-tuimer")
.join("config.toml");
}
}
#[cfg(windows)]
{
if let Some(config_dir) = dirs::config_dir() {
return config_dir.join("work-tuimer").join("config.toml");
}
}
PathBuf::from("./config.toml")
}
pub fn has_integrations(&self) -> bool {
self.integrations
.trackers
.values()
.any(|tracker| tracker.enabled && !tracker.base_url.is_empty())
}
pub fn get_theme(&self) -> Theme {
self.theme.get_active_theme()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
#[serde(default = "default_theme_name")]
pub active: String,
#[serde(default)]
pub custom: HashMap<String, CustomThemeColors>,
}
fn default_theme_name() -> String {
"default".to_string()
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
active: default_theme_name(),
custom: HashMap::new(),
}
}
}
impl ThemeConfig {
pub fn get_active_theme(&self) -> Theme {
if let Some(custom_colors) = self.custom.get(&self.active) {
return Theme::from_custom(custom_colors);
}
match self.active.as_str() {
"default" => Theme::default_theme(),
"kanagawa" => Theme::kanagawa(),
"catppuccin" => Theme::catppuccin(),
"gruvbox" => Theme::gruvbox(),
"monokai" => Theme::monokai(),
"dracula" => Theme::dracula(),
"everforest" => Theme::everforest(),
"terminal" => Theme::terminal(),
_ => {
Theme::default_theme()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomThemeColors {
pub active_border: String,
pub inactive_border: String,
pub searching_border: String,
pub selected_bg: String,
pub selected_inactive_bg: String,
pub visual_bg: String,
pub timer_active_bg: String,
pub row_alternate_bg: String,
pub edit_bg: String,
pub focus_bg: String,
pub primary_text: String,
pub secondary_text: String,
pub highlight_text: String,
pub success: String,
pub warning: String,
pub error: String,
pub info: String,
pub timer_text: String,
pub badge: String,
}
#[derive(Debug, Clone)]
pub struct Theme {
pub active_border: Color,
#[allow(dead_code)]
pub inactive_border: Color,
#[allow(dead_code)]
pub searching_border: Color,
pub selected_bg: Color,
#[allow(dead_code)]
pub selected_inactive_bg: Color,
pub visual_bg: Color,
pub timer_active_bg: Color,
pub row_alternate_bg: Color,
pub edit_bg: Color,
pub focus_bg: Color,
pub primary_text: Color,
pub secondary_text: Color,
pub highlight_text: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub info: Color,
pub timer_text: Color,
pub badge: Color,
}
impl Theme {
pub fn default_theme() -> Self {
Self {
active_border: Color::Cyan,
inactive_border: Color::DarkGray,
searching_border: Color::Yellow,
selected_bg: Color::Rgb(40, 40, 60),
selected_inactive_bg: Color::Rgb(30, 30, 45),
visual_bg: Color::Rgb(70, 130, 180),
timer_active_bg: Color::Rgb(34, 139, 34),
row_alternate_bg: Color::Rgb(25, 25, 35),
edit_bg: Color::Rgb(22, 78, 99),
focus_bg: Color::Rgb(80, 60, 120), primary_text: Color::White,
secondary_text: Color::Gray,
highlight_text: Color::Cyan,
success: Color::Green,
warning: Color::Yellow,
error: Color::LightRed,
info: Color::Cyan,
timer_text: Color::Yellow,
badge: Color::LightMagenta,
}
}
pub fn kanagawa() -> Self {
Self {
active_border: Color::Rgb(127, 180, 202), inactive_border: Color::Rgb(54, 59, 77), searching_border: Color::Rgb(255, 160, 102), selected_bg: Color::Rgb(42, 42, 55), selected_inactive_bg: Color::Rgb(31, 31, 40), visual_bg: Color::Rgb(45, 79, 103), timer_active_bg: Color::Rgb(152, 187, 106), row_alternate_bg: Color::Rgb(22, 25, 32), edit_bg: Color::Rgb(106, 149, 137), focus_bg: Color::Rgb(149, 127, 184), primary_text: Color::Rgb(220, 215, 186), secondary_text: Color::Rgb(114, 118, 129), highlight_text: Color::Rgb(230, 195, 132), success: Color::Rgb(152, 187, 106), warning: Color::Rgb(255, 160, 102), error: Color::Rgb(255, 93, 98), info: Color::Rgb(127, 180, 202), timer_text: Color::Rgb(230, 195, 132), badge: Color::Rgb(149, 127, 184), }
}
pub fn catppuccin() -> Self {
Self {
active_border: Color::Rgb(137, 180, 250), inactive_border: Color::Rgb(69, 71, 90), searching_border: Color::Rgb(249, 226, 175), selected_bg: Color::Rgb(49, 50, 68), selected_inactive_bg: Color::Rgb(30, 30, 46), visual_bg: Color::Rgb(116, 199, 236), timer_active_bg: Color::Rgb(166, 227, 161), row_alternate_bg: Color::Rgb(24, 24, 37), edit_bg: Color::Rgb(137, 180, 250), focus_bg: Color::Rgb(203, 166, 247), primary_text: Color::Rgb(205, 214, 244), secondary_text: Color::Rgb(127, 132, 156), highlight_text: Color::Rgb(137, 180, 250), success: Color::Rgb(166, 227, 161), warning: Color::Rgb(249, 226, 175), error: Color::Rgb(243, 139, 168), info: Color::Rgb(137, 180, 250), timer_text: Color::Rgb(245, 194, 231), badge: Color::Rgb(203, 166, 247), }
}
pub fn gruvbox() -> Self {
Self {
active_border: Color::Rgb(131, 165, 152), inactive_border: Color::Rgb(60, 56, 54), searching_border: Color::Rgb(250, 189, 47), selected_bg: Color::Rgb(60, 56, 54), selected_inactive_bg: Color::Rgb(40, 40, 40), visual_bg: Color::Rgb(69, 133, 136), timer_active_bg: Color::Rgb(152, 151, 26), row_alternate_bg: Color::Rgb(29, 32, 33), edit_bg: Color::Rgb(80, 73, 69), focus_bg: Color::Rgb(211, 134, 155), primary_text: Color::Rgb(235, 219, 178), secondary_text: Color::Rgb(146, 131, 116), highlight_text: Color::Rgb(131, 165, 152), success: Color::Rgb(184, 187, 38), warning: Color::Rgb(250, 189, 47), error: Color::Rgb(251, 73, 52), info: Color::Rgb(131, 165, 152), timer_text: Color::Rgb(254, 128, 25), badge: Color::Rgb(211, 134, 155), }
}
pub fn monokai() -> Self {
Self {
active_border: Color::Rgb(102, 217, 239), inactive_border: Color::Rgb(73, 72, 62), searching_border: Color::Rgb(230, 219, 116), selected_bg: Color::Rgb(73, 72, 62), selected_inactive_bg: Color::Rgb(39, 40, 34), visual_bg: Color::Rgb(249, 38, 114), timer_active_bg: Color::Rgb(166, 226, 46), row_alternate_bg: Color::Rgb(30, 31, 28), edit_bg: Color::Rgb(100, 85, 60), focus_bg: Color::Rgb(174, 129, 255), primary_text: Color::Rgb(248, 248, 242), secondary_text: Color::Rgb(117, 113, 94), highlight_text: Color::Rgb(253, 151, 31), success: Color::Rgb(166, 226, 46), warning: Color::Rgb(230, 219, 116), error: Color::Rgb(249, 38, 114), info: Color::Rgb(102, 217, 239), timer_text: Color::Rgb(253, 151, 31), badge: Color::Rgb(174, 129, 255), }
}
pub fn dracula() -> Self {
Self {
active_border: Color::Rgb(139, 233, 253), inactive_border: Color::Rgb(68, 71, 90), searching_border: Color::Rgb(241, 250, 140), selected_bg: Color::Rgb(68, 71, 90), selected_inactive_bg: Color::Rgb(40, 42, 54), visual_bg: Color::Rgb(139, 233, 253), timer_active_bg: Color::Rgb(80, 250, 123), row_alternate_bg: Color::Rgb(30, 31, 40), edit_bg: Color::Rgb(98, 114, 164), focus_bg: Color::Rgb(189, 147, 249), primary_text: Color::Rgb(248, 248, 242), secondary_text: Color::Rgb(98, 114, 164), highlight_text: Color::Rgb(189, 147, 249), success: Color::Rgb(80, 250, 123), warning: Color::Rgb(241, 250, 140), error: Color::Rgb(255, 85, 85), info: Color::Rgb(139, 233, 253), timer_text: Color::Rgb(255, 184, 108), badge: Color::Rgb(255, 121, 198), }
}
pub fn everforest() -> Self {
Self {
active_border: Color::Rgb(131, 192, 146), inactive_border: Color::Rgb(83, 86, 77), searching_border: Color::Rgb(219, 188, 127), selected_bg: Color::Rgb(67, 72, 60), selected_inactive_bg: Color::Rgb(45, 49, 41), visual_bg: Color::Rgb(123, 175, 153), timer_active_bg: Color::Rgb(131, 192, 146), row_alternate_bg: Color::Rgb(35, 38, 32), edit_bg: Color::Rgb(83, 86, 77), focus_bg: Color::Rgb(217, 143, 172), primary_text: Color::Rgb(211, 198, 170), secondary_text: Color::Rgb(146, 142, 123), highlight_text: Color::Rgb(123, 175, 153), success: Color::Rgb(131, 192, 146), warning: Color::Rgb(219, 188, 127), error: Color::Rgb(230, 126, 128), info: Color::Rgb(123, 175, 153), timer_text: Color::Rgb(230, 152, 117), badge: Color::Rgb(217, 143, 172), }
}
pub fn terminal() -> Self {
Self {
active_border: Color::Cyan,
inactive_border: Color::Reset,
searching_border: Color::Yellow,
selected_bg: Color::Blue,
selected_inactive_bg: Color::Reset,
visual_bg: Color::Blue,
timer_active_bg: Color::Green,
row_alternate_bg: Color::Reset,
edit_bg: Color::Cyan,
focus_bg: Color::Magenta, primary_text: Color::Reset,
secondary_text: Color::DarkGray,
highlight_text: Color::Cyan,
success: Color::Green,
warning: Color::Yellow,
error: Color::Red,
info: Color::Cyan,
timer_text: Color::Yellow,
badge: Color::Magenta,
}
}
pub fn from_custom(colors: &CustomThemeColors) -> Self {
Self {
active_border: parse_color(&colors.active_border),
inactive_border: parse_color(&colors.inactive_border),
searching_border: parse_color(&colors.searching_border),
selected_bg: parse_color(&colors.selected_bg),
selected_inactive_bg: parse_color(&colors.selected_inactive_bg),
visual_bg: parse_color(&colors.visual_bg),
timer_active_bg: parse_color(&colors.timer_active_bg),
row_alternate_bg: parse_color(&colors.row_alternate_bg),
edit_bg: parse_color(&colors.edit_bg),
focus_bg: parse_color(&colors.focus_bg),
primary_text: parse_color(&colors.primary_text),
secondary_text: parse_color(&colors.secondary_text),
highlight_text: parse_color(&colors.highlight_text),
success: parse_color(&colors.success),
warning: parse_color(&colors.warning),
error: parse_color(&colors.error),
info: parse_color(&colors.info),
timer_text: parse_color(&colors.timer_text),
badge: parse_color(&colors.badge),
}
}
}
fn parse_color(color_str: &str) -> Color {
let trimmed = color_str.trim();
if let Some(hex) = trimmed.strip_prefix('#') {
if hex.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&hex[0..2], 16),
u8::from_str_radix(&hex[2..4], 16),
u8::from_str_radix(&hex[4..6], 16),
) {
return Color::Rgb(r, g, b);
}
} else if hex.len() == 3 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&hex[0..1].repeat(2), 16),
u8::from_str_radix(&hex[1..2].repeat(2), 16),
u8::from_str_radix(&hex[2..3].repeat(2), 16),
) {
return Color::Rgb(r, g, b);
}
}
}
match trimmed.to_lowercase().as_str() {
"reset" | "terminal" | "default" => Color::Reset,
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
"white" => Color::White,
_ => {
let rgb_str = trimmed.trim_start_matches('(').trim_end_matches(')').trim();
let parts: Vec<&str> = rgb_str.split(',').map(|s| s.trim()).collect();
if parts.len() == 3
&& let (Ok(r), Ok(g), Ok(b)) = (
parts[0].parse::<u8>(),
parts[1].parse::<u8>(),
parts[2].parse::<u8>(),
)
{
return Color::Rgb(r, g, b);
}
Color::White
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.integrations.default_tracker, None);
assert!(config.integrations.trackers.is_empty());
assert!(config.columns.project);
assert!(!config.columns.customer);
assert!(config.columns.description);
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let toml_str = toml::to_string_pretty(&config).expect("Failed to serialize");
assert!(toml_str.contains("integrations"));
assert!(toml_str.contains("columns"));
}
#[test]
fn test_config_deserialization() {
let toml_str = r#"
[integrations]
default_tracker = "my-jira"
[integrations.trackers.my-jira]
enabled = true
base_url = "https://test.atlassian.net"
ticket_patterns = ["^PROJ-\\d+$"]
browse_url = "{base_url}/browse/{ticket}"
worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
assert_eq!(
config.integrations.default_tracker,
Some("my-jira".to_string())
);
let tracker = config.integrations.trackers.get("my-jira").unwrap();
assert_eq!(tracker.base_url, "https://test.atlassian.net");
assert_eq!(tracker.ticket_patterns[0], "^PROJ-\\d+$");
assert!(config.columns.project);
assert!(!config.columns.customer);
assert!(config.columns.description);
}
#[test]
fn test_columns_config_deserialization() {
let toml_str = r#"
[columns]
project = false
customer = true
description = false
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
assert!(!config.columns.project);
assert!(config.columns.customer);
assert!(!config.columns.description);
}
#[test]
fn test_columns_config_partial_defaults() {
let toml_str = r#"
[columns]
customer = true
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
assert!(config.columns.project);
assert!(config.columns.customer);
assert!(config.columns.description);
}
#[test]
fn test_tracker_config_defaults() {
let tracker = TrackerConfig::default();
assert!(!tracker.enabled);
assert!(tracker.base_url.is_empty());
assert!(tracker.ticket_patterns.is_empty());
}
#[test]
fn test_default_theme_config() {
let theme_config = ThemeConfig::default();
assert_eq!(theme_config.active, "default");
assert!(theme_config.custom.is_empty());
}
#[test]
fn test_theme_config_get_default_theme() {
let theme_config = ThemeConfig::default();
let theme = theme_config.get_active_theme();
assert!(matches!(theme.active_border, Color::Cyan));
assert!(matches!(theme.error, Color::LightRed));
}
#[test]
fn test_theme_config_get_kanagawa_theme() {
let theme_config = ThemeConfig {
active: "kanagawa".to_string(),
custom: HashMap::new(),
};
let theme = theme_config.get_active_theme();
assert!(matches!(theme.active_border, Color::Rgb(127, 180, 202)));
}
#[test]
fn test_theme_config_get_all_predefined_themes() {
let theme_names = vec![
"default",
"kanagawa",
"catppuccin",
"gruvbox",
"monokai",
"dracula",
"everforest",
"terminal",
];
for name in theme_names {
let theme_config = ThemeConfig {
active: name.to_string(),
custom: HashMap::new(),
};
let _theme = theme_config.get_active_theme();
}
}
#[test]
fn test_parse_color_hex_6_digit() {
let color = parse_color("#7e9cd8");
assert!(matches!(color, Color::Rgb(126, 156, 216)));
let color = parse_color("#FF0000");
assert!(matches!(color, Color::Rgb(255, 0, 0)));
}
#[test]
fn test_parse_color_hex_3_digit() {
let color = parse_color("#F00");
assert!(matches!(color, Color::Rgb(255, 0, 0)));
let color = parse_color("#0F0");
assert!(matches!(color, Color::Rgb(0, 255, 0)));
let color = parse_color("#00F");
assert!(matches!(color, Color::Rgb(0, 0, 255)));
}
#[test]
fn test_parse_color_rgb_tuple() {
let color = parse_color("255, 128, 64");
assert!(matches!(color, Color::Rgb(255, 128, 64)));
let color = parse_color("0,255,0");
assert!(matches!(color, Color::Rgb(0, 255, 0)));
let color = parse_color(" 100 , 200 , 150 ");
assert!(matches!(color, Color::Rgb(100, 200, 150)));
}
#[test]
fn test_parse_color_named_colors() {
assert!(matches!(parse_color("red"), Color::Red));
assert!(matches!(parse_color("Red"), Color::Red));
assert!(matches!(parse_color("RED"), Color::Red));
assert!(matches!(parse_color("green"), Color::Green));
assert!(matches!(parse_color("blue"), Color::Blue));
assert!(matches!(parse_color("yellow"), Color::Yellow));
assert!(matches!(parse_color("cyan"), Color::Cyan));
assert!(matches!(parse_color("magenta"), Color::Magenta));
assert!(matches!(parse_color("white"), Color::White));
assert!(matches!(parse_color("black"), Color::Black));
assert!(matches!(parse_color("gray"), Color::Gray));
assert!(matches!(parse_color("grey"), Color::Gray));
assert!(matches!(parse_color("darkgray"), Color::DarkGray));
assert!(matches!(parse_color("lightred"), Color::LightRed));
assert!(matches!(parse_color("lightgreen"), Color::LightGreen));
assert!(matches!(parse_color("lightyellow"), Color::LightYellow));
assert!(matches!(parse_color("lightblue"), Color::LightBlue));
assert!(matches!(parse_color("lightmagenta"), Color::LightMagenta));
assert!(matches!(parse_color("lightcyan"), Color::LightCyan));
assert!(matches!(parse_color("reset"), Color::Reset));
assert!(matches!(parse_color("terminal"), Color::Reset));
assert!(matches!(parse_color("default"), Color::Reset));
}
#[test]
fn test_parse_color_invalid_fallback() {
let color = parse_color("invalid_color");
assert!(matches!(color, Color::White));
let color = parse_color("#ZZZ");
assert!(matches!(color, Color::White));
let color = parse_color("300, 300, 300"); assert!(matches!(color, Color::White));
}
#[test]
fn test_custom_theme_deserialization() {
let toml_str = r##"
[theme]
active = "my-custom"
[theme.custom.my-custom]
active_border = "#7e9cd8"
inactive_border = "darkgray"
searching_border = "255, 160, 102"
selected_bg = "#252b37"
selected_inactive_bg = "#1e222c"
visual_bg = "70, 130, 180"
timer_active_bg = "green"
row_alternate_bg = "#16191f"
edit_bg = "#223d50"
focus_bg = "magenta"
primary_text = "white"
secondary_text = "gray"
highlight_text = "cyan"
success = "lightgreen"
warning = "yellow"
error = "lightred"
info = "cyan"
timer_text = "#ffc777"
badge = "lightmagenta"
"##;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
assert_eq!(config.theme.active, "my-custom");
assert!(config.theme.custom.contains_key("my-custom"));
let custom_colors = config.theme.custom.get("my-custom").unwrap();
assert_eq!(custom_colors.active_border, "#7e9cd8");
assert_eq!(custom_colors.timer_active_bg, "green");
assert_eq!(custom_colors.searching_border, "255, 160, 102");
}
#[test]
fn test_custom_theme_from_config() {
let mut custom = HashMap::new();
custom.insert(
"test-theme".to_string(),
CustomThemeColors {
active_border: "#FF0000".to_string(),
inactive_border: "darkgray".to_string(),
searching_border: "yellow".to_string(),
selected_bg: "#252b37".to_string(),
selected_inactive_bg: "#1e222c".to_string(),
visual_bg: "blue".to_string(),
timer_active_bg: "green".to_string(),
row_alternate_bg: "#16191f".to_string(),
edit_bg: "cyan".to_string(),
focus_bg: "magenta".to_string(),
primary_text: "white".to_string(),
secondary_text: "gray".to_string(),
highlight_text: "cyan".to_string(),
success: "green".to_string(),
warning: "yellow".to_string(),
error: "red".to_string(),
info: "cyan".to_string(),
timer_text: "yellow".to_string(),
badge: "magenta".to_string(),
},
);
let theme_config = ThemeConfig {
active: "test-theme".to_string(),
custom,
};
let theme = theme_config.get_active_theme();
assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0)));
}
#[test]
fn test_fallback_to_default_when_custom_not_found() {
let theme_config = ThemeConfig {
active: "non-existent-theme".to_string(),
custom: HashMap::new(),
};
let theme = theme_config.get_active_theme();
assert!(matches!(theme.active_border, Color::Cyan));
assert!(matches!(theme.error, Color::LightRed));
}
#[test]
fn test_theme_from_custom_colors() {
let custom_colors = CustomThemeColors {
active_border: "#7e9cd8".to_string(),
inactive_border: "darkgray".to_string(),
searching_border: "yellow".to_string(),
selected_bg: "50, 50, 70".to_string(),
selected_inactive_bg: "#1e222c".to_string(),
visual_bg: "blue".to_string(),
timer_active_bg: "green".to_string(),
row_alternate_bg: "#000000".to_string(),
edit_bg: "cyan".to_string(),
focus_bg: "magenta".to_string(),
primary_text: "white".to_string(),
secondary_text: "gray".to_string(),
highlight_text: "cyan".to_string(),
success: "green".to_string(),
warning: "yellow".to_string(),
error: "red".to_string(),
info: "cyan".to_string(),
timer_text: "255, 199, 119".to_string(),
badge: "magenta".to_string(),
};
let theme = Theme::from_custom(&custom_colors);
assert!(matches!(theme.active_border, Color::Rgb(126, 156, 216))); assert!(matches!(theme.inactive_border, Color::DarkGray)); assert!(matches!(theme.selected_bg, Color::Rgb(50, 50, 70))); assert!(matches!(theme.timer_text, Color::Rgb(255, 199, 119))); }
#[test]
fn test_config_get_theme() {
let mut custom = HashMap::new();
custom.insert(
"custom1".to_string(),
CustomThemeColors {
active_border: "red".to_string(),
inactive_border: "darkgray".to_string(),
searching_border: "yellow".to_string(),
selected_bg: "blue".to_string(),
selected_inactive_bg: "black".to_string(),
visual_bg: "cyan".to_string(),
timer_active_bg: "green".to_string(),
row_alternate_bg: "black".to_string(),
edit_bg: "blue".to_string(),
focus_bg: "magenta".to_string(),
primary_text: "white".to_string(),
secondary_text: "gray".to_string(),
highlight_text: "cyan".to_string(),
success: "green".to_string(),
warning: "yellow".to_string(),
error: "red".to_string(),
info: "cyan".to_string(),
timer_text: "yellow".to_string(),
badge: "magenta".to_string(),
},
);
let config = Config {
integrations: IntegrationConfig::default(),
theme: ThemeConfig {
active: "custom1".to_string(),
custom,
},
columns: ColumnVisibilityConfig::default(),
};
let theme = config.get_theme();
assert!(matches!(theme.active_border, Color::Red));
}
#[test]
fn test_all_predefined_theme_methods() {
let default = Theme::default_theme();
let kanagawa = Theme::kanagawa();
let catppuccin = Theme::catppuccin();
let gruvbox = Theme::gruvbox();
let monokai = Theme::monokai();
let dracula = Theme::dracula();
let everforest = Theme::everforest();
let terminal = Theme::terminal();
assert!(matches!(default.active_border, Color::Cyan));
assert!(matches!(kanagawa.active_border, Color::Rgb(127, 180, 202)));
assert!(matches!(
catppuccin.active_border,
Color::Rgb(137, 180, 250)
));
assert!(matches!(gruvbox.active_border, Color::Rgb(131, 165, 152)));
assert!(matches!(monokai.active_border, Color::Rgb(102, 217, 239)));
assert!(matches!(dracula.active_border, Color::Rgb(139, 233, 253)));
assert!(matches!(
everforest.active_border,
Color::Rgb(131, 192, 146)
));
assert!(matches!(terminal.active_border, Color::Cyan));
}
#[test]
fn test_parse_color_hex_edge_cases() {
assert!(matches!(parse_color("#ffffff"), Color::Rgb(255, 255, 255)));
assert!(matches!(parse_color("#000000"), Color::Rgb(0, 0, 0)));
assert!(matches!(parse_color("#FFFFFF"), Color::Rgb(255, 255, 255)));
assert!(matches!(parse_color("#ABC"), Color::Rgb(170, 187, 204)));
assert!(matches!(parse_color("#FfFfFf"), Color::Rgb(255, 255, 255)));
assert!(matches!(parse_color("#FF"), Color::White)); assert!(matches!(parse_color("#FFFFFFF"), Color::White));
assert!(matches!(parse_color("#GGGGGG"), Color::White)); assert!(matches!(parse_color("#XYZ"), Color::White)); }
#[test]
fn test_parse_color_rgb_edge_cases() {
assert!(matches!(parse_color("0, 0, 0"), Color::Rgb(0, 0, 0)));
assert!(matches!(
parse_color("255, 255, 255"),
Color::Rgb(255, 255, 255)
));
assert!(matches!(
parse_color("(100, 150, 200)"),
Color::Rgb(100, 150, 200)
));
assert!(matches!(parse_color("10,20,30"), Color::Rgb(10, 20, 30)));
assert!(matches!(
parse_color(" 50 , 100 , 150 "),
Color::Rgb(50, 100, 150)
));
assert!(matches!(parse_color("256, 100, 100"), Color::White)); assert!(matches!(parse_color("100, 300, 100"), Color::White)); assert!(matches!(parse_color("100, 100, 256"), Color::White)); assert!(matches!(parse_color("-1, 100, 100"), Color::White)); assert!(matches!(parse_color("abc, 100, 100"), Color::White));
assert!(matches!(parse_color("100, 100"), Color::White)); assert!(matches!(parse_color("100, 100, 100, 100"), Color::White)); }
#[test]
fn test_parse_color_named_variations() {
assert!(matches!(parse_color("RED"), Color::Red));
assert!(matches!(parse_color("Red"), Color::Red));
assert!(matches!(parse_color("rEd"), Color::Red));
assert!(matches!(parse_color("gray"), Color::Gray));
assert!(matches!(parse_color("grey"), Color::Gray));
assert!(matches!(parse_color("GRAY"), Color::Gray));
assert!(matches!(parse_color("GREY"), Color::Gray));
assert!(matches!(parse_color("darkgray"), Color::DarkGray));
assert!(matches!(parse_color("darkgrey"), Color::DarkGray));
assert!(matches!(parse_color("LightRed"), Color::LightRed));
assert!(matches!(parse_color("LightGreen"), Color::LightGreen));
assert!(matches!(parse_color("LightYellow"), Color::LightYellow));
assert!(matches!(parse_color("LightBlue"), Color::LightBlue));
assert!(matches!(parse_color("LightMagenta"), Color::LightMagenta));
assert!(matches!(parse_color("LightCyan"), Color::LightCyan));
assert!(matches!(parse_color("reset"), Color::Reset));
assert!(matches!(parse_color("terminal"), Color::Reset));
assert!(matches!(parse_color("default"), Color::Reset));
assert!(matches!(parse_color("RESET"), Color::Reset));
assert!(matches!(parse_color("Terminal"), Color::Reset));
}
#[test]
fn test_parse_color_whitespace_handling() {
assert!(matches!(parse_color(" red "), Color::Red));
assert!(matches!(parse_color("\tblue\t"), Color::Blue));
assert!(matches!(parse_color(" #FF0000 "), Color::Rgb(255, 0, 0)));
assert!(matches!(
parse_color(" 100, 200, 150 "),
Color::Rgb(100, 200, 150)
));
}
#[test]
fn test_parse_color_empty_and_whitespace() {
assert!(matches!(parse_color(""), Color::White));
assert!(matches!(parse_color(" "), Color::White));
assert!(matches!(parse_color("\t\t"), Color::White));
}
#[test]
fn test_theme_color_consistency() {
let themes = vec![
Theme::default_theme(),
Theme::kanagawa(),
Theme::catppuccin(),
Theme::gruvbox(),
Theme::monokai(),
Theme::dracula(),
Theme::everforest(),
Theme::terminal(),
];
for theme in themes {
let _ = theme.active_border;
let _ = theme.inactive_border;
let _ = theme.searching_border;
let _ = theme.selected_bg;
let _ = theme.selected_inactive_bg;
let _ = theme.visual_bg;
let _ = theme.timer_active_bg;
let _ = theme.row_alternate_bg;
let _ = theme.edit_bg;
let _ = theme.primary_text;
let _ = theme.secondary_text;
let _ = theme.highlight_text;
let _ = theme.success;
let _ = theme.warning;
let _ = theme.error;
let _ = theme.info;
let _ = theme.timer_text;
let _ = theme.badge;
}
}
#[test]
fn test_custom_theme_colors_all_formats() {
let custom_colors = CustomThemeColors {
active_border: "#FF0000".to_string(), inactive_border: "darkgray".to_string(), searching_border: "255, 255, 0".to_string(), selected_bg: "#00F".to_string(), selected_inactive_bg: "Black".to_string(), visual_bg: "0, 128, 255".to_string(), timer_active_bg: "lightgreen".to_string(), row_alternate_bg: "#111".to_string(), edit_bg: "(50, 100, 150)".to_string(), focus_bg: "magenta".to_string(), primary_text: "white".to_string(), secondary_text: "128, 128, 128".to_string(), highlight_text: "#0FF".to_string(), success: "green".to_string(), warning: "#FFAA00".to_string(), error: "255, 0, 0".to_string(), info: "cyan".to_string(), timer_text: "#FFA500".to_string(), badge: "magenta".to_string(), };
let theme = Theme::from_custom(&custom_colors);
assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0))); assert!(matches!(theme.inactive_border, Color::DarkGray)); assert!(matches!(theme.searching_border, Color::Rgb(255, 255, 0))); assert!(matches!(theme.selected_bg, Color::Rgb(0, 0, 255))); assert!(matches!(theme.selected_inactive_bg, Color::Black)); assert!(matches!(theme.visual_bg, Color::Rgb(0, 128, 255))); assert!(matches!(theme.timer_active_bg, Color::LightGreen)); assert!(matches!(theme.row_alternate_bg, Color::Rgb(17, 17, 17))); }
#[test]
fn test_multiple_custom_themes_in_config() {
let toml_str = r##"
[theme]
active = "theme2"
[theme.custom.theme1]
active_border = "red"
inactive_border = "darkgray"
searching_border = "yellow"
selected_bg = "blue"
selected_inactive_bg = "black"
visual_bg = "cyan"
timer_active_bg = "green"
row_alternate_bg = "black"
edit_bg = "blue"
focus_bg = "magenta"
primary_text = "white"
secondary_text = "gray"
highlight_text = "cyan"
success = "green"
warning = "yellow"
error = "red"
info = "cyan"
timer_text = "yellow"
badge = "magenta"
[theme.custom.theme2]
active_border = "#FF00FF"
inactive_border = "darkgray"
searching_border = "yellow"
selected_bg = "blue"
selected_inactive_bg = "black"
visual_bg = "cyan"
timer_active_bg = "green"
row_alternate_bg = "black"
edit_bg = "blue"
focus_bg = "magenta"
primary_text = "white"
secondary_text = "gray"
highlight_text = "cyan"
success = "green"
warning = "yellow"
error = "red"
info = "cyan"
timer_text = "yellow"
badge = "magenta"
"##;
let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
assert_eq!(config.theme.active, "theme2");
assert_eq!(config.theme.custom.len(), 2);
assert!(config.theme.custom.contains_key("theme1"));
assert!(config.theme.custom.contains_key("theme2"));
let theme = config.get_theme();
assert!(matches!(theme.active_border, Color::Rgb(255, 0, 255)));
}
#[test]
fn test_theme_config_case_sensitivity() {
let theme_config = ThemeConfig {
active: "KANAGAWA".to_string(), custom: HashMap::new(),
};
let theme = theme_config.get_active_theme();
assert!(matches!(theme.active_border, Color::Cyan)); }
#[test]
fn test_custom_theme_overrides_predefined() {
let mut custom = HashMap::new();
custom.insert(
"default".to_string(),
CustomThemeColors {
active_border: "#FF0000".to_string(), inactive_border: "darkgray".to_string(),
searching_border: "yellow".to_string(),
selected_bg: "blue".to_string(),
selected_inactive_bg: "black".to_string(),
visual_bg: "cyan".to_string(),
timer_active_bg: "green".to_string(),
row_alternate_bg: "black".to_string(),
edit_bg: "blue".to_string(),
focus_bg: "magenta".to_string(),
primary_text: "white".to_string(),
secondary_text: "gray".to_string(),
highlight_text: "cyan".to_string(),
success: "green".to_string(),
warning: "yellow".to_string(),
error: "red".to_string(),
info: "cyan".to_string(),
timer_text: "yellow".to_string(),
badge: "magenta".to_string(),
},
);
let theme_config = ThemeConfig {
active: "default".to_string(),
custom,
};
let theme = theme_config.get_active_theme();
assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0)));
}
#[test]
fn test_parse_color_rgb_with_parentheses_and_spaces() {
assert!(matches!(
parse_color("(255, 128, 64)"),
Color::Rgb(255, 128, 64)
));
assert!(matches!(
parse_color("( 100 , 200 , 150 )"),
Color::Rgb(100, 200, 150)
));
let result = parse_color("(10,20,30)");
assert!(matches!(result, Color::Rgb(10, 20, 30)));
}
#[test]
fn test_theme_serialization() {
let theme_config = ThemeConfig {
active: "gruvbox".to_string(),
custom: HashMap::new(),
};
let serialized = toml::to_string(&theme_config).expect("Failed to serialize");
assert!(serialized.contains("active"));
assert!(serialized.contains("gruvbox"));
let deserialized: ThemeConfig = toml::from_str(&serialized).expect("Failed to deserialize");
assert_eq!(deserialized.active, "gruvbox");
assert!(deserialized.custom.is_empty());
}
}