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 =
41            toml::from_str(&content).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
316                .parse_flexible(color_str)
317                .with_context(|| format!("Failed to parse style for extension '{ext}': {color_str}"))?;
318            extension_styles.insert(ext.clone(), style);
319        }
320
321        Ok(ParsedFileColors {
322            directory: parser.parse_flexible(&self.files.directory)?,
323            symlink: parser.parse_flexible(&self.files.symlink)?,
324            executable: parser.parse_flexible(&self.files.executable)?,
325            regular: parser.parse_flexible(&self.files.regular)?,
326            extensions: extension_styles,
327        })
328    }
329}
330
331/// Parsed CLI colors with [`AnsiStyle`] values ready for rendering.
332#[derive(Debug, Clone)]
333pub struct ParsedCliColors {
334    /// Style for success messages.
335    pub success: AnsiStyle,
336    /// Style for error messages.
337    pub error: AnsiStyle,
338    /// Style for warning messages.
339    pub warning: AnsiStyle,
340    /// Style for info messages.
341    pub info: AnsiStyle,
342    /// Style for prompt text.
343    pub prompt: AnsiStyle,
344}
345
346/// Parsed diff colors with [`AnsiStyle`] values ready for rendering.
347#[derive(Debug, Clone)]
348pub struct ParsedDiffColors {
349    /// Style for added lines.
350    pub new: AnsiStyle,
351    /// Style for removed lines.
352    pub old: AnsiStyle,
353    /// Style for context (unchanged) lines.
354    pub context: AnsiStyle,
355    /// Style for diff headers.
356    pub header: AnsiStyle,
357    /// Style for diff metadata.
358    pub meta: AnsiStyle,
359    /// Style for fragment indicators.
360    pub frag: AnsiStyle,
361}
362
363/// Parsed status colors with [`AnsiStyle`] values ready for rendering.
364#[derive(Debug, Clone)]
365pub struct ParsedStatusColors {
366    /// Style for added files.
367    pub added: AnsiStyle,
368    /// Style for modified files.
369    pub modified: AnsiStyle,
370    /// Style for deleted files.
371    pub deleted: AnsiStyle,
372    /// Style for untracked files.
373    pub untracked: AnsiStyle,
374    /// Style for the current branch.
375    pub current: AnsiStyle,
376    /// Style for local branches.
377    pub local: AnsiStyle,
378    /// Style for remote branches.
379    pub remote: AnsiStyle,
380}
381
382/// Parsed file-type colors with [`AnsiStyle`] values ready for rendering.
383#[derive(Debug, Clone)]
384pub struct ParsedFileColors {
385    /// Style for directories.
386    pub directory: AnsiStyle,
387    /// Style for symbolic links.
388    pub symlink: AnsiStyle,
389    /// Style for executable files.
390    pub executable: AnsiStyle,
391    /// Style for regular files without a matching extension rule.
392    pub regular: AnsiStyle,
393    /// Per-extension styles keyed by extension (without the leading dot).
394    pub extensions: hashbrown::HashMap<String, AnsiStyle>,
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_default_config() {
403        let config = ThemeConfig::default();
404        assert_eq!(config.cli.success, "green");
405        assert_eq!(config.diff.new, "green");
406        assert_eq!(config.status.added, "green");
407        assert_eq!(config.files.directory, "bold cyan");
408    }
409
410    #[test]
411    fn test_load_from_toml() {
412        let toml_content = r#"
413[cli]
414success = "bold green"
415error = "bold red"
416
417[diff]
418new = "green"
419old = "red"
420
421[status]
422added = "green"
423modified = "cyan"
424
425[files]
426directory = "bold cyan"
427executable = "bold cyan"
428
429[files.extensions]
430"rs" = "bright cyan"
431"py" = "bright cyan"
432"#;
433
434        let temp_file = tempfile::NamedTempFile::new().unwrap();
435        std::fs::write(&temp_file, toml_content).unwrap();
436
437        let config = ThemeConfig::load_from_file(&temp_file).expect("Failed to load config");
438        assert_eq!(config.cli.success, "bold green");
439        assert_eq!(config.diff.new, "green");
440        assert_eq!(config.files.extensions.get("rs"), Some(&"bright cyan".to_owned()));
441        assert_eq!(config.files.extensions.get("py"), Some(&"bright cyan".to_owned()));
442    }
443
444    #[test]
445    fn test_parse_styles() {
446        let config = ThemeConfig::default();
447
448        let cli_styles = config.parse_cli_styles().expect("Failed to parse CLI styles");
449        assert_ne!(cli_styles.success, AnsiStyle::new());
450
451        let diff_styles = config.parse_diff_styles().expect("Failed to parse diff styles");
452        assert_ne!(diff_styles.new, AnsiStyle::new());
453
454        let status_styles = config.parse_status_styles().expect("Failed to parse status styles");
455        assert_ne!(status_styles.added, AnsiStyle::new());
456
457        let file_styles = config.parse_file_styles().expect("Failed to parse file styles");
458        assert_ne!(file_styles.directory, AnsiStyle::new());
459    }
460
461    #[test]
462    fn test_parse_custom_styles() {
463        let mut config = ThemeConfig::default();
464        config.cli.success = "bold red ul".to_owned();
465        config.diff.new = "#00ff00".to_owned(); // RGB green
466        config.files.symlink = "01;35".to_owned(); // ANSI code for bold magenta
467
468        let cli_styles = config.parse_cli_styles().expect("Failed to parse CLI styles");
469        assert!(cli_styles.success.get_effects().contains(anstyle::Effects::BOLD));
470        assert!(cli_styles.success.get_effects().contains(anstyle::Effects::UNDERLINE));
471
472        let diff_styles = config.parse_diff_styles().expect("Failed to parse diff styles");
473        // The green color should be set
474        assert_ne!(diff_styles.new.get_fg_color(), None);
475
476        let file_styles = config.parse_file_styles().expect("Failed to parse file styles");
477        assert!(file_styles.symlink.get_effects().contains(anstyle::Effects::BOLD));
478    }
479}