Skip to main content

vtcode_core/ui/
theme_config.rs

1//! Theme Configuration File Support
2//!
3//! Parses custom .vtcode/theme.toml files with Git/LS-style syntax for colors.
4//! This allows users to customize colors beyond system defaults.
5
6use crate::utils::CachedStyleParser;
7use crate::utils::file_utils::read_file_with_context_sync;
8use anstyle::Style as AnsiStyle;
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12
13/// Theme configuration that can be loaded from a .vtcode/theme.toml file
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ThemeConfig {
16    /// Colors for CLI elements
17    #[serde(default)]
18    pub cli: CliColors,
19
20    /// Colors for diff rendering
21    #[serde(default)]
22    pub diff: DiffColors,
23
24    /// Colors for status output
25    #[serde(default)]
26    pub status: StatusColors,
27
28    /// Colors for file types (LS_COLORS-style)
29    #[serde(default)]
30    pub files: FileColors,
31}
32
33impl ThemeConfig {
34    /// Load theme configuration from a TOML file
35    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
36        let path = path.as_ref();
37        let content = read_file_with_context_sync(path, "theme file")
38            .with_context(|| format!("Failed to read theme file: {}", path.display()))?;
39
40        let config: ThemeConfig = toml::from_str(&content)
41            .with_context(|| format!("Failed to parse theme file: {}", path.display()))?;
42
43        Ok(config)
44    }
45
46    /// Create default theme configuration
47    pub fn new() -> Self {
48        Self::default_config()
49    }
50
51    /// Returns a default configuration
52    fn default_config() -> Self {
53        Self {
54            cli: CliColors::default(),
55            diff: DiffColors::default(),
56            status: StatusColors::default(),
57            files: FileColors::default(),
58        }
59    }
60}
61
62impl Default for ThemeConfig {
63    fn default() -> Self {
64        Self::default_config()
65    }
66}
67
68/// Colors for CLI elements like prompts, messages, etc.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CliColors {
71    /// Color for success messages
72    #[serde(default = "default_cli_success")]
73    pub success: String,
74
75    /// Color for error messages
76    #[serde(default = "default_cli_error")]
77    pub error: String,
78
79    /// Color for warning messages
80    #[serde(default = "default_cli_warning")]
81    pub warning: String,
82
83    /// Color for info messages
84    #[serde(default = "default_cli_info")]
85    pub info: String,
86
87    /// Color for prompt text
88    #[serde(default = "default_cli_prompt")]
89    pub prompt: String,
90}
91
92impl Default for CliColors {
93    fn default() -> Self {
94        Self {
95            success: "green".into(),
96            error: "red".into(),
97            warning: "red".into(),
98            info: "cyan".into(),
99            prompt: "bold cyan".into(),
100        }
101    }
102}
103
104/// Colors for diff rendering
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct DiffColors {
107    /// Color for added lines in diff
108    #[serde(default = "default_diff_new")]
109    pub new: String,
110
111    /// Color for removed lines in diff
112    #[serde(default = "default_diff_old")]
113    pub old: String,
114
115    /// Color for context/unchanged lines in diff
116    #[serde(default = "default_diff_context")]
117    pub context: String,
118
119    /// Color for diff headers
120    #[serde(default = "default_diff_header")]
121    pub header: String,
122
123    /// Color for diff metadata
124    #[serde(default = "default_diff_meta")]
125    pub meta: String,
126
127    /// Color for diff fragment indicators
128    #[serde(default = "default_diff_frag")]
129    pub frag: String,
130}
131
132impl Default for DiffColors {
133    fn default() -> Self {
134        Self {
135            new: "green".into(),
136            old: "red".into(),
137            context: "dim".into(),
138            header: "bold cyan".into(),
139            meta: "cyan".into(),
140            frag: "cyan".into(),
141        }
142    }
143}
144
145/// Colors for status output
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct StatusColors {
148    /// Color for added files
149    #[serde(default = "default_status_added")]
150    pub added: String,
151
152    /// Color for modified files
153    #[serde(default = "default_status_modified")]
154    pub modified: String,
155
156    /// Color for deleted files
157    #[serde(default = "default_status_deleted")]
158    pub deleted: String,
159
160    /// Color for untracked files
161    #[serde(default = "default_status_untracked")]
162    pub untracked: String,
163
164    /// Color for current branch
165    #[serde(default = "default_status_current")]
166    pub current: String,
167
168    /// Color for local branches
169    #[serde(default = "default_status_local")]
170    pub local: String,
171
172    /// Color for remote branches
173    #[serde(default = "default_status_remote")]
174    pub remote: String,
175}
176
177impl Default for StatusColors {
178    fn default() -> Self {
179        Self {
180            added: "green".into(),
181            modified: "cyan".into(),
182            deleted: "red bold".into(),
183            untracked: "cyan".into(),
184            current: "cyan bold".into(),
185            local: "cyan".into(),
186            remote: "cyan".into(),
187        }
188    }
189}
190
191/// File type colors using LS_COLORS-style patterns
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct FileColors {
194    /// Directory color
195    #[serde(default = "default_file_directory")]
196    pub directory: String,
197
198    /// Symbolic link color
199    #[serde(default = "default_file_symlink")]
200    pub symlink: String,
201
202    /// Executable file color
203    #[serde(default = "default_file_executable")]
204    pub executable: String,
205
206    /// Regular file color
207    #[serde(default = "default_file_regular")]
208    pub regular: String,
209
210    /// Custom colors for file extensions
211    #[serde(default)]
212    pub extensions: hashbrown::HashMap<String, String>,
213}
214
215impl Default for FileColors {
216    fn default() -> Self {
217        let mut extensions = hashbrown::HashMap::new();
218        extensions.insert("rs".into(), "cyan".into());
219        extensions.insert("js".into(), "cyan".into());
220        extensions.insert("ts".into(), "cyan".into());
221        extensions.insert("py".into(), "green".into());
222        extensions.insert("toml".into(), "cyan".into());
223        extensions.insert("md".into(), String::new());
224
225        Self {
226            directory: "bold cyan".into(),
227            symlink: "cyan".into(),
228            executable: "bold green".into(),
229            regular: String::new(),
230            extensions,
231        }
232    }
233}
234
235// Default value functions
236macro_rules! serde_default_string {
237    ($name:ident, $value:expr) => {
238        fn $name() -> String {
239            $value.into()
240        }
241    };
242}
243
244serde_default_string!(default_cli_success, "green");
245serde_default_string!(default_cli_error, "red");
246serde_default_string!(default_cli_warning, "red");
247serde_default_string!(default_cli_info, "cyan");
248serde_default_string!(default_cli_prompt, "bold cyan");
249
250serde_default_string!(default_diff_new, "green");
251serde_default_string!(default_diff_old, "red");
252serde_default_string!(default_diff_context, "dim");
253serde_default_string!(default_diff_header, "bold cyan");
254serde_default_string!(default_diff_meta, "cyan");
255serde_default_string!(default_diff_frag, "cyan");
256
257serde_default_string!(default_status_added, "green");
258serde_default_string!(default_status_modified, "cyan");
259serde_default_string!(default_status_deleted, "red bold");
260serde_default_string!(default_status_untracked, "cyan");
261serde_default_string!(default_status_current, "cyan bold");
262serde_default_string!(default_status_local, "cyan");
263serde_default_string!(default_status_remote, "cyan");
264
265serde_default_string!(default_file_directory, "bold cyan");
266serde_default_string!(default_file_symlink, "cyan");
267serde_default_string!(default_file_executable, "bold green");
268serde_default_string!(default_file_regular, "");
269
270impl ThemeConfig {
271    /// Convert CLI colors to anstyle::Style
272    pub fn parse_cli_styles(&self) -> Result<ParsedCliColors> {
273        let parser = CachedStyleParser::default();
274        Ok(ParsedCliColors {
275            success: parser.parse_flexible(&self.cli.success)?,
276            error: parser.parse_flexible(&self.cli.error)?,
277            warning: parser.parse_flexible(&self.cli.warning)?,
278            info: parser.parse_flexible(&self.cli.info)?,
279            prompt: parser.parse_flexible(&self.cli.prompt)?,
280        })
281    }
282
283    /// Convert diff colors to anstyle::Style
284    pub fn parse_diff_styles(&self) -> Result<ParsedDiffColors> {
285        let parser = CachedStyleParser::default();
286        Ok(ParsedDiffColors {
287            new: parser.parse_flexible(&self.diff.new)?,
288            old: parser.parse_flexible(&self.diff.old)?,
289            context: parser.parse_flexible(&self.diff.context)?,
290            header: parser.parse_flexible(&self.diff.header)?,
291            meta: parser.parse_flexible(&self.diff.meta)?,
292            frag: parser.parse_flexible(&self.diff.frag)?,
293        })
294    }
295
296    /// Convert status colors to anstyle::Style
297    pub fn parse_status_styles(&self) -> Result<ParsedStatusColors> {
298        let parser = CachedStyleParser::default();
299        Ok(ParsedStatusColors {
300            added: parser.parse_flexible(&self.status.added)?,
301            modified: parser.parse_flexible(&self.status.modified)?,
302            deleted: parser.parse_flexible(&self.status.deleted)?,
303            untracked: parser.parse_flexible(&self.status.untracked)?,
304            current: parser.parse_flexible(&self.status.current)?,
305            local: parser.parse_flexible(&self.status.local)?,
306            remote: parser.parse_flexible(&self.status.remote)?,
307        })
308    }
309
310    /// Convert file colors to anstyle::Style
311    pub fn parse_file_styles(&self) -> Result<ParsedFileColors> {
312        let parser = CachedStyleParser::default();
313        let mut extension_styles = hashbrown::HashMap::new();
314        for (ext, color_str) in &self.files.extensions {
315            let style = parser.parse_flexible(color_str).with_context(|| {
316                format!(
317                    "Failed to parse style for extension '{}': {}",
318                    ext, color_str
319                )
320            })?;
321            extension_styles.insert(ext.clone(), style);
322        }
323
324        Ok(ParsedFileColors {
325            directory: parser.parse_flexible(&self.files.directory)?,
326            symlink: parser.parse_flexible(&self.files.symlink)?,
327            executable: parser.parse_flexible(&self.files.executable)?,
328            regular: parser.parse_flexible(&self.files.regular)?,
329            extensions: extension_styles,
330        })
331    }
332}
333
334/// Parsed CLI colors with anstyle::Style values
335#[derive(Debug, Clone)]
336pub struct ParsedCliColors {
337    pub success: AnsiStyle,
338    pub error: AnsiStyle,
339    pub warning: AnsiStyle,
340    pub info: AnsiStyle,
341    pub prompt: AnsiStyle,
342}
343
344/// Parsed diff colors with anstyle::Style values
345#[derive(Debug, Clone)]
346pub struct ParsedDiffColors {
347    pub new: AnsiStyle,
348    pub old: AnsiStyle,
349    pub context: AnsiStyle,
350    pub header: AnsiStyle,
351    pub meta: AnsiStyle,
352    pub frag: AnsiStyle,
353}
354
355/// Parsed status colors with anstyle::Style values
356#[derive(Debug, Clone)]
357pub struct ParsedStatusColors {
358    pub added: AnsiStyle,
359    pub modified: AnsiStyle,
360    pub deleted: AnsiStyle,
361    pub untracked: AnsiStyle,
362    pub current: AnsiStyle,
363    pub local: AnsiStyle,
364    pub remote: AnsiStyle,
365}
366
367/// Parsed file colors with anstyle::Style values
368#[derive(Debug, Clone)]
369pub struct ParsedFileColors {
370    pub directory: AnsiStyle,
371    pub symlink: AnsiStyle,
372    pub executable: AnsiStyle,
373    pub regular: AnsiStyle,
374    pub extensions: hashbrown::HashMap<String, AnsiStyle>,
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_default_config() {
383        let config = ThemeConfig::default();
384        assert_eq!(config.cli.success, "green");
385        assert_eq!(config.diff.new, "green");
386        assert_eq!(config.status.added, "green");
387        assert_eq!(config.files.directory, "bold cyan");
388    }
389
390    #[test]
391    fn test_load_from_toml() {
392        let toml_content = r#"
393[cli]
394success = "bold green"
395error = "bold red"
396
397[diff]
398new = "green"
399old = "red"
400
401[status]
402added = "green"
403modified = "cyan"
404
405[files]
406directory = "bold cyan"
407executable = "bold cyan"
408
409[files.extensions]
410"rs" = "bright cyan"
411"py" = "bright cyan"
412"#;
413
414        let temp_file = tempfile::NamedTempFile::new().unwrap();
415        std::fs::write(&temp_file, toml_content).unwrap();
416
417        let config = ThemeConfig::load_from_file(&temp_file).expect("Failed to load config");
418        assert_eq!(config.cli.success, "bold green");
419        assert_eq!(config.diff.new, "green");
420        assert_eq!(
421            config.files.extensions.get("rs"),
422            Some(&"bright cyan".to_owned())
423        );
424        assert_eq!(
425            config.files.extensions.get("py"),
426            Some(&"bright cyan".to_owned())
427        );
428    }
429
430    #[test]
431    fn test_parse_styles() {
432        let config = ThemeConfig::default();
433
434        let cli_styles = config
435            .parse_cli_styles()
436            .expect("Failed to parse CLI styles");
437        assert_ne!(cli_styles.success, AnsiStyle::new());
438
439        let diff_styles = config
440            .parse_diff_styles()
441            .expect("Failed to parse diff styles");
442        assert_ne!(diff_styles.new, AnsiStyle::new());
443
444        let status_styles = config
445            .parse_status_styles()
446            .expect("Failed to parse status styles");
447        assert_ne!(status_styles.added, AnsiStyle::new());
448
449        let file_styles = config
450            .parse_file_styles()
451            .expect("Failed to parse file styles");
452        assert_ne!(file_styles.directory, AnsiStyle::new());
453    }
454
455    #[test]
456    fn test_parse_custom_styles() {
457        let mut config = ThemeConfig::default();
458        config.cli.success = "bold red ul".to_owned();
459        config.diff.new = "#00ff00".to_owned(); // RGB green
460        config.files.symlink = "01;35".to_owned(); // ANSI code for bold magenta
461
462        let cli_styles = config
463            .parse_cli_styles()
464            .expect("Failed to parse CLI styles");
465        assert!(
466            cli_styles
467                .success
468                .get_effects()
469                .contains(anstyle::Effects::BOLD)
470        );
471        assert!(
472            cli_styles
473                .success
474                .get_effects()
475                .contains(anstyle::Effects::UNDERLINE)
476        );
477
478        let diff_styles = config
479            .parse_diff_styles()
480            .expect("Failed to parse diff styles");
481        // The green color should be set
482        assert_ne!(diff_styles.new.get_fg_color(), None);
483
484        let file_styles = config
485            .parse_file_styles()
486            .expect("Failed to parse file styles");
487        assert!(
488            file_styles
489                .symlink
490                .get_effects()
491                .contains(anstyle::Effects::BOLD)
492        );
493    }
494}