Skip to main content

jolt_theme/
validation.rs

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}