1use std::fmt;
2use std::path::Path;
3
4const REQUIRED_COLOR_FIELDS: &[&str] = &[
5 "bg",
6 "dialog_bg",
7 "fg",
8 "accent",
9 "accent_secondary",
10 "highlight",
11 "muted",
12 "success",
13 "warning",
14 "danger",
15 "border",
16 "selection_bg",
17 "selection_fg",
18 "graph_line",
19];
20
21#[derive(Debug, Clone)]
22pub enum ValidationError {
23 InvalidToml {
24 message: String,
25 line: Option<usize>,
26 col: Option<usize>,
27 },
28 MissingNameField,
29 MissingField {
30 variant: String,
31 field: String,
32 },
33 InvalidColor {
34 variant: String,
35 field: String,
36 value: String,
37 },
38 NoVariants,
39}
40
41impl fmt::Display for ValidationError {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Self::InvalidToml { message, line, col } => {
45 if let (Some(l), Some(c)) = (line, col) {
46 write!(f, "Invalid TOML at line {}, col {}: {}", l, c, message)
47 } else {
48 write!(f, "Invalid TOML: {}", message)
49 }
50 }
51 Self::MissingNameField => write!(f, "Missing required 'name' field"),
52 Self::MissingField { variant, field } => {
53 write!(f, "[{}] Missing required field: {}", variant, field)
54 }
55 Self::InvalidColor {
56 variant,
57 field,
58 value,
59 } => {
60 write!(
61 f,
62 "[{}] Invalid color for '{}': \"{}\" (expected #RRGGBB hex)",
63 variant, field, value
64 )
65 }
66 Self::NoVariants => {
67 write!(f, "Theme must have at least one [dark] or [light] section")
68 }
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
74pub enum ValidationWarning {
75 MissingDarkVariant,
76 MissingLightVariant,
77}
78
79impl fmt::Display for ValidationWarning {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::MissingDarkVariant => {
83 write!(
84 f,
85 "Missing [dark] variant (theme will only work in light mode)"
86 )
87 }
88 Self::MissingLightVariant => {
89 write!(
90 f,
91 "Missing [light] variant (theme will only work in dark mode)"
92 )
93 }
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
99pub struct ValidationResult {
100 pub path: String,
101 pub theme_id: String,
102 pub theme_name: Option<String>,
103 pub errors: Vec<ValidationError>,
104 pub warnings: Vec<ValidationWarning>,
105}
106
107impl ValidationResult {
108 pub fn is_valid(&self) -> bool {
109 self.errors.is_empty()
110 }
111
112 pub fn has_warnings(&self) -> bool {
113 !self.warnings.is_empty()
114 }
115}
116
117pub fn validate_hex_color(value: &str) -> bool {
118 let hex = value.trim().trim_start_matches('#');
119 (hex.len() == 3 || hex.len() == 6) && hex.chars().all(|c| c.is_ascii_hexdigit())
120}
121
122fn parse_toml_with_location(content: &str) -> Result<toml::Value, ValidationError> {
123 toml::from_str(content).map_err(|e| {
124 let message = e.message().to_string();
125 let span = e.span();
126
127 let (line, col) = if let Some(span) = span {
128 let line = content[..span.start].matches('\n').count() + 1;
129 let last_newline = content[..span.start]
130 .rfind('\n')
131 .map(|i| i + 1)
132 .unwrap_or(0);
133 let col = span.start - last_newline + 1;
134 (Some(line), Some(col))
135 } else {
136 (None, None)
137 };
138
139 ValidationError::InvalidToml { message, line, col }
140 })
141}
142
143fn validate_variant_colors(
144 table: &toml::Value,
145 variant_name: &str,
146) -> (Vec<ValidationError>, bool) {
147 let mut errors = Vec::new();
148 let mut has_valid_colors = false;
149
150 let Some(variant) = table.get(variant_name) else {
151 return (errors, false);
152 };
153
154 let Some(variant_table) = variant.as_table() else {
155 errors.push(ValidationError::InvalidToml {
156 message: format!("[{}] must be a table", variant_name),
157 line: None,
158 col: None,
159 });
160 return (errors, false);
161 };
162
163 for &field in REQUIRED_COLOR_FIELDS {
164 match variant_table.get(field) {
165 None => {
166 errors.push(ValidationError::MissingField {
167 variant: variant_name.to_string(),
168 field: field.to_string(),
169 });
170 }
171 Some(value) => {
172 if let Some(color_str) = value.as_str() {
173 if validate_hex_color(color_str) {
174 has_valid_colors = true;
175 } else {
176 errors.push(ValidationError::InvalidColor {
177 variant: variant_name.to_string(),
178 field: field.to_string(),
179 value: color_str.to_string(),
180 });
181 }
182 } else {
183 errors.push(ValidationError::InvalidColor {
184 variant: variant_name.to_string(),
185 field: field.to_string(),
186 value: format!("{:?}", value),
187 });
188 }
189 }
190 }
191 }
192
193 let is_valid = has_valid_colors && errors.is_empty();
194 (errors, is_valid)
195}
196
197pub fn validate_theme_content(content: &str, path: &str, theme_id: &str) -> ValidationResult {
198 let mut errors = Vec::new();
199 let mut warnings = Vec::new();
200 let mut theme_name = None;
201
202 let toml_value = match parse_toml_with_location(content) {
203 Ok(v) => v,
204 Err(e) => {
205 return ValidationResult {
206 path: path.to_string(),
207 theme_id: theme_id.to_string(),
208 theme_name: None,
209 errors: vec![e],
210 warnings: vec![],
211 };
212 }
213 };
214
215 match toml_value.get("name") {
216 Some(name_val) => {
217 if let Some(name_str) = name_val.as_str() {
218 theme_name = Some(name_str.to_string());
219 } else {
220 errors.push(ValidationError::InvalidToml {
221 message: "'name' must be a string".to_string(),
222 line: None,
223 col: None,
224 });
225 }
226 }
227 None => {
228 errors.push(ValidationError::MissingNameField);
229 }
230 }
231
232 let (dark_errors, _dark_valid) = validate_variant_colors(&toml_value, "dark");
233 let (light_errors, _light_valid) = validate_variant_colors(&toml_value, "light");
234
235 let has_dark = toml_value.get("dark").is_some();
236 let has_light = toml_value.get("light").is_some();
237
238 errors.extend(dark_errors);
239 errors.extend(light_errors);
240
241 if !has_dark && !has_light {
242 errors.push(ValidationError::NoVariants);
243 }
244
245 if errors.is_empty() {
246 if !has_dark {
247 warnings.push(ValidationWarning::MissingDarkVariant);
248 }
249 if !has_light {
250 warnings.push(ValidationWarning::MissingLightVariant);
251 }
252 }
253
254 ValidationResult {
255 path: path.to_string(),
256 theme_id: theme_id.to_string(),
257 theme_name,
258 errors,
259 warnings,
260 }
261}
262
263pub fn validate_theme_files(dir: &Path) -> Vec<ValidationResult> {
264 let mut results = Vec::new();
265
266 if !dir.exists() {
267 return results;
268 }
269
270 let Ok(entries) = std::fs::read_dir(dir) else {
271 return results;
272 };
273
274 for entry in entries.flatten() {
275 let path = entry.path();
276 if path.extension().is_some_and(|e| e == "toml") {
277 let theme_id = path
278 .file_stem()
279 .and_then(|s| s.to_str())
280 .unwrap_or("unknown")
281 .to_string();
282
283 let path_str = path.display().to_string();
284
285 match std::fs::read_to_string(&path) {
286 Ok(content) => {
287 results.push(validate_theme_content(&content, &path_str, &theme_id));
288 }
289 Err(e) => {
290 results.push(ValidationResult {
291 path: path_str,
292 theme_id,
293 theme_name: None,
294 errors: vec![ValidationError::InvalidToml {
295 message: format!("Could not read file: {}", e),
296 line: None,
297 col: None,
298 }],
299 warnings: vec![],
300 });
301 }
302 }
303 }
304 }
305
306 results.sort_by(|a, b| a.theme_id.cmp(&b.theme_id));
307 results
308}
309
310pub fn print_validation_results(results: &[ValidationResult], verbose: bool) {
311 let errors: Vec<_> = results.iter().filter(|r| !r.is_valid()).collect();
312 let warnings: Vec<_> = results
313 .iter()
314 .filter(|r| r.is_valid() && r.has_warnings())
315 .collect();
316 let valid: Vec<_> = results
317 .iter()
318 .filter(|r| r.is_valid() && !r.has_warnings())
319 .collect();
320
321 println!("{}", "=".repeat(80));
322 println!("THEME VALIDATION");
323 println!("{}", "=".repeat(80));
324
325 if !errors.is_empty() {
326 println!("\nX ERRORS ({} theme(s) with issues)\n", errors.len());
327
328 for result in &errors {
329 let display_name = result.theme_name.as_deref().unwrap_or(&result.theme_id);
330 println!("{}:", display_name);
331
332 if result.path != result.theme_id {
333 println!(" File: {}", result.path);
334 }
335
336 for error in &result.errors {
337 println!(" * {}", error);
338 }
339 println!();
340 }
341 }
342
343 if !warnings.is_empty() {
344 println!("\n! WARNINGS ({} theme(s))\n", warnings.len());
345
346 for result in &warnings {
347 let display_name = result.theme_name.as_deref().unwrap_or(&result.theme_id);
348 println!("{}:", display_name);
349
350 for warning in &result.warnings {
351 println!(" * {}", warning);
352 }
353 println!();
354 }
355 }
356
357 if verbose && !valid.is_empty() {
358 println!("\n+ VALID ({} theme(s))\n", valid.len());
359
360 for result in &valid {
361 let display_name = result.theme_name.as_deref().unwrap_or(&result.theme_id);
362 println!(" {}", display_name);
363 }
364 println!();
365 }
366 println!("{}", "=".repeat(80));
367 println!("VALIDATION SUMMARY");
368 println!("{}", "=".repeat(80));
369 println!(
370 "\nThemes checked: {} | Valid: {} | Errors: {} | Warnings: {}",
371 results.len(),
372 valid.len() + warnings.len(),
373 errors.len(),
374 warnings.len()
375 );
376
377 if errors.is_empty() && warnings.is_empty() {
378 println!("\n+ All themes passed validation!");
379 } else if errors.is_empty() {
380 println!("\n+ All themes are valid (with some warnings)");
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_validate_hex_color_valid() {
390 assert!(validate_hex_color("#ffffff"));
391 assert!(validate_hex_color("#FFFFFF"));
392 assert!(validate_hex_color("#000000"));
393 assert!(validate_hex_color("#abc"));
394 assert!(validate_hex_color("#ABC"));
395 assert!(validate_hex_color("#1a2b3c"));
396 assert!(validate_hex_color("ffffff"));
397 assert!(validate_hex_color(" #ffffff "));
398 }
399
400 #[test]
401 fn test_validate_hex_color_invalid() {
402 assert!(!validate_hex_color("#gggggg"));
403 assert!(!validate_hex_color("#12345"));
404 assert!(!validate_hex_color("#1234567"));
405 assert!(!validate_hex_color(""));
406 assert!(!validate_hex_color("#"));
407 assert!(!validate_hex_color("not-a-color"));
408 }
409
410 #[test]
411 fn test_validate_valid_theme() {
412 let content = r##"
413name = "Test Theme"
414
415[dark]
416bg = "#1e1e2e"
417dialog_bg = "#313244"
418fg = "#cdd6f4"
419accent = "#89b4fa"
420accent_secondary = "#cba6f7"
421highlight = "#f9e2af"
422muted = "#6c7086"
423success = "#a6e3a1"
424warning = "#fab387"
425danger = "#f38ba8"
426border = "#45475a"
427selection_bg = "#585b70"
428selection_fg = "#cdd6f4"
429graph_line = "#89b4fa"
430"##;
431
432 let result = validate_theme_content(content, "test.toml", "test");
433 assert!(
434 result.is_valid(),
435 "Expected valid, got errors: {:?}",
436 result.errors
437 );
438 assert!(result.has_warnings());
439 }
440
441 #[test]
442 fn test_validate_missing_name() {
443 let content = r##"
444[dark]
445bg = "#1e1e2e"
446"##;
447
448 let result = validate_theme_content(content, "test.toml", "test");
449 assert!(!result.is_valid());
450 assert!(result
451 .errors
452 .iter()
453 .any(|e| matches!(e, ValidationError::MissingNameField)));
454 }
455
456 #[test]
457 fn test_validate_invalid_toml() {
458 let content = r##"
459name = "Test
460broken syntax
461"##;
462
463 let result = validate_theme_content(content, "test.toml", "test");
464 assert!(!result.is_valid());
465 assert!(result
466 .errors
467 .iter()
468 .any(|e| matches!(e, ValidationError::InvalidToml { .. })));
469 }
470
471 #[test]
472 fn test_validate_missing_field() {
473 let content = r##"
474name = "Test Theme"
475
476[dark]
477bg = "#1e1e2e"
478fg = "#cdd6f4"
479"##;
480
481 let result = validate_theme_content(content, "test.toml", "test");
482 assert!(!result.is_valid());
483 assert!(result
484 .errors
485 .iter()
486 .any(|e| matches!(e, ValidationError::MissingField { .. })));
487 }
488
489 #[test]
490 fn test_validate_invalid_color() {
491 let content = r##"
492name = "Test Theme"
493
494[dark]
495bg = "not-a-color"
496dialog_bg = "#313244"
497fg = "#cdd6f4"
498accent = "#89b4fa"
499accent_secondary = "#cba6f7"
500highlight = "#f9e2af"
501muted = "#6c7086"
502success = "#a6e3a1"
503warning = "#fab387"
504danger = "#f38ba8"
505border = "#45475a"
506selection_bg = "#585b70"
507selection_fg = "#cdd6f4"
508graph_line = "#89b4fa"
509"##;
510
511 let result = validate_theme_content(content, "test.toml", "test");
512 assert!(!result.is_valid());
513 assert!(result
514 .errors
515 .iter()
516 .any(|e| matches!(e, ValidationError::InvalidColor { field, .. } if field == "bg")));
517 }
518
519 #[test]
520 fn test_validate_no_variants() {
521 let content = r##"
522name = "Test Theme"
523"##;
524
525 let result = validate_theme_content(content, "test.toml", "test");
526 assert!(!result.is_valid());
527 assert!(result
528 .errors
529 .iter()
530 .any(|e| matches!(e, ValidationError::NoVariants)));
531 }
532
533 #[test]
534 fn test_validate_both_variants_valid() {
535 let content = r##"
536name = "Full Theme"
537
538[dark]
539bg = "#1e1e2e"
540dialog_bg = "#313244"
541fg = "#cdd6f4"
542accent = "#89b4fa"
543accent_secondary = "#cba6f7"
544highlight = "#f9e2af"
545muted = "#6c7086"
546success = "#a6e3a1"
547warning = "#fab387"
548danger = "#f38ba8"
549border = "#45475a"
550selection_bg = "#585b70"
551selection_fg = "#cdd6f4"
552graph_line = "#89b4fa"
553
554[light]
555bg = "#eff1f5"
556dialog_bg = "#e6e9ef"
557fg = "#4c4f69"
558accent = "#1e66f5"
559accent_secondary = "#8839ef"
560highlight = "#df8e1d"
561muted = "#6c6f85"
562success = "#40a02b"
563warning = "#fe640b"
564danger = "#d20f39"
565border = "#bcc0cc"
566selection_bg = "#acb0be"
567selection_fg = "#4c4f69"
568graph_line = "#1e66f5"
569"##;
570
571 let result = validate_theme_content(content, "test.toml", "test");
572 assert!(
573 result.is_valid(),
574 "Expected valid, got errors: {:?}",
575 result.errors
576 );
577 assert!(!result.has_warnings());
578 }
579}