use std::fmt;
use std::path::Path;
const REQUIRED_COLOR_FIELDS: &[&str] = &[
"bg",
"dialog_bg",
"fg",
"accent",
"accent_secondary",
"highlight",
"muted",
"success",
"warning",
"danger",
"border",
"selection_bg",
"selection_fg",
"graph_line",
];
#[derive(Debug, Clone)]
pub enum ValidationError {
InvalidToml {
message: String,
line: Option<usize>,
col: Option<usize>,
},
MissingNameField,
MissingField {
variant: String,
field: String,
},
InvalidColor {
variant: String,
field: String,
value: String,
},
NoVariants,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidToml { message, line, col } => {
if let (Some(l), Some(c)) = (line, col) {
write!(f, "Invalid TOML at line {}, col {}: {}", l, c, message)
} else {
write!(f, "Invalid TOML: {}", message)
}
}
Self::MissingNameField => write!(f, "Missing required 'name' field"),
Self::MissingField { variant, field } => {
write!(f, "[{}] Missing required field: {}", variant, field)
}
Self::InvalidColor {
variant,
field,
value,
} => {
write!(
f,
"[{}] Invalid color for '{}': \"{}\" (expected #RRGGBB hex)",
variant, field, value
)
}
Self::NoVariants => {
write!(f, "Theme must have at least one [dark] or [light] section")
}
}
}
}
#[derive(Debug, Clone)]
pub enum ValidationWarning {
MissingDarkVariant,
MissingLightVariant,
}
impl fmt::Display for ValidationWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingDarkVariant => {
write!(
f,
"Missing [dark] variant (theme will only work in light mode)"
)
}
Self::MissingLightVariant => {
write!(
f,
"Missing [light] variant (theme will only work in dark mode)"
)
}
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub path: String,
pub theme_id: String,
pub theme_name: Option<String>,
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
}
impl ValidationResult {
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
}
pub fn validate_hex_color(value: &str) -> bool {
let hex = value.trim().trim_start_matches('#');
(hex.len() == 3 || hex.len() == 6) && hex.chars().all(|c| c.is_ascii_hexdigit())
}
fn parse_toml_with_location(content: &str) -> Result<toml::Value, ValidationError> {
toml::from_str(content).map_err(|e| {
let message = e.message().to_string();
let span = e.span();
let (line, col) = if let Some(span) = span {
let line = content[..span.start].matches('\n').count() + 1;
let last_newline = content[..span.start]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let col = span.start - last_newline + 1;
(Some(line), Some(col))
} else {
(None, None)
};
ValidationError::InvalidToml { message, line, col }
})
}
fn validate_variant_colors(
table: &toml::Value,
variant_name: &str,
) -> (Vec<ValidationError>, bool) {
let mut errors = Vec::new();
let mut has_valid_colors = false;
let Some(variant) = table.get(variant_name) else {
return (errors, false);
};
let Some(variant_table) = variant.as_table() else {
errors.push(ValidationError::InvalidToml {
message: format!("[{}] must be a table", variant_name),
line: None,
col: None,
});
return (errors, false);
};
for &field in REQUIRED_COLOR_FIELDS {
match variant_table.get(field) {
None => {
errors.push(ValidationError::MissingField {
variant: variant_name.to_string(),
field: field.to_string(),
});
}
Some(value) => {
if let Some(color_str) = value.as_str() {
if validate_hex_color(color_str) {
has_valid_colors = true;
} else {
errors.push(ValidationError::InvalidColor {
variant: variant_name.to_string(),
field: field.to_string(),
value: color_str.to_string(),
});
}
} else {
errors.push(ValidationError::InvalidColor {
variant: variant_name.to_string(),
field: field.to_string(),
value: format!("{:?}", value),
});
}
}
}
}
let is_valid = has_valid_colors && errors.is_empty();
(errors, is_valid)
}
pub fn validate_theme_content(content: &str, path: &str, theme_id: &str) -> ValidationResult {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut theme_name = None;
let toml_value = match parse_toml_with_location(content) {
Ok(v) => v,
Err(e) => {
return ValidationResult {
path: path.to_string(),
theme_id: theme_id.to_string(),
theme_name: None,
errors: vec![e],
warnings: vec![],
};
}
};
match toml_value.get("name") {
Some(name_val) => {
if let Some(name_str) = name_val.as_str() {
theme_name = Some(name_str.to_string());
} else {
errors.push(ValidationError::InvalidToml {
message: "'name' must be a string".to_string(),
line: None,
col: None,
});
}
}
None => {
errors.push(ValidationError::MissingNameField);
}
}
let (dark_errors, _dark_valid) = validate_variant_colors(&toml_value, "dark");
let (light_errors, _light_valid) = validate_variant_colors(&toml_value, "light");
let has_dark = toml_value.get("dark").is_some();
let has_light = toml_value.get("light").is_some();
errors.extend(dark_errors);
errors.extend(light_errors);
if !has_dark && !has_light {
errors.push(ValidationError::NoVariants);
}
if errors.is_empty() {
if !has_dark {
warnings.push(ValidationWarning::MissingDarkVariant);
}
if !has_light {
warnings.push(ValidationWarning::MissingLightVariant);
}
}
ValidationResult {
path: path.to_string(),
theme_id: theme_id.to_string(),
theme_name,
errors,
warnings,
}
}
pub fn validate_theme_files(dir: &Path) -> Vec<ValidationResult> {
let mut results = Vec::new();
if !dir.exists() {
return results;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return results;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "toml") {
let theme_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let path_str = path.display().to_string();
match std::fs::read_to_string(&path) {
Ok(content) => {
results.push(validate_theme_content(&content, &path_str, &theme_id));
}
Err(e) => {
results.push(ValidationResult {
path: path_str,
theme_id,
theme_name: None,
errors: vec![ValidationError::InvalidToml {
message: format!("Could not read file: {}", e),
line: None,
col: None,
}],
warnings: vec![],
});
}
}
}
}
results.sort_by(|a, b| a.theme_id.cmp(&b.theme_id));
results
}
pub fn print_validation_results(results: &[ValidationResult], verbose: bool) {
let errors: Vec<_> = results.iter().filter(|r| !r.is_valid()).collect();
let warnings: Vec<_> = results
.iter()
.filter(|r| r.is_valid() && r.has_warnings())
.collect();
let valid: Vec<_> = results
.iter()
.filter(|r| r.is_valid() && !r.has_warnings())
.collect();
println!("{}", "=".repeat(80));
println!("THEME VALIDATION");
println!("{}", "=".repeat(80));
if !errors.is_empty() {
println!("\nX ERRORS ({} theme(s) with issues)\n", errors.len());
for result in &errors {
let display_name = result.theme_name.as_deref().unwrap_or(&result.theme_id);
println!("{}:", display_name);
if result.path != result.theme_id {
println!(" File: {}", result.path);
}
for error in &result.errors {
println!(" * {}", error);
}
println!();
}
}
if !warnings.is_empty() {
println!("\n! WARNINGS ({} theme(s))\n", warnings.len());
for result in &warnings {
let display_name = result.theme_name.as_deref().unwrap_or(&result.theme_id);
println!("{}:", display_name);
for warning in &result.warnings {
println!(" * {}", warning);
}
println!();
}
}
if verbose && !valid.is_empty() {
println!("\n+ VALID ({} theme(s))\n", valid.len());
for result in &valid {
let display_name = result.theme_name.as_deref().unwrap_or(&result.theme_id);
println!(" {}", display_name);
}
println!();
}
println!("{}", "=".repeat(80));
println!("VALIDATION SUMMARY");
println!("{}", "=".repeat(80));
println!(
"\nThemes checked: {} | Valid: {} | Errors: {} | Warnings: {}",
results.len(),
valid.len() + warnings.len(),
errors.len(),
warnings.len()
);
if errors.is_empty() && warnings.is_empty() {
println!("\n+ All themes passed validation!");
} else if errors.is_empty() {
println!("\n+ All themes are valid (with some warnings)");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_hex_color_valid() {
assert!(validate_hex_color("#ffffff"));
assert!(validate_hex_color("#FFFFFF"));
assert!(validate_hex_color("#000000"));
assert!(validate_hex_color("#abc"));
assert!(validate_hex_color("#ABC"));
assert!(validate_hex_color("#1a2b3c"));
assert!(validate_hex_color("ffffff"));
assert!(validate_hex_color(" #ffffff "));
}
#[test]
fn test_validate_hex_color_invalid() {
assert!(!validate_hex_color("#gggggg"));
assert!(!validate_hex_color("#12345"));
assert!(!validate_hex_color("#1234567"));
assert!(!validate_hex_color(""));
assert!(!validate_hex_color("#"));
assert!(!validate_hex_color("not-a-color"));
}
#[test]
fn test_validate_valid_theme() {
let content = r##"
name = "Test Theme"
[dark]
bg = "#1e1e2e"
dialog_bg = "#313244"
fg = "#cdd6f4"
accent = "#89b4fa"
accent_secondary = "#cba6f7"
highlight = "#f9e2af"
muted = "#6c7086"
success = "#a6e3a1"
warning = "#fab387"
danger = "#f38ba8"
border = "#45475a"
selection_bg = "#585b70"
selection_fg = "#cdd6f4"
graph_line = "#89b4fa"
"##;
let result = validate_theme_content(content, "test.toml", "test");
assert!(
result.is_valid(),
"Expected valid, got errors: {:?}",
result.errors
);
assert!(result.has_warnings());
}
#[test]
fn test_validate_missing_name() {
let content = r##"
[dark]
bg = "#1e1e2e"
"##;
let result = validate_theme_content(content, "test.toml", "test");
assert!(!result.is_valid());
assert!(result
.errors
.iter()
.any(|e| matches!(e, ValidationError::MissingNameField)));
}
#[test]
fn test_validate_invalid_toml() {
let content = r##"
name = "Test
broken syntax
"##;
let result = validate_theme_content(content, "test.toml", "test");
assert!(!result.is_valid());
assert!(result
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidToml { .. })));
}
#[test]
fn test_validate_missing_field() {
let content = r##"
name = "Test Theme"
[dark]
bg = "#1e1e2e"
fg = "#cdd6f4"
"##;
let result = validate_theme_content(content, "test.toml", "test");
assert!(!result.is_valid());
assert!(result
.errors
.iter()
.any(|e| matches!(e, ValidationError::MissingField { .. })));
}
#[test]
fn test_validate_invalid_color() {
let content = r##"
name = "Test Theme"
[dark]
bg = "not-a-color"
dialog_bg = "#313244"
fg = "#cdd6f4"
accent = "#89b4fa"
accent_secondary = "#cba6f7"
highlight = "#f9e2af"
muted = "#6c7086"
success = "#a6e3a1"
warning = "#fab387"
danger = "#f38ba8"
border = "#45475a"
selection_bg = "#585b70"
selection_fg = "#cdd6f4"
graph_line = "#89b4fa"
"##;
let result = validate_theme_content(content, "test.toml", "test");
assert!(!result.is_valid());
assert!(result
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidColor { field, .. } if field == "bg")));
}
#[test]
fn test_validate_no_variants() {
let content = r##"
name = "Test Theme"
"##;
let result = validate_theme_content(content, "test.toml", "test");
assert!(!result.is_valid());
assert!(result
.errors
.iter()
.any(|e| matches!(e, ValidationError::NoVariants)));
}
#[test]
fn test_validate_both_variants_valid() {
let content = r##"
name = "Full Theme"
[dark]
bg = "#1e1e2e"
dialog_bg = "#313244"
fg = "#cdd6f4"
accent = "#89b4fa"
accent_secondary = "#cba6f7"
highlight = "#f9e2af"
muted = "#6c7086"
success = "#a6e3a1"
warning = "#fab387"
danger = "#f38ba8"
border = "#45475a"
selection_bg = "#585b70"
selection_fg = "#cdd6f4"
graph_line = "#89b4fa"
[light]
bg = "#eff1f5"
dialog_bg = "#e6e9ef"
fg = "#4c4f69"
accent = "#1e66f5"
accent_secondary = "#8839ef"
highlight = "#df8e1d"
muted = "#6c6f85"
success = "#40a02b"
warning = "#fe640b"
danger = "#d20f39"
border = "#bcc0cc"
selection_bg = "#acb0be"
selection_fg = "#4c4f69"
graph_line = "#1e66f5"
"##;
let result = validate_theme_content(content, "test.toml", "test");
assert!(
result.is_valid(),
"Expected valid, got errors: {:?}",
result.errors
);
assert!(!result.has_warnings());
}
}