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 #[serde(default)]
18 pub columns: ColumnVisibilityConfig,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct IntegrationConfig {
23 #[serde(default)]
25 pub default_tracker: Option<String>,
26
27 #[serde(default)]
29 pub trackers: HashMap<String, TrackerConfig>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct TrackerConfig {
34 #[serde(default)]
35 pub enabled: bool,
36 #[serde(default)]
37 pub base_url: String,
38 #[serde(default)]
40 pub ticket_patterns: Vec<String>,
41 #[serde(default)]
43 pub browse_url: String,
44 #[serde(default)]
46 pub worklog_url: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ColumnVisibilityConfig {
51 #[serde(default = "default_show_project")]
52 pub project: bool,
53 #[serde(default = "default_show_customer")]
54 pub customer: bool,
55 #[serde(default = "default_show_description")]
56 pub description: bool,
57}
58
59fn default_show_project() -> bool {
60 true
61}
62
63fn default_show_customer() -> bool {
64 false
65}
66
67fn default_show_description() -> bool {
68 true
69}
70
71impl Default for ColumnVisibilityConfig {
72 fn default() -> Self {
73 Self {
74 project: default_show_project(),
75 customer: default_show_customer(),
76 description: default_show_description(),
77 }
78 }
79}
80
81impl Config {
82 pub fn load() -> Result<Self> {
84 let config_path = Self::get_config_path();
85
86 if config_path.exists() {
87 let contents = fs::read_to_string(&config_path)
88 .context(format!("Failed to read config file: {:?}", config_path))?;
89 let config: Config =
90 toml::from_str(&contents).context("Failed to parse config TOML")?;
91 Ok(config)
92 } else {
93 Ok(Config::default())
94 }
95 }
96
97 fn get_config_path() -> PathBuf {
100 #[cfg(unix)]
102 {
103 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
104 return PathBuf::from(xdg_config)
105 .join("work-tuimer")
106 .join("config.toml");
107 }
108 if let Some(home) = std::env::var_os("HOME") {
110 return PathBuf::from(home)
111 .join(".config")
112 .join("work-tuimer")
113 .join("config.toml");
114 }
115 }
116
117 #[cfg(windows)]
119 {
120 if let Some(config_dir) = dirs::config_dir() {
121 return config_dir.join("work-tuimer").join("config.toml");
122 }
123 }
124
125 PathBuf::from("./config.toml")
127 }
128
129 pub fn has_integrations(&self) -> bool {
131 self.integrations
132 .trackers
133 .values()
134 .any(|tracker| tracker.enabled && !tracker.base_url.is_empty())
135 }
136
137 pub fn get_theme(&self) -> Theme {
139 self.theme.get_active_theme()
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ThemeConfig {
146 #[serde(default = "default_theme_name")]
148 pub active: String,
149
150 #[serde(default)]
152 pub custom: HashMap<String, CustomThemeColors>,
153}
154
155fn default_theme_name() -> String {
156 "default".to_string()
157}
158
159impl Default for ThemeConfig {
160 fn default() -> Self {
161 Self {
162 active: default_theme_name(),
163 custom: HashMap::new(),
164 }
165 }
166}
167
168impl ThemeConfig {
169 pub fn get_active_theme(&self) -> Theme {
171 if let Some(custom_colors) = self.custom.get(&self.active) {
173 return Theme::from_custom(custom_colors);
174 }
175
176 match self.active.as_str() {
178 "default" => Theme::default_theme(),
179 "kanagawa" => Theme::kanagawa(),
180 "catppuccin" => Theme::catppuccin(),
181 "gruvbox" => Theme::gruvbox(),
182 "monokai" => Theme::monokai(),
183 "dracula" => Theme::dracula(),
184 "everforest" => Theme::everforest(),
185 "terminal" => Theme::terminal(),
186 _ => {
187 Theme::default_theme()
189 }
190 }
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct CustomThemeColors {
197 pub active_border: String,
199 pub inactive_border: String,
200 pub searching_border: String,
201
202 pub selected_bg: String,
204 pub selected_inactive_bg: String,
205 pub visual_bg: String,
206 pub timer_active_bg: String,
207 pub row_alternate_bg: String,
208 pub edit_bg: String,
209 pub focus_bg: String,
210
211 pub primary_text: String,
213 pub secondary_text: String,
214 pub highlight_text: String,
215
216 pub success: String,
218 pub warning: String,
219 pub error: String,
220 pub info: String,
221
222 pub timer_text: String,
224 pub badge: String,
225}
226
227#[derive(Debug, Clone)]
229pub struct Theme {
230 pub active_border: Color,
232 #[allow(dead_code)]
233 pub inactive_border: Color,
234 #[allow(dead_code)]
235 pub searching_border: Color,
236
237 pub selected_bg: Color,
239 #[allow(dead_code)]
240 pub selected_inactive_bg: Color,
241 pub visual_bg: Color,
242 pub timer_active_bg: Color,
243 pub row_alternate_bg: Color,
244 pub edit_bg: Color,
245 pub focus_bg: Color,
246
247 pub primary_text: Color,
249 pub secondary_text: Color,
250 pub highlight_text: Color,
251
252 pub success: Color,
254 pub warning: Color,
255 pub error: Color,
256 pub info: Color,
257
258 pub timer_text: Color,
260 pub badge: Color,
261}
262
263impl Theme {
264 pub fn default_theme() -> Self {
266 Self {
267 active_border: Color::Cyan,
268 inactive_border: Color::DarkGray,
269 searching_border: Color::Yellow,
270 selected_bg: Color::Rgb(40, 40, 60),
271 selected_inactive_bg: Color::Rgb(30, 30, 45),
272 visual_bg: Color::Rgb(70, 130, 180),
273 timer_active_bg: Color::Rgb(34, 139, 34),
274 row_alternate_bg: Color::Rgb(25, 25, 35),
275 edit_bg: Color::Rgb(22, 78, 99),
276 focus_bg: Color::Rgb(80, 60, 120), primary_text: Color::White,
278 secondary_text: Color::Gray,
279 highlight_text: Color::Cyan,
280 success: Color::Green,
281 warning: Color::Yellow,
282 error: Color::LightRed,
283 info: Color::Cyan,
284 timer_text: Color::Yellow,
285 badge: Color::LightMagenta,
286 }
287 }
288
289 pub fn kanagawa() -> Self {
291 Self {
292 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), }
312 }
313
314 pub fn catppuccin() -> Self {
316 Self {
317 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), }
337 }
338
339 pub fn gruvbox() -> Self {
341 Self {
342 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), }
362 }
363
364 pub fn monokai() -> Self {
366 Self {
367 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), }
387 }
388
389 pub fn dracula() -> Self {
391 Self {
392 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), }
412 }
413
414 pub fn everforest() -> Self {
416 Self {
417 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), }
437 }
438
439 pub fn terminal() -> Self {
441 Self {
442 active_border: Color::Cyan,
443 inactive_border: Color::Reset,
444 searching_border: Color::Yellow,
445 selected_bg: Color::Blue,
446 selected_inactive_bg: Color::Reset,
447 visual_bg: Color::Blue,
448 timer_active_bg: Color::Green,
449 row_alternate_bg: Color::Reset,
450 edit_bg: Color::Cyan,
451 focus_bg: Color::Magenta, primary_text: Color::Reset,
453 secondary_text: Color::DarkGray,
454 highlight_text: Color::Cyan,
455 success: Color::Green,
456 warning: Color::Yellow,
457 error: Color::Red,
458 info: Color::Cyan,
459 timer_text: Color::Yellow,
460 badge: Color::Magenta,
461 }
462 }
463
464 pub fn from_custom(colors: &CustomThemeColors) -> Self {
466 Self {
467 active_border: parse_color(&colors.active_border),
468 inactive_border: parse_color(&colors.inactive_border),
469 searching_border: parse_color(&colors.searching_border),
470 selected_bg: parse_color(&colors.selected_bg),
471 selected_inactive_bg: parse_color(&colors.selected_inactive_bg),
472 visual_bg: parse_color(&colors.visual_bg),
473 timer_active_bg: parse_color(&colors.timer_active_bg),
474 row_alternate_bg: parse_color(&colors.row_alternate_bg),
475 edit_bg: parse_color(&colors.edit_bg),
476 focus_bg: parse_color(&colors.focus_bg),
477 primary_text: parse_color(&colors.primary_text),
478 secondary_text: parse_color(&colors.secondary_text),
479 highlight_text: parse_color(&colors.highlight_text),
480 success: parse_color(&colors.success),
481 warning: parse_color(&colors.warning),
482 error: parse_color(&colors.error),
483 info: parse_color(&colors.info),
484 timer_text: parse_color(&colors.timer_text),
485 badge: parse_color(&colors.badge),
486 }
487 }
488}
489
490fn parse_color(color_str: &str) -> Color {
492 let trimmed = color_str.trim();
493
494 if let Some(hex) = trimmed.strip_prefix('#') {
496 if hex.len() == 6 {
497 if let (Ok(r), Ok(g), Ok(b)) = (
498 u8::from_str_radix(&hex[0..2], 16),
499 u8::from_str_radix(&hex[2..4], 16),
500 u8::from_str_radix(&hex[4..6], 16),
501 ) {
502 return Color::Rgb(r, g, b);
503 }
504 } else if hex.len() == 3 {
505 if let (Ok(r), Ok(g), Ok(b)) = (
507 u8::from_str_radix(&hex[0..1].repeat(2), 16),
508 u8::from_str_radix(&hex[1..2].repeat(2), 16),
509 u8::from_str_radix(&hex[2..3].repeat(2), 16),
510 ) {
511 return Color::Rgb(r, g, b);
512 }
513 }
514 }
515
516 match trimmed.to_lowercase().as_str() {
518 "reset" | "terminal" | "default" => Color::Reset,
519 "black" => Color::Black,
520 "red" => Color::Red,
521 "green" => Color::Green,
522 "yellow" => Color::Yellow,
523 "blue" => Color::Blue,
524 "magenta" => Color::Magenta,
525 "cyan" => Color::Cyan,
526 "gray" | "grey" => Color::Gray,
527 "darkgray" | "darkgrey" => Color::DarkGray,
528 "lightred" => Color::LightRed,
529 "lightgreen" => Color::LightGreen,
530 "lightyellow" => Color::LightYellow,
531 "lightblue" => Color::LightBlue,
532 "lightmagenta" => Color::LightMagenta,
533 "lightcyan" => Color::LightCyan,
534 "white" => Color::White,
535 _ => {
536 let rgb_str = trimmed.trim_start_matches('(').trim_end_matches(')').trim();
539 let parts: Vec<&str> = rgb_str.split(',').map(|s| s.trim()).collect();
540 if parts.len() == 3
541 && let (Ok(r), Ok(g), Ok(b)) = (
542 parts[0].parse::<u8>(),
543 parts[1].parse::<u8>(),
544 parts[2].parse::<u8>(),
545 )
546 {
547 return Color::Rgb(r, g, b);
548 }
549 Color::White
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn test_default_config() {
561 let config = Config::default();
562 assert_eq!(config.integrations.default_tracker, None);
563 assert!(config.integrations.trackers.is_empty());
564 assert!(config.columns.project);
565 assert!(!config.columns.customer);
566 assert!(config.columns.description);
567 }
568
569 #[test]
570 fn test_config_serialization() {
571 let config = Config::default();
572 let toml_str = toml::to_string_pretty(&config).expect("Failed to serialize");
573 assert!(toml_str.contains("integrations"));
574 assert!(toml_str.contains("columns"));
575 }
576
577 #[test]
578 fn test_config_deserialization() {
579 let toml_str = r#"
580[integrations]
581default_tracker = "my-jira"
582
583[integrations.trackers.my-jira]
584enabled = true
585base_url = "https://test.atlassian.net"
586ticket_patterns = ["^PROJ-\\d+$"]
587browse_url = "{base_url}/browse/{ticket}"
588worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1"
589 "#;
590
591 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
592 assert_eq!(
593 config.integrations.default_tracker,
594 Some("my-jira".to_string())
595 );
596 let tracker = config.integrations.trackers.get("my-jira").unwrap();
597 assert_eq!(tracker.base_url, "https://test.atlassian.net");
598 assert_eq!(tracker.ticket_patterns[0], "^PROJ-\\d+$");
599 assert!(config.columns.project);
600 assert!(!config.columns.customer);
601 assert!(config.columns.description);
602 }
603
604 #[test]
605 fn test_columns_config_deserialization() {
606 let toml_str = r#"
607[columns]
608project = false
609customer = true
610description = false
611 "#;
612
613 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
614 assert!(!config.columns.project);
615 assert!(config.columns.customer);
616 assert!(!config.columns.description);
617 }
618
619 #[test]
620 fn test_columns_config_partial_defaults() {
621 let toml_str = r#"
622[columns]
623customer = true
624 "#;
625
626 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
627 assert!(config.columns.project);
628 assert!(config.columns.customer);
629 assert!(config.columns.description);
630 }
631
632 #[test]
633 fn test_tracker_config_defaults() {
634 let tracker = TrackerConfig::default();
635 assert!(!tracker.enabled);
636 assert!(tracker.base_url.is_empty());
637 assert!(tracker.ticket_patterns.is_empty());
638 }
639
640 #[test]
643 fn test_default_theme_config() {
644 let theme_config = ThemeConfig::default();
645 assert_eq!(theme_config.active, "default");
646 assert!(theme_config.custom.is_empty());
647 }
648
649 #[test]
650 fn test_theme_config_get_default_theme() {
651 let theme_config = ThemeConfig::default();
652 let theme = theme_config.get_active_theme();
653 assert!(matches!(theme.active_border, Color::Cyan));
655 assert!(matches!(theme.error, Color::LightRed));
656 }
657
658 #[test]
659 fn test_theme_config_get_kanagawa_theme() {
660 let theme_config = ThemeConfig {
661 active: "kanagawa".to_string(),
662 custom: HashMap::new(),
663 };
664 let theme = theme_config.get_active_theme();
665 assert!(matches!(theme.active_border, Color::Rgb(127, 180, 202)));
667 }
668
669 #[test]
670 fn test_theme_config_get_all_predefined_themes() {
671 let theme_names = vec![
672 "default",
673 "kanagawa",
674 "catppuccin",
675 "gruvbox",
676 "monokai",
677 "dracula",
678 "everforest",
679 "terminal",
680 ];
681
682 for name in theme_names {
683 let theme_config = ThemeConfig {
684 active: name.to_string(),
685 custom: HashMap::new(),
686 };
687 let _theme = theme_config.get_active_theme();
688 }
690 }
691
692 #[test]
693 fn test_parse_color_hex_6_digit() {
694 let color = parse_color("#7e9cd8");
695 assert!(matches!(color, Color::Rgb(126, 156, 216)));
696
697 let color = parse_color("#FF0000");
698 assert!(matches!(color, Color::Rgb(255, 0, 0)));
699 }
700
701 #[test]
702 fn test_parse_color_hex_3_digit() {
703 let color = parse_color("#F00");
704 assert!(matches!(color, Color::Rgb(255, 0, 0)));
705
706 let color = parse_color("#0F0");
707 assert!(matches!(color, Color::Rgb(0, 255, 0)));
708
709 let color = parse_color("#00F");
710 assert!(matches!(color, Color::Rgb(0, 0, 255)));
711 }
712
713 #[test]
714 fn test_parse_color_rgb_tuple() {
715 let color = parse_color("255, 128, 64");
716 assert!(matches!(color, Color::Rgb(255, 128, 64)));
717
718 let color = parse_color("0,255,0");
719 assert!(matches!(color, Color::Rgb(0, 255, 0)));
720
721 let color = parse_color(" 100 , 200 , 150 ");
722 assert!(matches!(color, Color::Rgb(100, 200, 150)));
723 }
724
725 #[test]
726 fn test_parse_color_named_colors() {
727 assert!(matches!(parse_color("red"), Color::Red));
728 assert!(matches!(parse_color("Red"), Color::Red));
729 assert!(matches!(parse_color("RED"), Color::Red));
730 assert!(matches!(parse_color("green"), Color::Green));
731 assert!(matches!(parse_color("blue"), Color::Blue));
732 assert!(matches!(parse_color("yellow"), Color::Yellow));
733 assert!(matches!(parse_color("cyan"), Color::Cyan));
734 assert!(matches!(parse_color("magenta"), Color::Magenta));
735 assert!(matches!(parse_color("white"), Color::White));
736 assert!(matches!(parse_color("black"), Color::Black));
737 assert!(matches!(parse_color("gray"), Color::Gray));
738 assert!(matches!(parse_color("grey"), Color::Gray));
739 assert!(matches!(parse_color("darkgray"), Color::DarkGray));
740 assert!(matches!(parse_color("lightred"), Color::LightRed));
741 assert!(matches!(parse_color("lightgreen"), Color::LightGreen));
742 assert!(matches!(parse_color("lightyellow"), Color::LightYellow));
743 assert!(matches!(parse_color("lightblue"), Color::LightBlue));
744 assert!(matches!(parse_color("lightmagenta"), Color::LightMagenta));
745 assert!(matches!(parse_color("lightcyan"), Color::LightCyan));
746 assert!(matches!(parse_color("reset"), Color::Reset));
747 assert!(matches!(parse_color("terminal"), Color::Reset));
748 assert!(matches!(parse_color("default"), Color::Reset));
749 }
750
751 #[test]
752 fn test_parse_color_invalid_fallback() {
753 let color = parse_color("invalid_color");
755 assert!(matches!(color, Color::White));
756
757 let color = parse_color("#ZZZ");
758 assert!(matches!(color, Color::White));
759
760 let color = parse_color("300, 300, 300"); assert!(matches!(color, Color::White));
762 }
763
764 #[test]
765 fn test_custom_theme_deserialization() {
766 let toml_str = r##"
767[theme]
768active = "my-custom"
769
770[theme.custom.my-custom]
771active_border = "#7e9cd8"
772inactive_border = "darkgray"
773searching_border = "255, 160, 102"
774selected_bg = "#252b37"
775selected_inactive_bg = "#1e222c"
776visual_bg = "70, 130, 180"
777timer_active_bg = "green"
778row_alternate_bg = "#16191f"
779edit_bg = "#223d50"
780focus_bg = "magenta"
781primary_text = "white"
782secondary_text = "gray"
783highlight_text = "cyan"
784success = "lightgreen"
785warning = "yellow"
786error = "lightred"
787info = "cyan"
788timer_text = "#ffc777"
789badge = "lightmagenta"
790 "##;
791
792 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
793 assert_eq!(config.theme.active, "my-custom");
794 assert!(config.theme.custom.contains_key("my-custom"));
795
796 let custom_colors = config.theme.custom.get("my-custom").unwrap();
797 assert_eq!(custom_colors.active_border, "#7e9cd8");
798 assert_eq!(custom_colors.timer_active_bg, "green");
799 assert_eq!(custom_colors.searching_border, "255, 160, 102");
800 }
801
802 #[test]
803 fn test_custom_theme_from_config() {
804 let mut custom = HashMap::new();
805 custom.insert(
806 "test-theme".to_string(),
807 CustomThemeColors {
808 active_border: "#FF0000".to_string(),
809 inactive_border: "darkgray".to_string(),
810 searching_border: "yellow".to_string(),
811 selected_bg: "#252b37".to_string(),
812 selected_inactive_bg: "#1e222c".to_string(),
813 visual_bg: "blue".to_string(),
814 timer_active_bg: "green".to_string(),
815 row_alternate_bg: "#16191f".to_string(),
816 edit_bg: "cyan".to_string(),
817 focus_bg: "magenta".to_string(),
818 primary_text: "white".to_string(),
819 secondary_text: "gray".to_string(),
820 highlight_text: "cyan".to_string(),
821 success: "green".to_string(),
822 warning: "yellow".to_string(),
823 error: "red".to_string(),
824 info: "cyan".to_string(),
825 timer_text: "yellow".to_string(),
826 badge: "magenta".to_string(),
827 },
828 );
829
830 let theme_config = ThemeConfig {
831 active: "test-theme".to_string(),
832 custom,
833 };
834
835 let theme = theme_config.get_active_theme();
836 assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0)));
838 }
839
840 #[test]
841 fn test_fallback_to_default_when_custom_not_found() {
842 let theme_config = ThemeConfig {
843 active: "non-existent-theme".to_string(),
844 custom: HashMap::new(),
845 };
846
847 let theme = theme_config.get_active_theme();
848 assert!(matches!(theme.active_border, Color::Cyan));
850 assert!(matches!(theme.error, Color::LightRed));
851 }
852
853 #[test]
854 fn test_theme_from_custom_colors() {
855 let custom_colors = CustomThemeColors {
856 active_border: "#7e9cd8".to_string(),
857 inactive_border: "darkgray".to_string(),
858 searching_border: "yellow".to_string(),
859 selected_bg: "50, 50, 70".to_string(),
860 selected_inactive_bg: "#1e222c".to_string(),
861 visual_bg: "blue".to_string(),
862 timer_active_bg: "green".to_string(),
863 row_alternate_bg: "#000000".to_string(),
864 edit_bg: "cyan".to_string(),
865 focus_bg: "magenta".to_string(),
866 primary_text: "white".to_string(),
867 secondary_text: "gray".to_string(),
868 highlight_text: "cyan".to_string(),
869 success: "green".to_string(),
870 warning: "yellow".to_string(),
871 error: "red".to_string(),
872 info: "cyan".to_string(),
873 timer_text: "255, 199, 119".to_string(),
874 badge: "magenta".to_string(),
875 };
876
877 let theme = Theme::from_custom(&custom_colors);
878
879 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))); }
885
886 #[test]
887 fn test_config_get_theme() {
888 let mut custom = HashMap::new();
889 custom.insert(
890 "custom1".to_string(),
891 CustomThemeColors {
892 active_border: "red".to_string(),
893 inactive_border: "darkgray".to_string(),
894 searching_border: "yellow".to_string(),
895 selected_bg: "blue".to_string(),
896 selected_inactive_bg: "black".to_string(),
897 visual_bg: "cyan".to_string(),
898 timer_active_bg: "green".to_string(),
899 row_alternate_bg: "black".to_string(),
900 edit_bg: "blue".to_string(),
901 focus_bg: "magenta".to_string(),
902 primary_text: "white".to_string(),
903 secondary_text: "gray".to_string(),
904 highlight_text: "cyan".to_string(),
905 success: "green".to_string(),
906 warning: "yellow".to_string(),
907 error: "red".to_string(),
908 info: "cyan".to_string(),
909 timer_text: "yellow".to_string(),
910 badge: "magenta".to_string(),
911 },
912 );
913
914 let config = Config {
915 integrations: IntegrationConfig::default(),
916 theme: ThemeConfig {
917 active: "custom1".to_string(),
918 custom,
919 },
920 columns: ColumnVisibilityConfig::default(),
921 };
922
923 let theme = config.get_theme();
924 assert!(matches!(theme.active_border, Color::Red));
925 }
926
927 #[test]
930 fn test_all_predefined_theme_methods() {
931 let default = Theme::default_theme();
933 let kanagawa = Theme::kanagawa();
934 let catppuccin = Theme::catppuccin();
935 let gruvbox = Theme::gruvbox();
936 let monokai = Theme::monokai();
937 let dracula = Theme::dracula();
938 let everforest = Theme::everforest();
939 let terminal = Theme::terminal();
940
941 assert!(matches!(default.active_border, Color::Cyan));
943 assert!(matches!(kanagawa.active_border, Color::Rgb(127, 180, 202)));
944 assert!(matches!(
945 catppuccin.active_border,
946 Color::Rgb(137, 180, 250)
947 ));
948 assert!(matches!(gruvbox.active_border, Color::Rgb(131, 165, 152)));
949 assert!(matches!(monokai.active_border, Color::Rgb(102, 217, 239)));
950 assert!(matches!(dracula.active_border, Color::Rgb(139, 233, 253)));
951 assert!(matches!(
952 everforest.active_border,
953 Color::Rgb(131, 192, 146)
954 ));
955 assert!(matches!(terminal.active_border, Color::Cyan));
956 }
957
958 #[test]
959 fn test_parse_color_hex_edge_cases() {
960 assert!(matches!(parse_color("#ffffff"), Color::Rgb(255, 255, 255)));
962 assert!(matches!(parse_color("#000000"), Color::Rgb(0, 0, 0)));
963
964 assert!(matches!(parse_color("#FFFFFF"), Color::Rgb(255, 255, 255)));
966 assert!(matches!(parse_color("#ABC"), Color::Rgb(170, 187, 204)));
967
968 assert!(matches!(parse_color("#FfFfFf"), Color::Rgb(255, 255, 255)));
970
971 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)); }
979
980 #[test]
981 fn test_parse_color_rgb_edge_cases() {
982 assert!(matches!(parse_color("0, 0, 0"), Color::Rgb(0, 0, 0)));
984 assert!(matches!(
985 parse_color("255, 255, 255"),
986 Color::Rgb(255, 255, 255)
987 ));
988
989 assert!(matches!(
991 parse_color("(100, 150, 200)"),
992 Color::Rgb(100, 150, 200)
993 ));
994
995 assert!(matches!(parse_color("10,20,30"), Color::Rgb(10, 20, 30)));
997 assert!(matches!(
998 parse_color(" 50 , 100 , 150 "),
999 Color::Rgb(50, 100, 150)
1000 ));
1001
1002 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)); }
1013
1014 #[test]
1015 fn test_parse_color_named_variations() {
1016 assert!(matches!(parse_color("RED"), Color::Red));
1018 assert!(matches!(parse_color("Red"), Color::Red));
1019 assert!(matches!(parse_color("rEd"), Color::Red));
1020
1021 assert!(matches!(parse_color("gray"), Color::Gray));
1023 assert!(matches!(parse_color("grey"), Color::Gray));
1024 assert!(matches!(parse_color("GRAY"), Color::Gray));
1025 assert!(matches!(parse_color("GREY"), Color::Gray));
1026 assert!(matches!(parse_color("darkgray"), Color::DarkGray));
1027 assert!(matches!(parse_color("darkgrey"), Color::DarkGray));
1028
1029 assert!(matches!(parse_color("LightRed"), Color::LightRed));
1031 assert!(matches!(parse_color("LightGreen"), Color::LightGreen));
1032 assert!(matches!(parse_color("LightYellow"), Color::LightYellow));
1033 assert!(matches!(parse_color("LightBlue"), Color::LightBlue));
1034 assert!(matches!(parse_color("LightMagenta"), Color::LightMagenta));
1035 assert!(matches!(parse_color("LightCyan"), Color::LightCyan));
1036
1037 assert!(matches!(parse_color("reset"), Color::Reset));
1039 assert!(matches!(parse_color("terminal"), Color::Reset));
1040 assert!(matches!(parse_color("default"), Color::Reset));
1041 assert!(matches!(parse_color("RESET"), Color::Reset));
1042 assert!(matches!(parse_color("Terminal"), Color::Reset));
1043 }
1044
1045 #[test]
1046 fn test_parse_color_whitespace_handling() {
1047 assert!(matches!(parse_color(" red "), Color::Red));
1049 assert!(matches!(parse_color("\tblue\t"), Color::Blue));
1050 assert!(matches!(parse_color(" #FF0000 "), Color::Rgb(255, 0, 0)));
1051 assert!(matches!(
1052 parse_color(" 100, 200, 150 "),
1053 Color::Rgb(100, 200, 150)
1054 ));
1055 }
1056
1057 #[test]
1058 fn test_parse_color_empty_and_whitespace() {
1059 assert!(matches!(parse_color(""), Color::White));
1061 assert!(matches!(parse_color(" "), Color::White));
1062 assert!(matches!(parse_color("\t\t"), Color::White));
1063 }
1064
1065 #[test]
1066 fn test_theme_color_consistency() {
1067 let themes = vec![
1070 Theme::default_theme(),
1071 Theme::kanagawa(),
1072 Theme::catppuccin(),
1073 Theme::gruvbox(),
1074 Theme::monokai(),
1075 Theme::dracula(),
1076 Theme::everforest(),
1077 Theme::terminal(),
1078 ];
1079
1080 for theme in themes {
1081 let _ = theme.active_border;
1083 let _ = theme.inactive_border;
1084 let _ = theme.searching_border;
1085 let _ = theme.selected_bg;
1086 let _ = theme.selected_inactive_bg;
1087 let _ = theme.visual_bg;
1088 let _ = theme.timer_active_bg;
1089 let _ = theme.row_alternate_bg;
1090 let _ = theme.edit_bg;
1091 let _ = theme.primary_text;
1092 let _ = theme.secondary_text;
1093 let _ = theme.highlight_text;
1094 let _ = theme.success;
1095 let _ = theme.warning;
1096 let _ = theme.error;
1097 let _ = theme.info;
1098 let _ = theme.timer_text;
1099 let _ = theme.badge;
1100 }
1101 }
1102
1103 #[test]
1104 fn test_custom_theme_colors_all_formats() {
1105 let custom_colors = CustomThemeColors {
1106 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(), };
1126
1127 let theme = Theme::from_custom(&custom_colors);
1128
1129 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))); }
1139
1140 #[test]
1141 fn test_multiple_custom_themes_in_config() {
1142 let toml_str = r##"
1143[theme]
1144active = "theme2"
1145
1146[theme.custom.theme1]
1147active_border = "red"
1148inactive_border = "darkgray"
1149searching_border = "yellow"
1150selected_bg = "blue"
1151selected_inactive_bg = "black"
1152visual_bg = "cyan"
1153timer_active_bg = "green"
1154row_alternate_bg = "black"
1155edit_bg = "blue"
1156focus_bg = "magenta"
1157primary_text = "white"
1158secondary_text = "gray"
1159highlight_text = "cyan"
1160success = "green"
1161warning = "yellow"
1162error = "red"
1163info = "cyan"
1164timer_text = "yellow"
1165badge = "magenta"
1166
1167[theme.custom.theme2]
1168active_border = "#FF00FF"
1169inactive_border = "darkgray"
1170searching_border = "yellow"
1171selected_bg = "blue"
1172selected_inactive_bg = "black"
1173visual_bg = "cyan"
1174timer_active_bg = "green"
1175row_alternate_bg = "black"
1176edit_bg = "blue"
1177focus_bg = "magenta"
1178primary_text = "white"
1179secondary_text = "gray"
1180highlight_text = "cyan"
1181success = "green"
1182warning = "yellow"
1183error = "red"
1184info = "cyan"
1185timer_text = "yellow"
1186badge = "magenta"
1187 "##;
1188
1189 let config: Config = toml::from_str(toml_str).expect("Failed to deserialize");
1190 assert_eq!(config.theme.active, "theme2");
1191 assert_eq!(config.theme.custom.len(), 2);
1192 assert!(config.theme.custom.contains_key("theme1"));
1193 assert!(config.theme.custom.contains_key("theme2"));
1194
1195 let theme = config.get_theme();
1197 assert!(matches!(theme.active_border, Color::Rgb(255, 0, 255)));
1198 }
1199
1200 #[test]
1201 fn test_theme_config_case_sensitivity() {
1202 let theme_config = ThemeConfig {
1204 active: "KANAGAWA".to_string(), custom: HashMap::new(),
1206 };
1207
1208 let theme = theme_config.get_active_theme();
1209 assert!(matches!(theme.active_border, Color::Cyan)); }
1212
1213 #[test]
1214 fn test_custom_theme_overrides_predefined() {
1215 let mut custom = HashMap::new();
1217 custom.insert(
1218 "default".to_string(),
1219 CustomThemeColors {
1220 active_border: "#FF0000".to_string(), inactive_border: "darkgray".to_string(),
1222 searching_border: "yellow".to_string(),
1223 selected_bg: "blue".to_string(),
1224 selected_inactive_bg: "black".to_string(),
1225 visual_bg: "cyan".to_string(),
1226 timer_active_bg: "green".to_string(),
1227 row_alternate_bg: "black".to_string(),
1228 edit_bg: "blue".to_string(),
1229 focus_bg: "magenta".to_string(),
1230 primary_text: "white".to_string(),
1231 secondary_text: "gray".to_string(),
1232 highlight_text: "cyan".to_string(),
1233 success: "green".to_string(),
1234 warning: "yellow".to_string(),
1235 error: "red".to_string(),
1236 info: "cyan".to_string(),
1237 timer_text: "yellow".to_string(),
1238 badge: "magenta".to_string(),
1239 },
1240 );
1241
1242 let theme_config = ThemeConfig {
1243 active: "default".to_string(),
1244 custom,
1245 };
1246
1247 let theme = theme_config.get_active_theme();
1248 assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0)));
1250 }
1251
1252 #[test]
1253 fn test_parse_color_rgb_with_parentheses_and_spaces() {
1254 assert!(matches!(
1256 parse_color("(255, 128, 64)"),
1257 Color::Rgb(255, 128, 64)
1258 ));
1259 assert!(matches!(
1260 parse_color("( 100 , 200 , 150 )"),
1261 Color::Rgb(100, 200, 150)
1262 ));
1263
1264 let result = parse_color("(10,20,30)");
1266 assert!(matches!(result, Color::Rgb(10, 20, 30)));
1267 }
1268
1269 #[test]
1270 fn test_theme_serialization() {
1271 let theme_config = ThemeConfig {
1273 active: "gruvbox".to_string(),
1274 custom: HashMap::new(),
1275 };
1276
1277 let serialized = toml::to_string(&theme_config).expect("Failed to serialize");
1278 assert!(serialized.contains("active"));
1279 assert!(serialized.contains("gruvbox"));
1280
1281 let deserialized: ThemeConfig = toml::from_str(&serialized).expect("Failed to deserialize");
1282 assert_eq!(deserialized.active, "gruvbox");
1283 assert!(deserialized.custom.is_empty());
1284 }
1285}