Skip to main content

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    #[serde(default)]
18    pub columns: ColumnVisibilityConfig,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct IntegrationConfig {
23    /// Default tracker name when auto-detection is ambiguous
24    #[serde(default)]
25    pub default_tracker: Option<String>,
26
27    /// Map of tracker name to tracker configuration
28    #[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    /// Regex patterns to match ticket IDs for this tracker
39    #[serde(default)]
40    pub ticket_patterns: Vec<String>,
41    /// URL template for browsing tickets: {base_url}, {ticket}
42    #[serde(default)]
43    pub browse_url: String,
44    /// URL template for worklog page: {base_url}, {ticket}
45    #[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    /// Load config from file, or return defaults if file doesn't exist
83    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    /// Get config file path (~/.config/work-tuimer/config.toml)
98    /// Respects XDG_CONFIG_HOME environment variable on Unix systems
99    fn get_config_path() -> PathBuf {
100        // On Unix systems (Linux/macOS), respect XDG_CONFIG_HOME
101        #[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            // Fall back to ~/.config if XDG_CONFIG_HOME is not set
109            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        // On Windows, use dirs::config_dir() which returns AppData/Roaming
118        #[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        // Final fallback for any platform
126        PathBuf::from("./config.toml")
127    }
128
129    /// Check if any tracker integration is properly configured
130    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    /// Get the active theme (either pre-defined or custom)
138    pub fn get_theme(&self) -> Theme {
139        self.theme.get_active_theme()
140    }
141}
142
143/// Theme configuration
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ThemeConfig {
146    /// Name of active theme: "default", "kanagawa", "catppuccin", "gruvbox", "monokai", "dracula", "everforest", "terminal"
147    #[serde(default = "default_theme_name")]
148    pub active: String,
149
150    /// Custom theme definitions
151    #[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    /// Get the active theme based on config
170    pub fn get_active_theme(&self) -> Theme {
171        // Check custom themes first (allows overriding predefined themes)
172        if let Some(custom_colors) = self.custom.get(&self.active) {
173            return Theme::from_custom(custom_colors);
174        }
175
176        // Then check if it's a pre-defined theme
177        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                // Fallback to default if theme not found
188                Theme::default_theme()
189            }
190        }
191    }
192}
193
194/// Custom theme color definitions (supports hex colors, RGB tuples, and named colors)
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct CustomThemeColors {
197    // Border colors
198    pub active_border: String,
199    pub inactive_border: String,
200    pub searching_border: String,
201
202    // Background colors
203    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    // Text colors
212    pub primary_text: String,
213    pub secondary_text: String,
214    pub highlight_text: String,
215
216    // Status colors
217    pub success: String,
218    pub warning: String,
219    pub error: String,
220    pub info: String,
221
222    // Specific element colors
223    pub timer_text: String,
224    pub badge: String,
225}
226
227/// Theme with semantic color names (resolved to ratatui Colors)
228#[derive(Debug, Clone)]
229pub struct Theme {
230    // Border colors
231    pub active_border: Color,
232    #[allow(dead_code)]
233    pub inactive_border: Color,
234    #[allow(dead_code)]
235    pub searching_border: Color,
236
237    // Background colors
238    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    // Text colors
248    pub primary_text: Color,
249    pub secondary_text: Color,
250    pub highlight_text: Color,
251
252    // Status colors
253    pub success: Color,
254    pub warning: Color,
255    pub error: Color,
256    pub info: Color,
257
258    // Specific element colors
259    pub timer_text: Color,
260    pub badge: Color,
261}
262
263impl Theme {
264    /// Default theme (current color scheme)
265    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), // purple focus indicator
277            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    /// Kanagawa theme (dark navy blue aesthetic inspired by Hokusai's painting)
290    pub fn kanagawa() -> Self {
291        Self {
292            active_border: Color::Rgb(127, 180, 202), // springBlue (brighter)
293            inactive_border: Color::Rgb(54, 59, 77),  // sumiInk4
294            searching_border: Color::Rgb(255, 160, 102), // surimiOrange
295            selected_bg: Color::Rgb(42, 42, 55),      // sumiInk3 (slightly lighter for distinction)
296            selected_inactive_bg: Color::Rgb(31, 31, 40), // sumiInk1/2
297            visual_bg: Color::Rgb(45, 79, 103),       // waveBlue2 (darker for distinction)
298            timer_active_bg: Color::Rgb(152, 187, 106), // springGreen (brighter)
299            row_alternate_bg: Color::Rgb(22, 25, 32), // sumiInk1 (darker)
300            edit_bg: Color::Rgb(106, 149, 137),       // waveAqua1 (distinct teal, not blue!)
301            focus_bg: Color::Rgb(149, 127, 184),      // oniViolet (purple focus indicator)
302            primary_text: Color::Rgb(220, 215, 186),  // fujiWhite
303            secondary_text: Color::Rgb(114, 118, 129), // fujiGray (comments)
304            highlight_text: Color::Rgb(230, 195, 132), // carpYellow (warmer highlight)
305            success: Color::Rgb(152, 187, 106),       // springGreen
306            warning: Color::Rgb(255, 160, 102),       // surimiOrange
307            error: Color::Rgb(255, 93, 98),           // peachRed (brighter)
308            info: Color::Rgb(127, 180, 202),          // springBlue
309            timer_text: Color::Rgb(230, 195, 132),    // carpYellow
310            badge: Color::Rgb(149, 127, 184),         // oniViolet
311        }
312    }
313
314    /// Catppuccin Mocha theme (popular pastel theme)
315    pub fn catppuccin() -> Self {
316        Self {
317            active_border: Color::Rgb(137, 180, 250),     // blue
318            inactive_border: Color::Rgb(69, 71, 90),      // surface1
319            searching_border: Color::Rgb(249, 226, 175),  // yellow
320            selected_bg: Color::Rgb(49, 50, 68),          // surface0
321            selected_inactive_bg: Color::Rgb(30, 30, 46), // base
322            visual_bg: Color::Rgb(116, 199, 236),         // sapphire
323            timer_active_bg: Color::Rgb(166, 227, 161),   // green
324            row_alternate_bg: Color::Rgb(24, 24, 37),     // mantle
325            edit_bg: Color::Rgb(137, 180, 250),           // blue (dimmed)
326            focus_bg: Color::Rgb(203, 166, 247),          // mauve (purple focus indicator)
327            primary_text: Color::Rgb(205, 214, 244),      // text
328            secondary_text: Color::Rgb(127, 132, 156),    // overlay0
329            highlight_text: Color::Rgb(137, 180, 250),    // blue
330            success: Color::Rgb(166, 227, 161),           // green
331            warning: Color::Rgb(249, 226, 175),           // yellow
332            error: Color::Rgb(243, 139, 168),             // red
333            info: Color::Rgb(137, 180, 250),              // blue
334            timer_text: Color::Rgb(245, 194, 231),        // pink
335            badge: Color::Rgb(203, 166, 247),             // mauve
336        }
337    }
338
339    /// Gruvbox theme (retro warm colors)
340    pub fn gruvbox() -> Self {
341        Self {
342            active_border: Color::Rgb(131, 165, 152),     // aqua
343            inactive_border: Color::Rgb(60, 56, 54),      // bg2
344            searching_border: Color::Rgb(250, 189, 47),   // yellow
345            selected_bg: Color::Rgb(60, 56, 54),          // bg2
346            selected_inactive_bg: Color::Rgb(40, 40, 40), // bg0
347            visual_bg: Color::Rgb(69, 133, 136),          // blue
348            timer_active_bg: Color::Rgb(152, 151, 26),    // green
349            row_alternate_bg: Color::Rgb(29, 32, 33),     // bg0_h
350            edit_bg: Color::Rgb(80, 73, 69),              // bg3
351            focus_bg: Color::Rgb(211, 134, 155),          // purple (warm purple focus)
352            primary_text: Color::Rgb(235, 219, 178),      // fg
353            secondary_text: Color::Rgb(146, 131, 116),    // fg4
354            highlight_text: Color::Rgb(131, 165, 152),    // aqua
355            success: Color::Rgb(184, 187, 38),            // green
356            warning: Color::Rgb(250, 189, 47),            // yellow
357            error: Color::Rgb(251, 73, 52),               // red
358            info: Color::Rgb(131, 165, 152),              // aqua
359            timer_text: Color::Rgb(254, 128, 25),         // orange
360            badge: Color::Rgb(211, 134, 155),             // purple
361        }
362    }
363
364    /// Monokai theme (classic vibrant)
365    pub fn monokai() -> Self {
366        Self {
367            active_border: Color::Rgb(102, 217, 239), // cyan (vibrant)
368            inactive_border: Color::Rgb(73, 72, 62),  // darker gray
369            searching_border: Color::Rgb(230, 219, 116), // yellow
370            selected_bg: Color::Rgb(73, 72, 62),      // dark gray
371            selected_inactive_bg: Color::Rgb(39, 40, 34), // background
372            visual_bg: Color::Rgb(249, 38, 114),      // magenta (distinct!)
373            timer_active_bg: Color::Rgb(166, 226, 46), // vibrant green
374            row_alternate_bg: Color::Rgb(30, 31, 28), // darker bg
375            edit_bg: Color::Rgb(100, 85, 60),         // warm brown (distinct from selected_bg!)
376            focus_bg: Color::Rgb(174, 129, 255),      // purple (vibrant purple focus)
377            primary_text: Color::Rgb(248, 248, 242),  // foreground
378            secondary_text: Color::Rgb(117, 113, 94), // comment gray
379            highlight_text: Color::Rgb(253, 151, 31), // bright orange (not cyan!)
380            success: Color::Rgb(166, 226, 46),        // vibrant green
381            warning: Color::Rgb(230, 219, 116),       // yellow
382            error: Color::Rgb(249, 38, 114),          // magenta/pink
383            info: Color::Rgb(102, 217, 239),          // cyan
384            timer_text: Color::Rgb(253, 151, 31),     // orange
385            badge: Color::Rgb(174, 129, 255),         // purple
386        }
387    }
388
389    /// Dracula theme (purple/pink accents)
390    pub fn dracula() -> Self {
391        Self {
392            active_border: Color::Rgb(139, 233, 253),     // cyan
393            inactive_border: Color::Rgb(68, 71, 90),      // current line
394            searching_border: Color::Rgb(241, 250, 140),  // yellow
395            selected_bg: Color::Rgb(68, 71, 90),          // current line
396            selected_inactive_bg: Color::Rgb(40, 42, 54), // background
397            visual_bg: Color::Rgb(139, 233, 253),         // cyan (distinct from purple)
398            timer_active_bg: Color::Rgb(80, 250, 123),    // green
399            row_alternate_bg: Color::Rgb(30, 31, 40),     // darker than bg
400            edit_bg: Color::Rgb(98, 114, 164),            // purple (distinct from selected_bg!)
401            focus_bg: Color::Rgb(189, 147, 249),          // bright purple (focus indicator)
402            primary_text: Color::Rgb(248, 248, 242),      // foreground
403            secondary_text: Color::Rgb(98, 114, 164),     // purple/comment
404            highlight_text: Color::Rgb(189, 147, 249),    // bright purple
405            success: Color::Rgb(80, 250, 123),            // green
406            warning: Color::Rgb(241, 250, 140),           // yellow
407            error: Color::Rgb(255, 85, 85),               // red
408            info: Color::Rgb(139, 233, 253),              // cyan
409            timer_text: Color::Rgb(255, 184, 108),        // orange
410            badge: Color::Rgb(255, 121, 198),             // pink
411        }
412    }
413
414    /// Everforest theme (green forest aesthetic)
415    pub fn everforest() -> Self {
416        Self {
417            active_border: Color::Rgb(131, 192, 146),     // green
418            inactive_border: Color::Rgb(83, 86, 77),      // bg3
419            searching_border: Color::Rgb(219, 188, 127),  // yellow
420            selected_bg: Color::Rgb(67, 72, 60),          // bg2
421            selected_inactive_bg: Color::Rgb(45, 49, 41), // bg1
422            visual_bg: Color::Rgb(123, 175, 153),         // aqua
423            timer_active_bg: Color::Rgb(131, 192, 146),   // green
424            row_alternate_bg: Color::Rgb(35, 38, 32),     // bg0
425            edit_bg: Color::Rgb(83, 86, 77),              // bg3
426            focus_bg: Color::Rgb(217, 143, 172),          // purple (soft purple focus)
427            primary_text: Color::Rgb(211, 198, 170),      // fg
428            secondary_text: Color::Rgb(146, 142, 123),    // gray1
429            highlight_text: Color::Rgb(123, 175, 153),    // aqua
430            success: Color::Rgb(131, 192, 146),           // green
431            warning: Color::Rgb(219, 188, 127),           // yellow
432            error: Color::Rgb(230, 126, 128),             // red
433            info: Color::Rgb(123, 175, 153),              // aqua
434            timer_text: Color::Rgb(230, 152, 117),        // orange
435            badge: Color::Rgb(217, 143, 172),             // purple
436        }
437    }
438
439    /// Terminal theme (uses terminal's native colors)
440    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, // magenta/purple focus
452            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    /// Create theme from custom color definitions
465    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
490/// Parse color string (supports hex, RGB tuples, and named colors)
491fn parse_color(color_str: &str) -> Color {
492    let trimmed = color_str.trim();
493
494    // Handle hex colors (#RRGGBB or #RGB)
495    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            // Short hex format #RGB -> #RRGGBB
506            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    // Handle named colors
517    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            // Fallback: try to parse as RGB tuple "r,g,b" or "(r, g, b)"
537            // Strip parentheses if present
538            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            // Final fallback: return default white
550            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    // Theme-related tests
641
642    #[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        // Verify it returns a valid theme (checking a few fields)
654        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        // Verify it returns kanagawa theme (check one specific color)
666        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            // Just verify it doesn't panic and returns a theme
689        }
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        // Invalid formats should fallback to white
754        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"); // Out of range
761        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        // Verify custom theme is applied (check the custom red border)
837        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        // Should fallback to default theme
849        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        // Verify different color formats are parsed correctly
880        assert!(matches!(theme.active_border, Color::Rgb(126, 156, 216))); // hex
881        assert!(matches!(theme.inactive_border, Color::DarkGray)); // named
882        assert!(matches!(theme.selected_bg, Color::Rgb(50, 50, 70))); // rgb tuple
883        assert!(matches!(theme.timer_text, Color::Rgb(255, 199, 119))); // rgb tuple
884    }
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    // Additional comprehensive tests
928
929    #[test]
930    fn test_all_predefined_theme_methods() {
931        // Test that all theme methods return valid themes with all fields populated
932        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        // Verify each theme has different active_border colors (unique themes)
942        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        // Test lowercase hex
961        assert!(matches!(parse_color("#ffffff"), Color::Rgb(255, 255, 255)));
962        assert!(matches!(parse_color("#000000"), Color::Rgb(0, 0, 0)));
963
964        // Test uppercase hex
965        assert!(matches!(parse_color("#FFFFFF"), Color::Rgb(255, 255, 255)));
966        assert!(matches!(parse_color("#ABC"), Color::Rgb(170, 187, 204)));
967
968        // Test mixed case
969        assert!(matches!(parse_color("#FfFfFf"), Color::Rgb(255, 255, 255)));
970
971        // Test invalid hex (wrong length)
972        assert!(matches!(parse_color("#FF"), Color::White)); // fallback
973        assert!(matches!(parse_color("#FFFFFFF"), Color::White)); // fallback
974
975        // Test invalid hex characters
976        assert!(matches!(parse_color("#GGGGGG"), Color::White)); // fallback
977        assert!(matches!(parse_color("#XYZ"), Color::White)); // fallback
978    }
979
980    #[test]
981    fn test_parse_color_rgb_edge_cases() {
982        // Test boundary values
983        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        // Test with parentheses (should be handled by trim)
990        assert!(matches!(
991            parse_color("(100, 150, 200)"),
992            Color::Rgb(100, 150, 200)
993        ));
994
995        // Test various spacing
996        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        // Test invalid RGB values
1003        assert!(matches!(parse_color("256, 100, 100"), Color::White)); // out of range
1004        assert!(matches!(parse_color("100, 300, 100"), Color::White)); // out of range
1005        assert!(matches!(parse_color("100, 100, 256"), Color::White)); // out of range
1006        assert!(matches!(parse_color("-1, 100, 100"), Color::White)); // negative
1007        assert!(matches!(parse_color("abc, 100, 100"), Color::White)); // non-numeric
1008
1009        // Test wrong number of components
1010        assert!(matches!(parse_color("100, 100"), Color::White)); // too few
1011        assert!(matches!(parse_color("100, 100, 100, 100"), Color::White)); // too many
1012    }
1013
1014    #[test]
1015    fn test_parse_color_named_variations() {
1016        // Test case variations
1017        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        // Test grey vs gray
1022        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        // Test all light colors
1030        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        // Test terminal color aliases
1038        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        // Test leading/trailing whitespace
1048        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        // Empty strings should fallback to white
1060        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        // Verify that all pre-defined themes have consistent structure
1068        // (all 18 colors are present and valid)
1069        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            // Just access all fields to ensure they exist
1082            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(),        // hex
1107            inactive_border: "darkgray".to_string(),     // named
1108            searching_border: "255, 255, 0".to_string(), // rgb
1109            selected_bg: "#00F".to_string(),             // short hex
1110            selected_inactive_bg: "Black".to_string(),   // named (capitalized)
1111            visual_bg: "0, 128, 255".to_string(),        // rgb
1112            timer_active_bg: "lightgreen".to_string(),   // named
1113            row_alternate_bg: "#111".to_string(),        // short hex
1114            edit_bg: "(50, 100, 150)".to_string(),       // rgb with parens
1115            focus_bg: "magenta".to_string(),             // named
1116            primary_text: "white".to_string(),           // named
1117            secondary_text: "128, 128, 128".to_string(), // rgb
1118            highlight_text: "#0FF".to_string(),          // short hex cyan
1119            success: "green".to_string(),                // named
1120            warning: "#FFAA00".to_string(),              // hex
1121            error: "255, 0, 0".to_string(),              // rgb
1122            info: "cyan".to_string(),                    // named
1123            timer_text: "#FFA500".to_string(),           // hex
1124            badge: "magenta".to_string(),                // named
1125        };
1126
1127        let theme = Theme::from_custom(&custom_colors);
1128
1129        // Verify mixed color formats are parsed correctly
1130        assert!(matches!(theme.active_border, Color::Rgb(255, 0, 0))); // hex
1131        assert!(matches!(theme.inactive_border, Color::DarkGray)); // named
1132        assert!(matches!(theme.searching_border, Color::Rgb(255, 255, 0))); // rgb
1133        assert!(matches!(theme.selected_bg, Color::Rgb(0, 0, 255))); // short hex
1134        assert!(matches!(theme.selected_inactive_bg, Color::Black)); // named
1135        assert!(matches!(theme.visual_bg, Color::Rgb(0, 128, 255))); // rgb
1136        assert!(matches!(theme.timer_active_bg, Color::LightGreen)); // named
1137        assert!(matches!(theme.row_alternate_bg, Color::Rgb(17, 17, 17))); // short hex
1138    }
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        // Verify the active theme is theme2 with magenta border
1196        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        // Theme names should be case-sensitive (lowercase by convention)
1203        let theme_config = ThemeConfig {
1204            active: "KANAGAWA".to_string(), // uppercase (not found)
1205            custom: HashMap::new(),
1206        };
1207
1208        let theme = theme_config.get_active_theme();
1209        // Should fallback to default theme (not kanagawa)
1210        assert!(matches!(theme.active_border, Color::Cyan)); // default theme
1211    }
1212
1213    #[test]
1214    fn test_custom_theme_overrides_predefined() {
1215        // Custom theme with same name as predefined should override
1216        let mut custom = HashMap::new();
1217        custom.insert(
1218            "default".to_string(),
1219            CustomThemeColors {
1220                active_border: "#FF0000".to_string(), // red instead of cyan
1221                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        // Should use custom theme (red), not predefined default (cyan)
1249        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        // RGB tuples can have parentheses (users might include them) - we strip them
1255        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        // Parentheses are now stripped, so this should parse successfully
1265        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        // Test that ThemeConfig can be serialized/deserialized
1272        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}