work_tuimer/config/
mod.rs

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/// Configuration for issue tracker integrations (JIRA, Linear, GitHub, etc.)
9#[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    /// Default tracker name when auto-detection is ambiguous
21    #[serde(default)]
22    pub default_tracker: Option<String>,
23
24    /// Map of tracker name to tracker configuration
25    #[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    /// Regex patterns to match ticket IDs for this tracker
36    #[serde(default)]
37    pub ticket_patterns: Vec<String>,
38    /// URL template for browsing tickets: {base_url}, {ticket}
39    #[serde(default)]
40    pub browse_url: String,
41    /// URL template for worklog page: {base_url}, {ticket}
42    #[serde(default)]
43    pub worklog_url: String,
44}
45
46impl Config {
47    /// Load config from file, or return defaults if file doesn't exist
48    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    /// Get config file path (~/.config/work-tuimer/config.toml)
63    /// Respects XDG_CONFIG_HOME environment variable on Unix systems
64    fn get_config_path() -> PathBuf {
65        // On Unix systems (Linux/macOS), respect XDG_CONFIG_HOME
66        #[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            // Fall back to ~/.config if XDG_CONFIG_HOME is not set
74            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        // On Windows, use dirs::config_dir() which returns AppData/Roaming
83        #[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        // Final fallback for any platform
91        PathBuf::from("./config.toml")
92    }
93
94    /// Check if any tracker integration is properly configured
95    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    /// Get the active theme (either pre-defined or custom)
103    pub fn get_theme(&self) -> Theme {
104        self.theme.get_active_theme()
105    }
106}
107
108/// Theme configuration
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ThemeConfig {
111    /// Name of active theme: "default", "kanagawa", "catppuccin", "gruvbox", "monokai", "dracula", "everforest", "terminal"
112    #[serde(default = "default_theme_name")]
113    pub active: String,
114
115    /// Custom theme definitions
116    #[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    /// Get the active theme based on config
135    pub fn get_active_theme(&self) -> Theme {
136        // Check custom themes first (allows overriding predefined themes)
137        if let Some(custom_colors) = self.custom.get(&self.active) {
138            return Theme::from_custom(custom_colors);
139        }
140
141        // Then check if it's a pre-defined theme
142        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                // Fallback to default if theme not found
153                Theme::default_theme()
154            }
155        }
156    }
157}
158
159/// Custom theme color definitions (supports hex colors, RGB tuples, and named colors)
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct CustomThemeColors {
162    // Border colors
163    pub active_border: String,
164    pub inactive_border: String,
165    pub searching_border: String,
166
167    // Background colors
168    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    // Text colors
177    pub primary_text: String,
178    pub secondary_text: String,
179    pub highlight_text: String,
180
181    // Status colors
182    pub success: String,
183    pub warning: String,
184    pub error: String,
185    pub info: String,
186
187    // Specific element colors
188    pub timer_text: String,
189    pub badge: String,
190}
191
192/// Theme with semantic color names (resolved to ratatui Colors)
193#[derive(Debug, Clone)]
194pub struct Theme {
195    // Border colors
196    pub active_border: Color,
197    #[allow(dead_code)]
198    pub inactive_border: Color,
199    #[allow(dead_code)]
200    pub searching_border: Color,
201
202    // Background colors
203    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    // Text colors
213    pub primary_text: Color,
214    pub secondary_text: Color,
215    pub highlight_text: Color,
216
217    // Status colors
218    pub success: Color,
219    pub warning: Color,
220    pub error: Color,
221    pub info: Color,
222
223    // Specific element colors
224    pub timer_text: Color,
225    pub badge: Color,
226}
227
228impl Theme {
229    /// Default theme (current color scheme)
230    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), // purple focus indicator
242            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    /// Kanagawa theme (dark navy blue aesthetic inspired by Hokusai's painting)
255    pub fn kanagawa() -> Self {
256        Self {
257            active_border: Color::Rgb(127, 180, 202), // springBlue (brighter)
258            inactive_border: Color::Rgb(54, 59, 77),  // sumiInk4
259            searching_border: Color::Rgb(255, 160, 102), // surimiOrange
260            selected_bg: Color::Rgb(42, 42, 55),      // sumiInk3 (slightly lighter for distinction)
261            selected_inactive_bg: Color::Rgb(31, 31, 40), // sumiInk1/2
262            visual_bg: Color::Rgb(45, 79, 103),       // waveBlue2 (darker for distinction)
263            timer_active_bg: Color::Rgb(152, 187, 106), // springGreen (brighter)
264            row_alternate_bg: Color::Rgb(22, 25, 32), // sumiInk1 (darker)
265            edit_bg: Color::Rgb(106, 149, 137),       // waveAqua1 (distinct teal, not blue!)
266            focus_bg: Color::Rgb(149, 127, 184),      // oniViolet (purple focus indicator)
267            primary_text: Color::Rgb(220, 215, 186),  // fujiWhite
268            secondary_text: Color::Rgb(114, 118, 129), // fujiGray (comments)
269            highlight_text: Color::Rgb(230, 195, 132), // carpYellow (warmer highlight)
270            success: Color::Rgb(152, 187, 106),       // springGreen
271            warning: Color::Rgb(255, 160, 102),       // surimiOrange
272            error: Color::Rgb(255, 93, 98),           // peachRed (brighter)
273            info: Color::Rgb(127, 180, 202),          // springBlue
274            timer_text: Color::Rgb(230, 195, 132),    // carpYellow
275            badge: Color::Rgb(149, 127, 184),         // oniViolet
276        }
277    }
278
279    /// Catppuccin Mocha theme (popular pastel theme)
280    pub fn catppuccin() -> Self {
281        Self {
282            active_border: Color::Rgb(137, 180, 250),     // blue
283            inactive_border: Color::Rgb(69, 71, 90),      // surface1
284            searching_border: Color::Rgb(249, 226, 175),  // yellow
285            selected_bg: Color::Rgb(49, 50, 68),          // surface0
286            selected_inactive_bg: Color::Rgb(30, 30, 46), // base
287            visual_bg: Color::Rgb(116, 199, 236),         // sapphire
288            timer_active_bg: Color::Rgb(166, 227, 161),   // green
289            row_alternate_bg: Color::Rgb(24, 24, 37),     // mantle
290            edit_bg: Color::Rgb(137, 180, 250),           // blue (dimmed)
291            focus_bg: Color::Rgb(203, 166, 247),          // mauve (purple focus indicator)
292            primary_text: Color::Rgb(205, 214, 244),      // text
293            secondary_text: Color::Rgb(127, 132, 156),    // overlay0
294            highlight_text: Color::Rgb(137, 180, 250),    // blue
295            success: Color::Rgb(166, 227, 161),           // green
296            warning: Color::Rgb(249, 226, 175),           // yellow
297            error: Color::Rgb(243, 139, 168),             // red
298            info: Color::Rgb(137, 180, 250),              // blue
299            timer_text: Color::Rgb(245, 194, 231),        // pink
300            badge: Color::Rgb(203, 166, 247),             // mauve
301        }
302    }
303
304    /// Gruvbox theme (retro warm colors)
305    pub fn gruvbox() -> Self {
306        Self {
307            active_border: Color::Rgb(131, 165, 152),     // aqua
308            inactive_border: Color::Rgb(60, 56, 54),      // bg2
309            searching_border: Color::Rgb(250, 189, 47),   // yellow
310            selected_bg: Color::Rgb(60, 56, 54),          // bg2
311            selected_inactive_bg: Color::Rgb(40, 40, 40), // bg0
312            visual_bg: Color::Rgb(69, 133, 136),          // blue
313            timer_active_bg: Color::Rgb(152, 151, 26),    // green
314            row_alternate_bg: Color::Rgb(29, 32, 33),     // bg0_h
315            edit_bg: Color::Rgb(80, 73, 69),              // bg3
316            focus_bg: Color::Rgb(211, 134, 155),          // purple (warm purple focus)
317            primary_text: Color::Rgb(235, 219, 178),      // fg
318            secondary_text: Color::Rgb(146, 131, 116),    // fg4
319            highlight_text: Color::Rgb(131, 165, 152),    // aqua
320            success: Color::Rgb(184, 187, 38),            // green
321            warning: Color::Rgb(250, 189, 47),            // yellow
322            error: Color::Rgb(251, 73, 52),               // red
323            info: Color::Rgb(131, 165, 152),              // aqua
324            timer_text: Color::Rgb(254, 128, 25),         // orange
325            badge: Color::Rgb(211, 134, 155),             // purple
326        }
327    }
328
329    /// Monokai theme (classic vibrant)
330    pub fn monokai() -> Self {
331        Self {
332            active_border: Color::Rgb(102, 217, 239), // cyan (vibrant)
333            inactive_border: Color::Rgb(73, 72, 62),  // darker gray
334            searching_border: Color::Rgb(230, 219, 116), // yellow
335            selected_bg: Color::Rgb(73, 72, 62),      // dark gray
336            selected_inactive_bg: Color::Rgb(39, 40, 34), // background
337            visual_bg: Color::Rgb(249, 38, 114),      // magenta (distinct!)
338            timer_active_bg: Color::Rgb(166, 226, 46), // vibrant green
339            row_alternate_bg: Color::Rgb(30, 31, 28), // darker bg
340            edit_bg: Color::Rgb(100, 85, 60),         // warm brown (distinct from selected_bg!)
341            focus_bg: Color::Rgb(174, 129, 255),      // purple (vibrant purple focus)
342            primary_text: Color::Rgb(248, 248, 242),  // foreground
343            secondary_text: Color::Rgb(117, 113, 94), // comment gray
344            highlight_text: Color::Rgb(253, 151, 31), // bright orange (not cyan!)
345            success: Color::Rgb(166, 226, 46),        // vibrant green
346            warning: Color::Rgb(230, 219, 116),       // yellow
347            error: Color::Rgb(249, 38, 114),          // magenta/pink
348            info: Color::Rgb(102, 217, 239),          // cyan
349            timer_text: Color::Rgb(253, 151, 31),     // orange
350            badge: Color::Rgb(174, 129, 255),         // purple
351        }
352    }
353
354    /// Dracula theme (purple/pink accents)
355    pub fn dracula() -> Self {
356        Self {
357            active_border: Color::Rgb(139, 233, 253),     // cyan
358            inactive_border: Color::Rgb(68, 71, 90),      // current line
359            searching_border: Color::Rgb(241, 250, 140),  // yellow
360            selected_bg: Color::Rgb(68, 71, 90),          // current line
361            selected_inactive_bg: Color::Rgb(40, 42, 54), // background
362            visual_bg: Color::Rgb(139, 233, 253),         // cyan (distinct from purple)
363            timer_active_bg: Color::Rgb(80, 250, 123),    // green
364            row_alternate_bg: Color::Rgb(30, 31, 40),     // darker than bg
365            edit_bg: Color::Rgb(98, 114, 164),            // purple (distinct from selected_bg!)
366            focus_bg: Color::Rgb(189, 147, 249),          // bright purple (focus indicator)
367            primary_text: Color::Rgb(248, 248, 242),      // foreground
368            secondary_text: Color::Rgb(98, 114, 164),     // purple/comment
369            highlight_text: Color::Rgb(189, 147, 249),    // bright purple
370            success: Color::Rgb(80, 250, 123),            // green
371            warning: Color::Rgb(241, 250, 140),           // yellow
372            error: Color::Rgb(255, 85, 85),               // red
373            info: Color::Rgb(139, 233, 253),              // cyan
374            timer_text: Color::Rgb(255, 184, 108),        // orange
375            badge: Color::Rgb(255, 121, 198),             // pink
376        }
377    }
378
379    /// Everforest theme (green forest aesthetic)
380    pub fn everforest() -> Self {
381        Self {
382            active_border: Color::Rgb(131, 192, 146),     // green
383            inactive_border: Color::Rgb(83, 86, 77),      // bg3
384            searching_border: Color::Rgb(219, 188, 127),  // yellow
385            selected_bg: Color::Rgb(67, 72, 60),          // bg2
386            selected_inactive_bg: Color::Rgb(45, 49, 41), // bg1
387            visual_bg: Color::Rgb(123, 175, 153),         // aqua
388            timer_active_bg: Color::Rgb(131, 192, 146),   // green
389            row_alternate_bg: Color::Rgb(35, 38, 32),     // bg0
390            edit_bg: Color::Rgb(83, 86, 77),              // bg3
391            focus_bg: Color::Rgb(217, 143, 172),          // purple (soft purple focus)
392            primary_text: Color::Rgb(211, 198, 170),      // fg
393            secondary_text: Color::Rgb(146, 142, 123),    // gray1
394            highlight_text: Color::Rgb(123, 175, 153),    // aqua
395            success: Color::Rgb(131, 192, 146),           // green
396            warning: Color::Rgb(219, 188, 127),           // yellow
397            error: Color::Rgb(230, 126, 128),             // red
398            info: Color::Rgb(123, 175, 153),              // aqua
399            timer_text: Color::Rgb(230, 152, 117),        // orange
400            badge: Color::Rgb(217, 143, 172),             // purple
401        }
402    }
403
404    /// Terminal theme (uses terminal's native colors)
405    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, // magenta/purple focus
417            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    /// Create theme from custom color definitions
430    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
455/// Parse color string (supports hex, RGB tuples, and named colors)
456fn parse_color(color_str: &str) -> Color {
457    let trimmed = color_str.trim();
458
459    // Handle hex colors (#RRGGBB or #RGB)
460    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            // Short hex format #RGB -> #RRGGBB
471            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    // Handle named colors
482    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            // Fallback: try to parse as RGB tuple "r,g,b" or "(r, g, b)"
502            // Strip parentheses if present
503            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            // Final fallback: return default white
515            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    // Theme-related tests
571
572    #[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        // Verify it returns a valid theme (checking a few fields)
584        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        // Verify it returns kanagawa theme (check one specific color)
596        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            // Just verify it doesn't panic and returns a theme
619        }
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        // Invalid formats should fallback to white
684        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"); // Out of range
691        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        // Verify custom theme is applied (check the custom red border)
767        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        // Should fallback to default theme
779        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        // Verify different color formats are parsed correctly
810        assert!(matches!(theme.active_border, Color::Rgb(126, 156, 216))); // hex
811        assert!(matches!(theme.inactive_border, Color::DarkGray)); // named
812        assert!(matches!(theme.selected_bg, Color::Rgb(50, 50, 70))); // rgb tuple
813        assert!(matches!(theme.timer_text, Color::Rgb(255, 199, 119))); // rgb tuple
814    }
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    // Additional comprehensive tests
857
858    #[test]
859    fn test_all_predefined_theme_methods() {
860        // Test that all theme methods return valid themes with all fields populated
861        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        // Verify each theme has different active_border colors (unique themes)
871        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        // Test lowercase hex
890        assert!(matches!(parse_color("#ffffff"), Color::Rgb(255, 255, 255)));
891        assert!(matches!(parse_color("#000000"), Color::Rgb(0, 0, 0)));
892
893        // Test uppercase hex
894        assert!(matches!(parse_color("#FFFFFF"), Color::Rgb(255, 255, 255)));
895        assert!(matches!(parse_color("#ABC"), Color::Rgb(170, 187, 204)));
896
897        // Test mixed case
898        assert!(matches!(parse_color("#FfFfFf"), Color::Rgb(255, 255, 255)));
899
900        // Test invalid hex (wrong length)
901        assert!(matches!(parse_color("#FF"), Color::White)); // fallback
902        assert!(matches!(parse_color("#FFFFFFF"), Color::White)); // fallback
903
904        // Test invalid hex characters
905        assert!(matches!(parse_color("#GGGGGG"), Color::White)); // fallback
906        assert!(matches!(parse_color("#XYZ"), Color::White)); // fallback
907    }
908
909    #[test]
910    fn test_parse_color_rgb_edge_cases() {
911        // Test boundary values
912        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        // Test with parentheses (should be handled by trim)
919        assert!(matches!(
920            parse_color("(100, 150, 200)"),
921            Color::Rgb(100, 150, 200)
922        ));
923
924        // Test various spacing
925        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        // Test invalid RGB values
932        assert!(matches!(parse_color("256, 100, 100"), Color::White)); // out of range
933        assert!(matches!(parse_color("100, 300, 100"), Color::White)); // out of range
934        assert!(matches!(parse_color("100, 100, 256"), Color::White)); // out of range
935        assert!(matches!(parse_color("-1, 100, 100"), Color::White)); // negative
936        assert!(matches!(parse_color("abc, 100, 100"), Color::White)); // non-numeric
937
938        // Test wrong number of components
939        assert!(matches!(parse_color("100, 100"), Color::White)); // too few
940        assert!(matches!(parse_color("100, 100, 100, 100"), Color::White)); // too many
941    }
942
943    #[test]
944    fn test_parse_color_named_variations() {
945        // Test case variations
946        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        // Test grey vs gray
951        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        // Test all light colors
959        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        // Test terminal color aliases
967        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        // Test leading/trailing whitespace
977        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        // Empty strings should fallback to white
989        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        // Verify that all pre-defined themes have consistent structure
997        // (all 18 colors are present and valid)
998        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            // Just access all fields to ensure they exist
1011            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(),        // hex
1036            inactive_border: "darkgray".to_string(),     // named
1037            searching_border: "255, 255, 0".to_string(), // rgb
1038            selected_bg: "#00F".to_string(),             // short hex
1039            selected_inactive_bg: "Black".to_string(),   // named (capitalized)
1040            visual_bg: "0, 128, 255".to_string(),        // rgb
1041            timer_active_bg: "lightgreen".to_string(),   // named
1042            row_alternate_bg: "#111".to_string(),        // short hex
1043            edit_bg: "(50, 100, 150)".to_string(),       // rgb with parens
1044            focus_bg: "magenta".to_string(),             // named
1045            primary_text: "white".to_string(),           // named
1046            secondary_text: "128, 128, 128".to_string(), // rgb
1047            highlight_text: "#0FF".to_string(),          // short hex cyan
1048            success: "green".to_string(),                // named
1049            warning: "#FFAA00".to_string(),              // hex
1050            error: "255, 0, 0".to_string(),              // rgb
1051            info: "cyan".to_string(),                    // named
1052            timer_text: "#FFA500".to_string(),           // hex
1053            badge: "magenta".to_string(),                // named
1054        };
1055
1056        let theme = Theme::from_custom(&custom_colors);
1057
1058        // Verify mixed color formats are parsed correctly
1059        assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0))); // hex
1060        assert!(matches!(theme.inactive_border, Color::DarkGray)); // named
1061        assert!(matches!(theme.searching_border, Color::Rgb(255, 255, 0))); // rgb
1062        assert!(matches!(theme.selected_bg, Color::Rgb(0, 0, 255))); // short hex
1063        assert!(matches!(theme.selected_inactive_bg, Color::Black)); // named
1064        assert!(matches!(theme.visual_bg, Color::Rgb(0, 128, 255))); // rgb
1065        assert!(matches!(theme.timer_active_bg, Color::LightGreen)); // named
1066        assert!(matches!(theme.row_alternate_bg, Color::Rgb(17, 17, 17))); // short hex
1067    }
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        // Verify the active theme is theme2 with magenta border
1125        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        // Theme names should be case-sensitive (lowercase by convention)
1132        let theme_config = ThemeConfig {
1133            active: "KANAGAWA".to_string(), // uppercase (not found)
1134            custom: HashMap::new(),
1135        };
1136
1137        let theme = theme_config.get_active_theme();
1138        // Should fallback to default theme (not kanagawa)
1139        assert!(matches!(theme.active_border, Color::Cyan)); // default theme
1140    }
1141
1142    #[test]
1143    fn test_custom_theme_overrides_predefined() {
1144        // Custom theme with same name as predefined should override
1145        let mut custom = HashMap::new();
1146        custom.insert(
1147            "default".to_string(),
1148            CustomThemeColors {
1149                active_border: "#FF0000".to_string(), // red instead of cyan
1150                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        // Should use custom theme (red), not predefined default (cyan)
1178        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        // RGB tuples can have parentheses (users might include them) - we strip them
1184        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        // Parentheses are now stripped, so this should parse successfully
1194        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        // Test that ThemeConfig can be serialized/deserialized
1201        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}