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 =
41 toml::from_str(&content).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
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#[derive(Debug, Clone)]
333pub struct ParsedCliColors {
334 pub success: AnsiStyle,
336 pub error: AnsiStyle,
338 pub warning: AnsiStyle,
340 pub info: AnsiStyle,
342 pub prompt: AnsiStyle,
344}
345
346#[derive(Debug, Clone)]
348pub struct ParsedDiffColors {
349 pub new: AnsiStyle,
351 pub old: AnsiStyle,
353 pub context: AnsiStyle,
355 pub header: AnsiStyle,
357 pub meta: AnsiStyle,
359 pub frag: AnsiStyle,
361}
362
363#[derive(Debug, Clone)]
365pub struct ParsedStatusColors {
366 pub added: AnsiStyle,
368 pub modified: AnsiStyle,
370 pub deleted: AnsiStyle,
372 pub untracked: AnsiStyle,
374 pub current: AnsiStyle,
376 pub local: AnsiStyle,
378 pub remote: AnsiStyle,
380}
381
382#[derive(Debug, Clone)]
384pub struct ParsedFileColors {
385 pub directory: AnsiStyle,
387 pub symlink: AnsiStyle,
389 pub executable: AnsiStyle,
391 pub regular: AnsiStyle,
393 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(); config.files.symlink = "01;35".to_owned(); 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 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}