1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ThemeConfig {
16 #[serde(default)]
18 pub cli: CliColors,
19
20 #[serde(default)]
22 pub diff: DiffColors,
23
24 #[serde(default)]
26 pub status: StatusColors,
27
28 #[serde(default)]
30 pub files: FileColors,
31}
32
33impl ThemeConfig {
34 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 pub fn new() -> Self {
48 Self::default_config()
49 }
50
51 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#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CliColors {
71 #[serde(default = "default_cli_success")]
73 pub success: String,
74
75 #[serde(default = "default_cli_error")]
77 pub error: String,
78
79 #[serde(default = "default_cli_warning")]
81 pub warning: String,
82
83 #[serde(default = "default_cli_info")]
85 pub info: String,
86
87 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct DiffColors {
107 #[serde(default = "default_diff_new")]
109 pub new: String,
110
111 #[serde(default = "default_diff_old")]
113 pub old: String,
114
115 #[serde(default = "default_diff_context")]
117 pub context: String,
118
119 #[serde(default = "default_diff_header")]
121 pub header: String,
122
123 #[serde(default = "default_diff_meta")]
125 pub meta: String,
126
127 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct StatusColors {
148 #[serde(default = "default_status_added")]
150 pub added: String,
151
152 #[serde(default = "default_status_modified")]
154 pub modified: String,
155
156 #[serde(default = "default_status_deleted")]
158 pub deleted: String,
159
160 #[serde(default = "default_status_untracked")]
162 pub untracked: String,
163
164 #[serde(default = "default_status_current")]
166 pub current: String,
167
168 #[serde(default = "default_status_local")]
170 pub local: String,
171
172 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct FileColors {
194 #[serde(default = "default_file_directory")]
196 pub directory: String,
197
198 #[serde(default = "default_file_symlink")]
200 pub symlink: String,
201
202 #[serde(default = "default_file_executable")]
204 pub executable: String,
205
206 #[serde(default = "default_file_regular")]
208 pub regular: String,
209
210 #[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
235macro_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 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 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 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 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#[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#[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#[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#[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(); config.files.symlink = "01;35".to_owned(); 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 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}