1use anyhow::{Context, Result};
2use ratatui::style::Color;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct Config {
11 #[serde(default)]
12 pub integrations: IntegrationConfig,
13
14 #[serde(default)]
15 pub theme: ThemeConfig,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct IntegrationConfig {
20 #[serde(default)]
22 pub default_tracker: Option<String>,
23
24 #[serde(default)]
26 pub trackers: HashMap<String, TrackerConfig>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct TrackerConfig {
31 #[serde(default)]
32 pub enabled: bool,
33 #[serde(default)]
34 pub base_url: String,
35 #[serde(default)]
37 pub ticket_patterns: Vec<String>,
38 #[serde(default)]
40 pub browse_url: String,
41 #[serde(default)]
43 pub worklog_url: String,
44}
45
46impl Config {
47 pub fn load() -> Result<Self> {
49 let config_path = Self::get_config_path();
50
51 if config_path.exists() {
52 let contents = fs::read_to_string(&config_path)
53 .context(format!("Failed to read config file: {:?}", config_path))?;
54 let config: Config =
55 toml::from_str(&contents).context("Failed to parse config TOML")?;
56 Ok(config)
57 } else {
58 Ok(Config::default())
59 }
60 }
61
62 fn get_config_path() -> PathBuf {
65 #[cfg(unix)]
67 {
68 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
69 return PathBuf::from(xdg_config)
70 .join("work-tuimer")
71 .join("config.toml");
72 }
73 if let Some(home) = std::env::var_os("HOME") {
75 return PathBuf::from(home)
76 .join(".config")
77 .join("work-tuimer")
78 .join("config.toml");
79 }
80 }
81
82 #[cfg(windows)]
84 {
85 if let Some(config_dir) = dirs::config_dir() {
86 return config_dir.join("work-tuimer").join("config.toml");
87 }
88 }
89
90 PathBuf::from("./config.toml")
92 }
93
94 pub fn has_integrations(&self) -> bool {
96 self.integrations
97 .trackers
98 .values()
99 .any(|tracker| tracker.enabled && !tracker.base_url.is_empty())
100 }
101
102 pub fn get_theme(&self) -> Theme {
104 self.theme.get_active_theme()
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ThemeConfig {
111 #[serde(default = "default_theme_name")]
113 pub active: String,
114
115 #[serde(default)]
117 pub custom: HashMap<String, CustomThemeColors>,
118}
119
120fn default_theme_name() -> String {
121 "default".to_string()
122}
123
124impl Default for ThemeConfig {
125 fn default() -> Self {
126 Self {
127 active: default_theme_name(),
128 custom: HashMap::new(),
129 }
130 }
131}
132
133impl ThemeConfig {
134 pub fn get_active_theme(&self) -> Theme {
136 if let Some(custom_colors) = self.custom.get(&self.active) {
138 return Theme::from_custom(custom_colors);
139 }
140
141 match self.active.as_str() {
143 "default" => Theme::default_theme(),
144 "kanagawa" => Theme::kanagawa(),
145 "catppuccin" => Theme::catppuccin(),
146 "gruvbox" => Theme::gruvbox(),
147 "monokai" => Theme::monokai(),
148 "dracula" => Theme::dracula(),
149 "everforest" => Theme::everforest(),
150 "terminal" => Theme::terminal(),
151 _ => {
152 Theme::default_theme()
154 }
155 }
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct CustomThemeColors {
162 pub active_border: String,
164 pub inactive_border: String,
165 pub searching_border: String,
166
167 pub selected_bg: String,
169 pub selected_inactive_bg: String,
170 pub visual_bg: String,
171 pub timer_active_bg: String,
172 pub row_alternate_bg: String,
173 pub edit_bg: String,
174 pub focus_bg: String,
175
176 pub primary_text: String,
178 pub secondary_text: String,
179 pub highlight_text: String,
180
181 pub success: String,
183 pub warning: String,
184 pub error: String,
185 pub info: String,
186
187 pub timer_text: String,
189 pub badge: String,
190}
191
192#[derive(Debug, Clone)]
194pub struct Theme {
195 pub active_border: Color,
197 #[allow(dead_code)]
198 pub inactive_border: Color,
199 #[allow(dead_code)]
200 pub searching_border: Color,
201
202 pub selected_bg: Color,
204 #[allow(dead_code)]
205 pub selected_inactive_bg: Color,
206 pub visual_bg: Color,
207 pub timer_active_bg: Color,
208 pub row_alternate_bg: Color,
209 pub edit_bg: Color,
210 pub focus_bg: Color,
211
212 pub primary_text: Color,
214 pub secondary_text: Color,
215 pub highlight_text: Color,
216
217 pub success: Color,
219 pub warning: Color,
220 pub error: Color,
221 pub info: Color,
222
223 pub timer_text: Color,
225 pub badge: Color,
226}
227
228impl Theme {
229 pub fn default_theme() -> Self {
231 Self {
232 active_border: Color::Cyan,
233 inactive_border: Color::DarkGray,
234 searching_border: Color::Yellow,
235 selected_bg: Color::Rgb(40, 40, 60),
236 selected_inactive_bg: Color::Rgb(30, 30, 45),
237 visual_bg: Color::Rgb(70, 130, 180),
238 timer_active_bg: Color::Rgb(34, 139, 34),
239 row_alternate_bg: Color::Rgb(25, 25, 35),
240 edit_bg: Color::Rgb(22, 78, 99),
241 focus_bg: Color::Rgb(80, 60, 120), primary_text: Color::White,
243 secondary_text: Color::Gray,
244 highlight_text: Color::Cyan,
245 success: Color::Green,
246 warning: Color::Yellow,
247 error: Color::LightRed,
248 info: Color::Cyan,
249 timer_text: Color::Yellow,
250 badge: Color::LightMagenta,
251 }
252 }
253
254 pub fn kanagawa() -> Self {
256 Self {
257 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), }
277 }
278
279 pub fn catppuccin() -> Self {
281 Self {
282 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), }
302 }
303
304 pub fn gruvbox() -> Self {
306 Self {
307 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), }
327 }
328
329 pub fn monokai() -> Self {
331 Self {
332 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), }
352 }
353
354 pub fn dracula() -> Self {
356 Self {
357 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), }
377 }
378
379 pub fn everforest() -> Self {
381 Self {
382 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), }
402 }
403
404 pub fn terminal() -> Self {
406 Self {
407 active_border: Color::Cyan,
408 inactive_border: Color::Reset,
409 searching_border: Color::Yellow,
410 selected_bg: Color::Blue,
411 selected_inactive_bg: Color::Reset,
412 visual_bg: Color::Blue,
413 timer_active_bg: Color::Green,
414 row_alternate_bg: Color::Reset,
415 edit_bg: Color::Cyan,
416 focus_bg: Color::Magenta, primary_text: Color::Reset,
418 secondary_text: Color::DarkGray,
419 highlight_text: Color::Cyan,
420 success: Color::Green,
421 warning: Color::Yellow,
422 error: Color::Red,
423 info: Color::Cyan,
424 timer_text: Color::Yellow,
425 badge: Color::Magenta,
426 }
427 }
428
429 pub fn from_custom(colors: &CustomThemeColors) -> Self {
431 Self {
432 active_border: parse_color(&colors.active_border),
433 inactive_border: parse_color(&colors.inactive_border),
434 searching_border: parse_color(&colors.searching_border),
435 selected_bg: parse_color(&colors.selected_bg),
436 selected_inactive_bg: parse_color(&colors.selected_inactive_bg),
437 visual_bg: parse_color(&colors.visual_bg),
438 timer_active_bg: parse_color(&colors.timer_active_bg),
439 row_alternate_bg: parse_color(&colors.row_alternate_bg),
440 edit_bg: parse_color(&colors.edit_bg),
441 focus_bg: parse_color(&colors.focus_bg),
442 primary_text: parse_color(&colors.primary_text),
443 secondary_text: parse_color(&colors.secondary_text),
444 highlight_text: parse_color(&colors.highlight_text),
445 success: parse_color(&colors.success),
446 warning: parse_color(&colors.warning),
447 error: parse_color(&colors.error),
448 info: parse_color(&colors.info),
449 timer_text: parse_color(&colors.timer_text),
450 badge: parse_color(&colors.badge),
451 }
452 }
453}
454
455fn parse_color(color_str: &str) -> Color {
457 let trimmed = color_str.trim();
458
459 if let Some(hex) = trimmed.strip_prefix('#') {
461 if hex.len() == 6 {
462 if let (Ok(r), Ok(g), Ok(b)) = (
463 u8::from_str_radix(&hex[0..2], 16),
464 u8::from_str_radix(&hex[2..4], 16),
465 u8::from_str_radix(&hex[4..6], 16),
466 ) {
467 return Color::Rgb(r, g, b);
468 }
469 } else if hex.len() == 3 {
470 if let (Ok(r), Ok(g), Ok(b)) = (
472 u8::from_str_radix(&hex[0..1].repeat(2), 16),
473 u8::from_str_radix(&hex[1..2].repeat(2), 16),
474 u8::from_str_radix(&hex[2..3].repeat(2), 16),
475 ) {
476 return Color::Rgb(r, g, b);
477 }
478 }
479 }
480
481 match trimmed.to_lowercase().as_str() {
483 "reset" | "terminal" | "default" => Color::Reset,
484 "black" => Color::Black,
485 "red" => Color::Red,
486 "green" => Color::Green,
487 "yellow" => Color::Yellow,
488 "blue" => Color::Blue,
489 "magenta" => Color::Magenta,
490 "cyan" => Color::Cyan,
491 "gray" | "grey" => Color::Gray,
492 "darkgray" | "darkgrey" => Color::DarkGray,
493 "lightred" => Color::LightRed,
494 "lightgreen" => Color::LightGreen,
495 "lightyellow" => Color::LightYellow,
496 "lightblue" => Color::LightBlue,
497 "lightmagenta" => Color::LightMagenta,
498 "lightcyan" => Color::LightCyan,
499 "white" => Color::White,
500 _ => {
501 let rgb_str = trimmed.trim_start_matches('(').trim_end_matches(')').trim();
504 let parts: Vec<&str> = rgb_str.split(',').map(|s| s.trim()).collect();
505 if parts.len() == 3
506 && let (Ok(r), Ok(g), Ok(b)) = (
507 parts[0].parse::<u8>(),
508 parts[1].parse::<u8>(),
509 parts[2].parse::<u8>(),
510 )
511 {
512 return Color::Rgb(r, g, b);
513 }
514 Color::White
516 }
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn test_default_config() {
526 let config = Config::default();
527 assert_eq!(config.integrations.default_tracker, None);
528 assert!(config.integrations.trackers.is_empty());
529 }
530
531 #[test]
532 fn test_config_serialization() {
533 let config = Config::default();
534 let toml_str = toml::to_string_pretty(&config).expect("Failed to serialize");
535 assert!(toml_str.contains("integrations"));
536 }
537
538 #[test]
539 fn test_config_deserialization() {
540 let toml_str = r#"
541[integrations]
542default_tracker = "my-jira"
543
544[integrations.trackers.my-jira]
545enabled = true
546base_url = "https://test.atlassian.net"
547ticket_patterns = ["^PROJ-\\d+$"]
548browse_url = "{base_url}/browse/{ticket}"
549worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1"
550 "#;
551
552 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
553 assert_eq!(
554 config.integrations.default_tracker,
555 Some("my-jira".to_string())
556 );
557 let tracker = config.integrations.trackers.get("my-jira").unwrap();
558 assert_eq!(tracker.base_url, "https://test.atlassian.net");
559 assert_eq!(tracker.ticket_patterns[0], "^PROJ-\\d+$");
560 }
561
562 #[test]
563 fn test_tracker_config_defaults() {
564 let tracker = TrackerConfig::default();
565 assert!(!tracker.enabled);
566 assert!(tracker.base_url.is_empty());
567 assert!(tracker.ticket_patterns.is_empty());
568 }
569
570 #[test]
573 fn test_default_theme_config() {
574 let theme_config = ThemeConfig::default();
575 assert_eq!(theme_config.active, "default");
576 assert!(theme_config.custom.is_empty());
577 }
578
579 #[test]
580 fn test_theme_config_get_default_theme() {
581 let theme_config = ThemeConfig::default();
582 let theme = theme_config.get_active_theme();
583 assert!(matches!(theme.active_border, Color::Cyan));
585 assert!(matches!(theme.error, Color::LightRed));
586 }
587
588 #[test]
589 fn test_theme_config_get_kanagawa_theme() {
590 let theme_config = ThemeConfig {
591 active: "kanagawa".to_string(),
592 custom: HashMap::new(),
593 };
594 let theme = theme_config.get_active_theme();
595 assert!(matches!(theme.active_border, Color::Rgb(127, 180, 202)));
597 }
598
599 #[test]
600 fn test_theme_config_get_all_predefined_themes() {
601 let theme_names = vec![
602 "default",
603 "kanagawa",
604 "catppuccin",
605 "gruvbox",
606 "monokai",
607 "dracula",
608 "everforest",
609 "terminal",
610 ];
611
612 for name in theme_names {
613 let theme_config = ThemeConfig {
614 active: name.to_string(),
615 custom: HashMap::new(),
616 };
617 let _theme = theme_config.get_active_theme();
618 }
620 }
621
622 #[test]
623 fn test_parse_color_hex_6_digit() {
624 let color = parse_color("#7e9cd8");
625 assert!(matches!(color, Color::Rgb(126, 156, 216)));
626
627 let color = parse_color("#FF0000");
628 assert!(matches!(color, Color::Rgb(255, 0, 0)));
629 }
630
631 #[test]
632 fn test_parse_color_hex_3_digit() {
633 let color = parse_color("#F00");
634 assert!(matches!(color, Color::Rgb(255, 0, 0)));
635
636 let color = parse_color("#0F0");
637 assert!(matches!(color, Color::Rgb(0, 255, 0)));
638
639 let color = parse_color("#00F");
640 assert!(matches!(color, Color::Rgb(0, 0, 255)));
641 }
642
643 #[test]
644 fn test_parse_color_rgb_tuple() {
645 let color = parse_color("255, 128, 64");
646 assert!(matches!(color, Color::Rgb(255, 128, 64)));
647
648 let color = parse_color("0,255,0");
649 assert!(matches!(color, Color::Rgb(0, 255, 0)));
650
651 let color = parse_color(" 100 , 200 , 150 ");
652 assert!(matches!(color, Color::Rgb(100, 200, 150)));
653 }
654
655 #[test]
656 fn test_parse_color_named_colors() {
657 assert!(matches!(parse_color("red"), Color::Red));
658 assert!(matches!(parse_color("Red"), Color::Red));
659 assert!(matches!(parse_color("RED"), Color::Red));
660 assert!(matches!(parse_color("green"), Color::Green));
661 assert!(matches!(parse_color("blue"), Color::Blue));
662 assert!(matches!(parse_color("yellow"), Color::Yellow));
663 assert!(matches!(parse_color("cyan"), Color::Cyan));
664 assert!(matches!(parse_color("magenta"), Color::Magenta));
665 assert!(matches!(parse_color("white"), Color::White));
666 assert!(matches!(parse_color("black"), Color::Black));
667 assert!(matches!(parse_color("gray"), Color::Gray));
668 assert!(matches!(parse_color("grey"), Color::Gray));
669 assert!(matches!(parse_color("darkgray"), Color::DarkGray));
670 assert!(matches!(parse_color("lightred"), Color::LightRed));
671 assert!(matches!(parse_color("lightgreen"), Color::LightGreen));
672 assert!(matches!(parse_color("lightyellow"), Color::LightYellow));
673 assert!(matches!(parse_color("lightblue"), Color::LightBlue));
674 assert!(matches!(parse_color("lightmagenta"), Color::LightMagenta));
675 assert!(matches!(parse_color("lightcyan"), Color::LightCyan));
676 assert!(matches!(parse_color("reset"), Color::Reset));
677 assert!(matches!(parse_color("terminal"), Color::Reset));
678 assert!(matches!(parse_color("default"), Color::Reset));
679 }
680
681 #[test]
682 fn test_parse_color_invalid_fallback() {
683 let color = parse_color("invalid_color");
685 assert!(matches!(color, Color::White));
686
687 let color = parse_color("#ZZZ");
688 assert!(matches!(color, Color::White));
689
690 let color = parse_color("300, 300, 300"); assert!(matches!(color, Color::White));
692 }
693
694 #[test]
695 fn test_custom_theme_deserialization() {
696 let toml_str = r##"
697[theme]
698active = "my-custom"
699
700[theme.custom.my-custom]
701active_border = "#7e9cd8"
702inactive_border = "darkgray"
703searching_border = "255, 160, 102"
704selected_bg = "#252b37"
705selected_inactive_bg = "#1e222c"
706visual_bg = "70, 130, 180"
707timer_active_bg = "green"
708row_alternate_bg = "#16191f"
709edit_bg = "#223d50"
710focus_bg = "magenta"
711primary_text = "white"
712secondary_text = "gray"
713highlight_text = "cyan"
714success = "lightgreen"
715warning = "yellow"
716error = "lightred"
717info = "cyan"
718timer_text = "#ffc777"
719badge = "lightmagenta"
720 "##;
721
722 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
723 assert_eq!(config.theme.active, "my-custom");
724 assert!(config.theme.custom.contains_key("my-custom"));
725
726 let custom_colors = config.theme.custom.get("my-custom").unwrap();
727 assert_eq!(custom_colors.active_border, "#7e9cd8");
728 assert_eq!(custom_colors.timer_active_bg, "green");
729 assert_eq!(custom_colors.searching_border, "255, 160, 102");
730 }
731
732 #[test]
733 fn test_custom_theme_from_config() {
734 let mut custom = HashMap::new();
735 custom.insert(
736 "test-theme".to_string(),
737 CustomThemeColors {
738 active_border: "#FF0000".to_string(),
739 inactive_border: "darkgray".to_string(),
740 searching_border: "yellow".to_string(),
741 selected_bg: "#252b37".to_string(),
742 selected_inactive_bg: "#1e222c".to_string(),
743 visual_bg: "blue".to_string(),
744 timer_active_bg: "green".to_string(),
745 row_alternate_bg: "#16191f".to_string(),
746 edit_bg: "cyan".to_string(),
747 focus_bg: "magenta".to_string(),
748 primary_text: "white".to_string(),
749 secondary_text: "gray".to_string(),
750 highlight_text: "cyan".to_string(),
751 success: "green".to_string(),
752 warning: "yellow".to_string(),
753 error: "red".to_string(),
754 info: "cyan".to_string(),
755 timer_text: "yellow".to_string(),
756 badge: "magenta".to_string(),
757 },
758 );
759
760 let theme_config = ThemeConfig {
761 active: "test-theme".to_string(),
762 custom,
763 };
764
765 let theme = theme_config.get_active_theme();
766 assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0)));
768 }
769
770 #[test]
771 fn test_fallback_to_default_when_custom_not_found() {
772 let theme_config = ThemeConfig {
773 active: "non-existent-theme".to_string(),
774 custom: HashMap::new(),
775 };
776
777 let theme = theme_config.get_active_theme();
778 assert!(matches!(theme.active_border, Color::Cyan));
780 assert!(matches!(theme.error, Color::LightRed));
781 }
782
783 #[test]
784 fn test_theme_from_custom_colors() {
785 let custom_colors = CustomThemeColors {
786 active_border: "#7e9cd8".to_string(),
787 inactive_border: "darkgray".to_string(),
788 searching_border: "yellow".to_string(),
789 selected_bg: "50, 50, 70".to_string(),
790 selected_inactive_bg: "#1e222c".to_string(),
791 visual_bg: "blue".to_string(),
792 timer_active_bg: "green".to_string(),
793 row_alternate_bg: "#000000".to_string(),
794 edit_bg: "cyan".to_string(),
795 focus_bg: "magenta".to_string(),
796 primary_text: "white".to_string(),
797 secondary_text: "gray".to_string(),
798 highlight_text: "cyan".to_string(),
799 success: "green".to_string(),
800 warning: "yellow".to_string(),
801 error: "red".to_string(),
802 info: "cyan".to_string(),
803 timer_text: "255, 199, 119".to_string(),
804 badge: "magenta".to_string(),
805 };
806
807 let theme = Theme::from_custom(&custom_colors);
808
809 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))); }
815
816 #[test]
817 fn test_config_get_theme() {
818 let mut custom = HashMap::new();
819 custom.insert(
820 "custom1".to_string(),
821 CustomThemeColors {
822 active_border: "red".to_string(),
823 inactive_border: "darkgray".to_string(),
824 searching_border: "yellow".to_string(),
825 selected_bg: "blue".to_string(),
826 selected_inactive_bg: "black".to_string(),
827 visual_bg: "cyan".to_string(),
828 timer_active_bg: "green".to_string(),
829 row_alternate_bg: "black".to_string(),
830 edit_bg: "blue".to_string(),
831 focus_bg: "magenta".to_string(),
832 primary_text: "white".to_string(),
833 secondary_text: "gray".to_string(),
834 highlight_text: "cyan".to_string(),
835 success: "green".to_string(),
836 warning: "yellow".to_string(),
837 error: "red".to_string(),
838 info: "cyan".to_string(),
839 timer_text: "yellow".to_string(),
840 badge: "magenta".to_string(),
841 },
842 );
843
844 let config = Config {
845 integrations: IntegrationConfig::default(),
846 theme: ThemeConfig {
847 active: "custom1".to_string(),
848 custom,
849 },
850 };
851
852 let theme = config.get_theme();
853 assert!(matches!(theme.active_border, Color::Red));
854 }
855
856 #[test]
859 fn test_all_predefined_theme_methods() {
860 let default = Theme::default_theme();
862 let kanagawa = Theme::kanagawa();
863 let catppuccin = Theme::catppuccin();
864 let gruvbox = Theme::gruvbox();
865 let monokai = Theme::monokai();
866 let dracula = Theme::dracula();
867 let everforest = Theme::everforest();
868 let terminal = Theme::terminal();
869
870 assert!(matches!(default.active_border, Color::Cyan));
872 assert!(matches!(kanagawa.active_border, Color::Rgb(127, 180, 202)));
873 assert!(matches!(
874 catppuccin.active_border,
875 Color::Rgb(137, 180, 250)
876 ));
877 assert!(matches!(gruvbox.active_border, Color::Rgb(131, 165, 152)));
878 assert!(matches!(monokai.active_border, Color::Rgb(102, 217, 239)));
879 assert!(matches!(dracula.active_border, Color::Rgb(139, 233, 253)));
880 assert!(matches!(
881 everforest.active_border,
882 Color::Rgb(131, 192, 146)
883 ));
884 assert!(matches!(terminal.active_border, Color::Cyan));
885 }
886
887 #[test]
888 fn test_parse_color_hex_edge_cases() {
889 assert!(matches!(parse_color("#ffffff"), Color::Rgb(255, 255, 255)));
891 assert!(matches!(parse_color("#000000"), Color::Rgb(0, 0, 0)));
892
893 assert!(matches!(parse_color("#FFFFFF"), Color::Rgb(255, 255, 255)));
895 assert!(matches!(parse_color("#ABC"), Color::Rgb(170, 187, 204)));
896
897 assert!(matches!(parse_color("#FfFfFf"), Color::Rgb(255, 255, 255)));
899
900 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)); }
908
909 #[test]
910 fn test_parse_color_rgb_edge_cases() {
911 assert!(matches!(parse_color("0, 0, 0"), Color::Rgb(0, 0, 0)));
913 assert!(matches!(
914 parse_color("255, 255, 255"),
915 Color::Rgb(255, 255, 255)
916 ));
917
918 assert!(matches!(
920 parse_color("(100, 150, 200)"),
921 Color::Rgb(100, 150, 200)
922 ));
923
924 assert!(matches!(parse_color("10,20,30"), Color::Rgb(10, 20, 30)));
926 assert!(matches!(
927 parse_color(" 50 , 100 , 150 "),
928 Color::Rgb(50, 100, 150)
929 ));
930
931 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)); }
942
943 #[test]
944 fn test_parse_color_named_variations() {
945 assert!(matches!(parse_color("RED"), Color::Red));
947 assert!(matches!(parse_color("Red"), Color::Red));
948 assert!(matches!(parse_color("rEd"), Color::Red));
949
950 assert!(matches!(parse_color("gray"), Color::Gray));
952 assert!(matches!(parse_color("grey"), Color::Gray));
953 assert!(matches!(parse_color("GRAY"), Color::Gray));
954 assert!(matches!(parse_color("GREY"), Color::Gray));
955 assert!(matches!(parse_color("darkgray"), Color::DarkGray));
956 assert!(matches!(parse_color("darkgrey"), Color::DarkGray));
957
958 assert!(matches!(parse_color("LightRed"), Color::LightRed));
960 assert!(matches!(parse_color("LightGreen"), Color::LightGreen));
961 assert!(matches!(parse_color("LightYellow"), Color::LightYellow));
962 assert!(matches!(parse_color("LightBlue"), Color::LightBlue));
963 assert!(matches!(parse_color("LightMagenta"), Color::LightMagenta));
964 assert!(matches!(parse_color("LightCyan"), Color::LightCyan));
965
966 assert!(matches!(parse_color("reset"), Color::Reset));
968 assert!(matches!(parse_color("terminal"), Color::Reset));
969 assert!(matches!(parse_color("default"), Color::Reset));
970 assert!(matches!(parse_color("RESET"), Color::Reset));
971 assert!(matches!(parse_color("Terminal"), Color::Reset));
972 }
973
974 #[test]
975 fn test_parse_color_whitespace_handling() {
976 assert!(matches!(parse_color(" red "), Color::Red));
978 assert!(matches!(parse_color("\tblue\t"), Color::Blue));
979 assert!(matches!(parse_color(" #FF0000 "), Color::Rgb(255, 0, 0)));
980 assert!(matches!(
981 parse_color(" 100, 200, 150 "),
982 Color::Rgb(100, 200, 150)
983 ));
984 }
985
986 #[test]
987 fn test_parse_color_empty_and_whitespace() {
988 assert!(matches!(parse_color(""), Color::White));
990 assert!(matches!(parse_color(" "), Color::White));
991 assert!(matches!(parse_color("\t\t"), Color::White));
992 }
993
994 #[test]
995 fn test_theme_color_consistency() {
996 let themes = vec![
999 Theme::default_theme(),
1000 Theme::kanagawa(),
1001 Theme::catppuccin(),
1002 Theme::gruvbox(),
1003 Theme::monokai(),
1004 Theme::dracula(),
1005 Theme::everforest(),
1006 Theme::terminal(),
1007 ];
1008
1009 for theme in themes {
1010 let _ = theme.active_border;
1012 let _ = theme.inactive_border;
1013 let _ = theme.searching_border;
1014 let _ = theme.selected_bg;
1015 let _ = theme.selected_inactive_bg;
1016 let _ = theme.visual_bg;
1017 let _ = theme.timer_active_bg;
1018 let _ = theme.row_alternate_bg;
1019 let _ = theme.edit_bg;
1020 let _ = theme.primary_text;
1021 let _ = theme.secondary_text;
1022 let _ = theme.highlight_text;
1023 let _ = theme.success;
1024 let _ = theme.warning;
1025 let _ = theme.error;
1026 let _ = theme.info;
1027 let _ = theme.timer_text;
1028 let _ = theme.badge;
1029 }
1030 }
1031
1032 #[test]
1033 fn test_custom_theme_colors_all_formats() {
1034 let custom_colors = CustomThemeColors {
1035 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(), };
1055
1056 let theme = Theme::from_custom(&custom_colors);
1057
1058 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))); }
1068
1069 #[test]
1070 fn test_multiple_custom_themes_in_config() {
1071 let toml_str = r##"
1072[theme]
1073active = "theme2"
1074
1075[theme.custom.theme1]
1076active_border = "red"
1077inactive_border = "darkgray"
1078searching_border = "yellow"
1079selected_bg = "blue"
1080selected_inactive_bg = "black"
1081visual_bg = "cyan"
1082timer_active_bg = "green"
1083row_alternate_bg = "black"
1084edit_bg = "blue"
1085focus_bg = "magenta"
1086primary_text = "white"
1087secondary_text = "gray"
1088highlight_text = "cyan"
1089success = "green"
1090warning = "yellow"
1091error = "red"
1092info = "cyan"
1093timer_text = "yellow"
1094badge = "magenta"
1095
1096[theme.custom.theme2]
1097active_border = "#FF00FF"
1098inactive_border = "darkgray"
1099searching_border = "yellow"
1100selected_bg = "blue"
1101selected_inactive_bg = "black"
1102visual_bg = "cyan"
1103timer_active_bg = "green"
1104row_alternate_bg = "black"
1105edit_bg = "blue"
1106focus_bg = "magenta"
1107primary_text = "white"
1108secondary_text = "gray"
1109highlight_text = "cyan"
1110success = "green"
1111warning = "yellow"
1112error = "red"
1113info = "cyan"
1114timer_text = "yellow"
1115badge = "magenta"
1116 "##;
1117
1118 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
1119 assert_eq!(config.theme.active, "theme2");
1120 assert_eq!(config.theme.custom.len(), 2);
1121 assert!(config.theme.custom.contains_key("theme1"));
1122 assert!(config.theme.custom.contains_key("theme2"));
1123
1124 let theme = config.get_theme();
1126 assert!(matches!(theme.active_border, Color::Rgb(255, 0, 255)));
1127 }
1128
1129 #[test]
1130 fn test_theme_config_case_sensitivity() {
1131 let theme_config = ThemeConfig {
1133 active: "KANAGAWA".to_string(), custom: HashMap::new(),
1135 };
1136
1137 let theme = theme_config.get_active_theme();
1138 assert!(matches!(theme.active_border, Color::Cyan)); }
1141
1142 #[test]
1143 fn test_custom_theme_overrides_predefined() {
1144 let mut custom = HashMap::new();
1146 custom.insert(
1147 "default".to_string(),
1148 CustomThemeColors {
1149 active_border: "#FF0000".to_string(), inactive_border: "darkgray".to_string(),
1151 searching_border: "yellow".to_string(),
1152 selected_bg: "blue".to_string(),
1153 selected_inactive_bg: "black".to_string(),
1154 visual_bg: "cyan".to_string(),
1155 timer_active_bg: "green".to_string(),
1156 row_alternate_bg: "black".to_string(),
1157 edit_bg: "blue".to_string(),
1158 focus_bg: "magenta".to_string(),
1159 primary_text: "white".to_string(),
1160 secondary_text: "gray".to_string(),
1161 highlight_text: "cyan".to_string(),
1162 success: "green".to_string(),
1163 warning: "yellow".to_string(),
1164 error: "red".to_string(),
1165 info: "cyan".to_string(),
1166 timer_text: "yellow".to_string(),
1167 badge: "magenta".to_string(),
1168 },
1169 );
1170
1171 let theme_config = ThemeConfig {
1172 active: "default".to_string(),
1173 custom,
1174 };
1175
1176 let theme = theme_config.get_active_theme();
1177 assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0)));
1179 }
1180
1181 #[test]
1182 fn test_parse_color_rgb_with_parentheses_and_spaces() {
1183 assert!(matches!(
1185 parse_color("(255, 128, 64)"),
1186 Color::Rgb(255, 128, 64)
1187 ));
1188 assert!(matches!(
1189 parse_color("( 100 , 200 , 150 )"),
1190 Color::Rgb(100, 200, 150)
1191 ));
1192
1193 let result = parse_color("(10,20,30)");
1195 assert!(matches!(result, Color::Rgb(10, 20, 30)));
1196 }
1197
1198 #[test]
1199 fn test_theme_serialization() {
1200 let theme_config = ThemeConfig {
1202 active: "gruvbox".to_string(),
1203 custom: HashMap::new(),
1204 };
1205
1206 let serialized = toml::to_string(&theme_config).expect("Failed to serialize");
1207 assert!(serialized.contains("active"));
1208 assert!(serialized.contains("gruvbox"));
1209
1210 let deserialized: ThemeConfig = toml::from_str(&serialized).expect("Failed to deserialize");
1211 assert_eq!(deserialized.active, "gruvbox");
1212 assert!(deserialized.custom.is_empty());
1213 }
1214}