magical markdown slides

feat(validator): implement error & validation for check command

+689 -47
+13 -13
ROADMAP.md
··· 22 22 23 23 __Objective:__ Parse markdown documents into a rich `Slide` struct. 24 24 25 - | Task | Description | Key Crates | 26 - | ----------------------- | --------------------------------------------------------------- | -------------------- | 27 - | __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] | 28 - | | Detect title blocks, lists, and code fences. | | 29 - | | Represent as `Vec<Slide>`. | | 30 - | __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | 31 - | __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] | 32 - | __Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] | 33 - | __✓ Basic CLI UX__ | `lantern present file.md` runs full TUI. | `clap` | 34 - | | `lantern print` renders to stdout with width constraint. | | 25 + | Task | Description | Key Crates | 26 + | ------------------------ | --------------------------------------------------------------- | -------------------- | 27 + | __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] | 28 + | | Detect title blocks, lists, and code fences. | | 29 + | | Represent as `Vec<Slide>`. | | 30 + | __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | 31 + | __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] | 32 + | __✓ Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] | 33 + | __✓ Basic CLI UX__ | `lantern present file.md` runs full TUI. | `clap` | 34 + | | `lantern print` renders to stdout with width constraint. | | 35 35 36 36 ## Rendering & Navigation 37 37 ··· 81 81 82 82 | Task | Description | Key Crates | 83 83 | -------------------- | ------------------------------------------------------------- | ----------------------------- | 84 - | __Tables & Lists__ | Render GitHub-style tables, bullets, and task lists. | `pulldown-cmark`, `ratatui` | 84 + | __Tables & Lists__ | Render GitHub-style tables, bullets, and task lists | `pulldown-cmark`, `ratatui` | 85 + | __Horizontal Rules__ | Use box-drawing (`─`, `═`) and/or black horizontal bar (`▬`) | Unicode constants | 85 86 | __Admonitions__ | Highlighted boxes with icons | `owo-colors`, internal glyphs | 86 - | __Horizontal Rules__ | Use box-drawing (`─`, `═`) and shading (`░`, `▓`). | Unicode constants | 87 - | __Generators__ | `lantern init` scaffolds an example deck with code and notes. | `include_str!`, `fs` | 87 + | __Generators__ | `lantern init` scaffolds an example deck with code and notes | `include_str!`, `fs` | 88 88 89 89 ## RC 90 90
+192 -6
cli/src/main.rs
··· 58 58 /// Enable strict mode with additional checks 59 59 #[arg(short, long)] 60 60 strict: bool, 61 + /// Validate file as a theme instead of slides 62 + #[arg(short, long)] 63 + theme: bool, 61 64 }, 62 65 } 63 66 ··· 86 89 eprintln!("Init command not yet implemented"); 87 90 } 88 91 89 - Commands::Check { file, strict } => { 90 - tracing::info!("Checking slides: {}", file.display()); 91 - if strict { 92 - tracing::debug!("Strict mode enabled"); 92 + Commands::Check { file, strict, theme } => { 93 + if let Err(e) = run_check(&file, strict, theme) { 94 + eprintln!("Error: {}", e); 95 + std::process::exit(1); 93 96 } 94 - eprintln!("Check command not yet implemented"); 95 97 } 96 98 } 97 99 } ··· 140 142 result 141 143 } 142 144 145 + fn run_check(file: &PathBuf, strict: bool, is_theme: bool) -> io::Result<()> { 146 + use lantern_core::validator::{validate_slides, validate_theme_file}; 147 + use owo_colors::OwoColorize; 148 + 149 + if is_theme { 150 + tracing::info!("Validating theme file: {}", file.display()); 151 + let result = validate_theme_file(file); 152 + 153 + if result.is_valid() { 154 + println!("{} Theme is valid", "✓".green().bold()); 155 + } else { 156 + println!("{} Theme validation failed", "✗".red().bold()); 157 + } 158 + 159 + for error in &result.errors { 160 + println!(" {} {}", "Error:".red().bold(), error); 161 + } 162 + 163 + for warning in &result.warnings { 164 + println!(" {} {}", "Warning:".yellow().bold(), warning); 165 + } 166 + 167 + if !result.is_valid() { 168 + return Err(io::Error::new( 169 + io::ErrorKind::InvalidData, 170 + "Theme validation failed", 171 + )); 172 + } 173 + } else { 174 + tracing::info!("Validating slides: {}", file.display()); 175 + if strict { 176 + tracing::debug!("Strict mode enabled"); 177 + } 178 + 179 + let result = validate_slides(file, strict); 180 + 181 + if result.is_valid() && !result.has_issues() { 182 + println!("{} Slides are valid", "✓".green().bold()); 183 + } else if result.is_valid() { 184 + println!("{} Slides are valid (with warnings)", "✓".yellow().bold()); 185 + } else { 186 + println!("{} Slide validation failed", "✗".red().bold()); 187 + } 188 + 189 + for error in &result.errors { 190 + println!(" {} {}", "Error:".red().bold(), error); 191 + } 192 + 193 + for warning in &result.warnings { 194 + println!(" {} {}", "Warning:".yellow().bold(), warning); 195 + } 196 + 197 + if !result.is_valid() { 198 + return Err(io::Error::new( 199 + io::ErrorKind::InvalidData, 200 + "Slide validation failed", 201 + )); 202 + } 203 + } 204 + 205 + Ok(()) 206 + } 207 + 143 208 fn run_print(file: &PathBuf, width: usize, theme_arg: Option<String>) -> io::Result<()> { 144 209 tracing::info!("Printing slides from: {} (width: {})", file.display(), width); 145 210 ··· 220 285 fn cli_check_command() { 221 286 let cli = ArgParser::parse_from(["slides", "check", "test.md", "--strict"]); 222 287 match cli.command { 223 - Commands::Check { file, strict } => { 288 + Commands::Check { file, strict, theme } => { 224 289 assert_eq!(file, PathBuf::from("test.md")); 225 290 assert!(strict); 291 + assert!(!theme); 292 + } 293 + _ => panic!("Expected Check command"), 294 + } 295 + } 296 + 297 + #[test] 298 + fn cli_check_theme_command() { 299 + let cli = ArgParser::parse_from(["slides", "check", "theme.yml", "--theme"]); 300 + match cli.command { 301 + Commands::Check { file, strict, theme } => { 302 + assert_eq!(file, PathBuf::from("theme.yml")); 303 + assert!(!strict); 304 + assert!(theme); 226 305 } 227 306 _ => panic!("Expected Check command"), 228 307 } ··· 286 365 287 366 let result = run_print(&test_file, 80, Some("monokai".to_string())); 288 367 assert!(result.is_ok()); 368 + 369 + std::fs::remove_file(&test_file).ok(); 370 + } 371 + 372 + #[test] 373 + fn run_check_valid_slides() { 374 + let temp_dir = std::env::temp_dir(); 375 + let test_file = temp_dir.join("test_check_valid.md"); 376 + let content = "# Test Slide\n\nThis is a test paragraph."; 377 + std::fs::write(&test_file, content).expect("Failed to write test file"); 378 + 379 + let result = run_check(&test_file, false, false); 380 + assert!(result.is_ok()); 381 + 382 + std::fs::remove_file(&test_file).ok(); 383 + } 384 + 385 + #[test] 386 + fn run_check_invalid_slides() { 387 + let temp_dir = std::env::temp_dir(); 388 + let test_file = temp_dir.join("test_check_invalid.md"); 389 + let content = ""; 390 + std::fs::write(&test_file, content).expect("Failed to write test file"); 391 + 392 + let result = run_check(&test_file, false, false); 393 + assert!(result.is_err()); 394 + 395 + std::fs::remove_file(&test_file).ok(); 396 + } 397 + 398 + #[test] 399 + fn run_check_nonexistent_file() { 400 + let test_file = PathBuf::from("/nonexistent/test_check.md"); 401 + let result = run_check(&test_file, false, false); 402 + assert!(result.is_err()); 403 + } 404 + 405 + #[test] 406 + fn run_check_strict_mode() { 407 + let temp_dir = std::env::temp_dir(); 408 + let test_file = temp_dir.join("test_check_strict.md"); 409 + let content = "---\ntheme: nonexistent-theme\n---\n# Slide 1\n\nContent"; 410 + std::fs::write(&test_file, content).expect("Failed to write test file"); 411 + 412 + let result = run_check(&test_file, true, false); 413 + assert!(result.is_ok()); 414 + 415 + std::fs::remove_file(&test_file).ok(); 416 + } 417 + 418 + #[test] 419 + fn run_check_valid_theme() { 420 + let temp_dir = std::env::temp_dir(); 421 + let test_file = temp_dir.join("test_check_valid_theme.yml"); 422 + let content = r###" 423 + system: "base16" 424 + name: "Test Theme" 425 + author: "Test Author" 426 + variant: "dark" 427 + palette: 428 + base00: "#000000" 429 + base01: "#111111" 430 + base02: "#222222" 431 + base03: "#333333" 432 + base04: "#444444" 433 + base05: "#555555" 434 + base06: "#666666" 435 + base07: "#777777" 436 + base08: "#888888" 437 + base09: "#999999" 438 + base0A: "#aaaaaa" 439 + base0B: "#bbbbbb" 440 + base0C: "#cccccc" 441 + base0D: "#dddddd" 442 + base0E: "#eeeeee" 443 + base0F: "#ffffff" 444 + "###; 445 + std::fs::write(&test_file, content).expect("Failed to write test file"); 446 + 447 + let result = run_check(&test_file, false, true); 448 + assert!(result.is_ok()); 449 + 450 + std::fs::remove_file(&test_file).ok(); 451 + } 452 + 453 + #[test] 454 + fn run_check_invalid_theme() { 455 + let temp_dir = std::env::temp_dir(); 456 + let test_file = temp_dir.join("test_check_invalid_theme.yml"); 457 + let content = "invalid: yaml: content: [unclosed"; 458 + std::fs::write(&test_file, content).expect("Failed to write test file"); 459 + 460 + let result = run_check(&test_file, false, true); 461 + assert!(result.is_err()); 462 + 463 + std::fs::remove_file(&test_file).ok(); 464 + } 465 + 466 + #[test] 467 + fn run_check_invalid_frontmatter() { 468 + let temp_dir = std::env::temp_dir(); 469 + let test_file = temp_dir.join("test_check_bad_frontmatter.md"); 470 + let content = "---\ninvalid yaml: [unclosed\n---\n# Slide"; 471 + std::fs::write(&test_file, content).expect("Failed to write test file"); 472 + 473 + let result = run_check(&test_file, false, false); 474 + assert!(result.is_err()); 289 475 290 476 std::fs::remove_file(&test_file).ok(); 291 477 }
+7
core/src/error.rs
··· 21 21 22 22 #[error("JSON parsing failed: {0}")] 23 23 JsonError(#[from] serde_json::Error), 24 + 25 + #[error("Theme validation error: {0}")] 26 + ThemeError(String), 24 27 } 25 28 26 29 pub type Result<T> = std::result::Result<T, SlideError>; ··· 39 42 40 43 pub fn front_matter(message: impl Into<String>) -> Self { 41 44 Self::FrontMatterError(message.into()) 45 + } 46 + 47 + pub fn theme_error(message: impl Into<String>) -> Self { 48 + Self::ThemeError(message.into()) 42 49 } 43 50 } 44 51
+1
core/src/lib.rs
··· 6 6 pub mod slide; 7 7 pub mod term; 8 8 pub mod theme; 9 + pub mod validator;
+23 -28
core/src/theme.rs
··· 23 23 /// 24 24 /// Defines a standard 16-color palette that can be mapped to semantic theme roles. 25 25 #[derive(Debug, Clone, Deserialize)] 26 - struct Base16Scheme { 27 - #[allow(dead_code)] 28 - system: String, 29 - #[allow(dead_code)] 30 - name: String, 31 - #[allow(dead_code)] 32 - author: String, 33 - #[allow(dead_code)] 34 - variant: String, 35 - palette: Base16Palette, 26 + pub struct Base16Scheme { 27 + pub system: String, 28 + pub name: String, 29 + pub author: String, 30 + pub variant: String, 31 + pub palette: Base16Palette, 36 32 } 37 33 38 34 /// Base16 color palette with 16 standardized color slots. ··· 42 38 /// - base04-07: Foreground shades (darker to lightest) 43 39 /// - base08-0F: Accent colors (red, orange, yellow, green, cyan, blue, magenta, brown) 44 40 #[derive(Debug, Clone, Deserialize)] 45 - #[allow(dead_code)] 46 - struct Base16Palette { 47 - base00: String, 48 - base01: String, 49 - base02: String, 50 - base03: String, 51 - base04: String, 52 - base05: String, 53 - base06: String, 54 - base07: String, 55 - base08: String, 56 - base09: String, 41 + pub struct Base16Palette { 42 + pub base00: String, 43 + pub base01: String, 44 + pub base02: String, 45 + pub base03: String, 46 + pub base04: String, 47 + pub base05: String, 48 + pub base06: String, 49 + pub base07: String, 50 + pub base08: String, 51 + pub base09: String, 57 52 #[serde(rename = "base0A")] 58 - base0a: String, 53 + pub base0a: String, 59 54 #[serde(rename = "base0B")] 60 - base0b: String, 55 + pub base0b: String, 61 56 #[serde(rename = "base0C")] 62 - base0c: String, 57 + pub base0c: String, 63 58 #[serde(rename = "base0D")] 64 - base0d: String, 59 + pub base0d: String, 65 60 #[serde(rename = "base0E")] 66 - base0e: String, 61 + pub base0e: String, 67 62 #[serde(rename = "base0F")] 68 - base0f: String, 63 + pub base0f: String, 69 64 } 70 65 71 66 static CATPPUCCIN_LATTE: &str = include_str!("themes/catppuccin-latte.yml");
+453
core/src/validator.rs
··· 1 + use crate::error::{Result, SlideError}; 2 + use crate::metadata::Meta; 3 + use crate::parser::parse_slides_with_meta; 4 + use crate::theme::{Base16Scheme, ThemeColors, ThemeRegistry}; 5 + use std::path::Path; 6 + 7 + /// Validation result containing errors and warnings 8 + #[derive(Debug, Clone, Default)] 9 + pub struct ValidationResult { 10 + pub errors: Vec<String>, 11 + pub warnings: Vec<String>, 12 + } 13 + 14 + impl ValidationResult { 15 + pub fn new() -> Self { 16 + Self::default() 17 + } 18 + 19 + pub fn add_error(&mut self, error: String) { 20 + self.errors.push(error); 21 + } 22 + 23 + pub fn add_warning(&mut self, warning: String) { 24 + self.warnings.push(warning); 25 + } 26 + 27 + pub fn is_valid(&self) -> bool { 28 + self.errors.is_empty() 29 + } 30 + 31 + pub fn has_issues(&self) -> bool { 32 + !self.errors.is_empty() || !self.warnings.is_empty() 33 + } 34 + } 35 + 36 + /// Validate a slide deck markdown file 37 + /// 38 + /// Checks for: 39 + /// - File readability 40 + /// - Valid frontmatter (YAML/TOML) 41 + /// - Slide parsing 42 + /// - Empty slide deck 43 + /// - Theme references 44 + pub fn validate_slides(file_path: &Path, strict: bool) -> ValidationResult { 45 + let mut result = ValidationResult::new(); 46 + 47 + let markdown = match std::fs::read_to_string(file_path) { 48 + Ok(content) => content, 49 + Err(e) => { 50 + result.add_error(format!("Failed to read file '{}': {}", file_path.display(), e)); 51 + return result; 52 + } 53 + }; 54 + 55 + let (meta, slides) = match parse_slides_with_meta(&markdown) { 56 + Ok((m, s)) => (m, s), 57 + Err(e) => { 58 + result.add_error(format!("Parse error: {}", e)); 59 + return result; 60 + } 61 + }; 62 + 63 + if slides.is_empty() { 64 + result.add_error("No slides found in file".to_string()); 65 + return result; 66 + } 67 + 68 + if strict { 69 + validate_metadata(&meta, &mut result); 70 + validate_slide_content(&slides, &mut result); 71 + } 72 + 73 + result 74 + } 75 + 76 + /// Validate metadata fields 77 + fn validate_metadata(meta: &Meta, result: &mut ValidationResult) { 78 + if meta.theme != "default" && !ThemeRegistry::available_themes().contains(&meta.theme.as_str()) { 79 + result.add_warning(format!( 80 + "Theme '{}' is not a built-in theme. Available themes: {}", 81 + meta.theme, 82 + ThemeRegistry::available_themes().join(", ") 83 + )); 84 + } 85 + 86 + if meta.author == "Unknown" { 87 + result.add_warning("No author specified in frontmatter".to_string()); 88 + } 89 + } 90 + 91 + /// Validate slide content 92 + fn validate_slide_content(slides: &[crate::slide::Slide], result: &mut ValidationResult) { 93 + for (idx, slide) in slides.iter().enumerate() { 94 + if slide.blocks.is_empty() { 95 + result.add_warning(format!("Slide {} is empty", idx + 1)); 96 + } 97 + } 98 + } 99 + 100 + /// Validate a theme file 101 + /// 102 + /// Checks for: 103 + /// - File readability 104 + /// - Valid YAML format 105 + /// - Base16 schema compliance 106 + /// - Color format validity 107 + pub fn validate_theme_file(file_path: &Path) -> ValidationResult { 108 + let mut result = ValidationResult::new(); 109 + 110 + let yaml_content = match std::fs::read_to_string(file_path) { 111 + Ok(content) => content, 112 + Err(e) => { 113 + result.add_error(format!("Failed to read theme file '{}': {}", file_path.display(), e)); 114 + return result; 115 + } 116 + }; 117 + 118 + let scheme: Base16Scheme = match serde_yml::from_str(&yaml_content) { 119 + Ok(s) => s, 120 + Err(e) => { 121 + result.add_error(format!("Failed to parse YAML: {}", e)); 122 + return result; 123 + } 124 + }; 125 + 126 + validate_base16_scheme(&scheme, &mut result); 127 + 128 + if result.is_valid() { 129 + let colors = vec![ 130 + ("base00", &scheme.palette.base00), 131 + ("base01", &scheme.palette.base01), 132 + ("base02", &scheme.palette.base02), 133 + ("base03", &scheme.palette.base03), 134 + ("base04", &scheme.palette.base04), 135 + ("base05", &scheme.palette.base05), 136 + ("base06", &scheme.palette.base06), 137 + ("base07", &scheme.palette.base07), 138 + ("base08", &scheme.palette.base08), 139 + ("base09", &scheme.palette.base09), 140 + ("base0A", &scheme.palette.base0a), 141 + ("base0B", &scheme.palette.base0b), 142 + ("base0C", &scheme.palette.base0c), 143 + ("base0D", &scheme.palette.base0d), 144 + ("base0E", &scheme.palette.base0e), 145 + ("base0F", &scheme.palette.base0f), 146 + ]; 147 + 148 + for (name, color) in colors { 149 + validate_hex_color(name, color, &mut result); 150 + } 151 + } 152 + 153 + result 154 + } 155 + 156 + /// Validate base16 scheme structure 157 + fn validate_base16_scheme(scheme: &Base16Scheme, result: &mut ValidationResult) { 158 + if scheme.system != "base16" { 159 + result.add_error(format!("Invalid system '{}', expected 'base16'", scheme.system)); 160 + } 161 + 162 + if scheme.name.is_empty() { 163 + result.add_error("Theme name is empty".to_string()); 164 + } 165 + 166 + if scheme.author.is_empty() { 167 + result.add_warning("Theme author is empty".to_string()); 168 + } 169 + 170 + let valid_variants = ["dark", "light"]; 171 + if !valid_variants.contains(&scheme.variant.as_str()) { 172 + result.add_warning(format!("Variant '{}' should be 'dark' or 'light'", scheme.variant)); 173 + } 174 + } 175 + 176 + /// Validate hex color format 177 + fn validate_hex_color(name: &str, hex: &str, result: &mut ValidationResult) { 178 + let hex = hex.trim_start_matches('#'); 179 + 180 + if hex.len() != 6 { 181 + result.add_error(format!( 182 + "Color {} has invalid length {} (expected 6 hex digits)", 183 + name, 184 + hex.len() 185 + )); 186 + return; 187 + } 188 + 189 + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { 190 + result.add_error(format!("Color {} contains invalid hex characters", name)); 191 + } 192 + } 193 + 194 + /// Validate theme by name 195 + /// 196 + /// Checks if the theme exists in the built-in registry 197 + pub fn validate_theme_name(name: &str) -> Result<ThemeColors> { 198 + let available = ThemeRegistry::available_themes(); 199 + 200 + if available.contains(&name) || name == "default" { 201 + Ok(ThemeRegistry::get(name)) 202 + } else { 203 + Err(SlideError::theme_error(format!( 204 + "Theme '{}' not found. Available themes: {}", 205 + name, 206 + available.join(", ") 207 + ))) 208 + } 209 + } 210 + 211 + #[cfg(test)] 212 + mod tests { 213 + use super::*; 214 + 215 + #[test] 216 + fn validate_slides_nonexistent_file() { 217 + let path = Path::new("/nonexistent/file.md"); 218 + let result = validate_slides(path, false); 219 + assert!(!result.is_valid()); 220 + assert!(!result.errors.is_empty()); 221 + assert!(result.errors[0].contains("Failed to read file")); 222 + } 223 + 224 + #[test] 225 + fn validate_slides_empty_content() { 226 + let temp_dir = std::env::temp_dir(); 227 + let test_file = temp_dir.join("test_empty_validation.md"); 228 + std::fs::write(&test_file, "").expect("Failed to write test file"); 229 + 230 + let result = validate_slides(&test_file, false); 231 + assert!(!result.is_valid()); 232 + assert!(result.errors.iter().any(|e| e.contains("No slides found"))); 233 + 234 + std::fs::remove_file(&test_file).ok(); 235 + } 236 + 237 + #[test] 238 + fn validate_slides_valid_content() { 239 + let temp_dir = std::env::temp_dir(); 240 + let test_file = temp_dir.join("test_valid_validation.md"); 241 + let content = "# Test Slide\n\nThis is a test paragraph."; 242 + std::fs::write(&test_file, content).expect("Failed to write test file"); 243 + 244 + let result = validate_slides(&test_file, false); 245 + assert!(result.is_valid()); 246 + 247 + std::fs::remove_file(&test_file).ok(); 248 + } 249 + 250 + #[test] 251 + fn validate_slides_invalid_frontmatter() { 252 + let temp_dir = std::env::temp_dir(); 253 + let test_file = temp_dir.join("test_invalid_frontmatter.md"); 254 + let content = "---\ninvalid yaml: [unclosed\n---\n# Slide"; 255 + std::fs::write(&test_file, content).expect("Failed to write test file"); 256 + 257 + let result = validate_slides(&test_file, false); 258 + assert!(!result.is_valid()); 259 + assert!(result.errors.iter().any(|e| e.contains("Parse error"))); 260 + 261 + std::fs::remove_file(&test_file).ok(); 262 + } 263 + 264 + #[test] 265 + fn validate_slides_with_warnings_strict() { 266 + let temp_dir = std::env::temp_dir(); 267 + let test_file = temp_dir.join("test_warnings_validation.md"); 268 + let content = "---\ntheme: nonexistent-theme\nauthor: Unknown\n---\n# Slide 1\n\nContent"; 269 + std::fs::write(&test_file, content).expect("Failed to write test file"); 270 + 271 + let result = validate_slides(&test_file, true); 272 + assert!(result.is_valid()); 273 + assert!(!result.warnings.is_empty()); 274 + 275 + std::fs::remove_file(&test_file).ok(); 276 + } 277 + 278 + #[test] 279 + fn validate_theme_file_invalid_yaml() { 280 + let temp_dir = std::env::temp_dir(); 281 + let test_file = temp_dir.join("test_invalid_theme.yml"); 282 + let content = "invalid: yaml: content: [unclosed"; 283 + std::fs::write(&test_file, content).expect("Failed to write test file"); 284 + 285 + let result = validate_theme_file(&test_file); 286 + assert!(!result.is_valid()); 287 + assert!(result.errors.iter().any(|e| e.contains("Failed to parse YAML"))); 288 + 289 + std::fs::remove_file(&test_file).ok(); 290 + } 291 + 292 + #[test] 293 + fn validate_theme_file_invalid_system() { 294 + let temp_dir = std::env::temp_dir(); 295 + let test_file = temp_dir.join("test_invalid_system.yml"); 296 + let content = r###" 297 + system: "base32" 298 + name: "Test" 299 + author: "Test Author" 300 + variant: "dark" 301 + palette: 302 + base00: "#000000" 303 + base01: "#111111" 304 + base02: "#222222" 305 + base03: "#333333" 306 + base04: "#444444" 307 + base05: "#555555" 308 + base06: "#666666" 309 + base07: "#777777" 310 + base08: "#888888" 311 + base09: "#999999" 312 + base0A: "#aaaaaa" 313 + base0B: "#bbbbbb" 314 + base0C: "#cccccc" 315 + base0D: "#dddddd" 316 + base0E: "#eeeeee" 317 + base0F: "#ffffff" 318 + "###; 319 + std::fs::write(&test_file, content).expect("Failed to write test file"); 320 + 321 + let result = validate_theme_file(&test_file); 322 + assert!(!result.is_valid()); 323 + assert!( 324 + result 325 + .errors 326 + .iter() 327 + .any(|e| e.contains("Invalid system") && e.contains("base32")) 328 + ); 329 + 330 + std::fs::remove_file(&test_file).ok(); 331 + } 332 + 333 + #[test] 334 + fn validate_theme_file_invalid_color() { 335 + let temp_dir = std::env::temp_dir(); 336 + let test_file = temp_dir.join("test_invalid_color.yml"); 337 + let content = r###" 338 + system: "base16" 339 + name: "Test" 340 + author: "Test Author" 341 + variant: "dark" 342 + palette: 343 + base00: "#000000" 344 + base01: "#111111" 345 + base02: "#222222" 346 + base03: "#333333" 347 + base04: "#GGGGGG" 348 + base05: "#555555" 349 + base06: "#666666" 350 + base07: "#777777" 351 + base08: "#888888" 352 + base09: "#999999" 353 + base0A: "#aaaaaa" 354 + base0B: "#bbbbbb" 355 + base0C: "#cccccc" 356 + base0D: "#dddddd" 357 + base0E: "#eeeeee" 358 + base0F: "#ffffff" 359 + "###; 360 + std::fs::write(&test_file, content).expect("Failed to write test file"); 361 + 362 + let result = validate_theme_file(&test_file); 363 + assert!(!result.is_valid()); 364 + assert!( 365 + result 366 + .errors 367 + .iter() 368 + .any(|e| e.contains("base04") && e.contains("invalid hex")) 369 + ); 370 + 371 + std::fs::remove_file(&test_file).ok(); 372 + } 373 + 374 + #[test] 375 + fn validate_theme_file_valid() { 376 + let temp_dir = std::env::temp_dir(); 377 + let test_file = temp_dir.join("test_valid_theme.yml"); 378 + let content = r###" 379 + system: "base16" 380 + name: "Test Theme" 381 + author: "Test Author" 382 + variant: "dark" 383 + palette: 384 + base00: "#000000" 385 + base01: "#111111" 386 + base02: "#222222" 387 + base03: "#333333" 388 + base04: "#444444" 389 + base05: "#555555" 390 + base06: "#666666" 391 + base07: "#777777" 392 + base08: "#888888" 393 + base09: "#999999" 394 + base0A: "#aaaaaa" 395 + base0B: "#bbbbbb" 396 + base0C: "#cccccc" 397 + base0D: "#dddddd" 398 + base0E: "#eeeeee" 399 + base0F: "#ffffff" 400 + "###; 401 + std::fs::write(&test_file, content).expect("Failed to write test file"); 402 + 403 + let result = validate_theme_file(&test_file); 404 + assert!(result.is_valid()); 405 + 406 + std::fs::remove_file(&test_file).ok(); 407 + } 408 + 409 + #[test] 410 + fn validate_theme_name_builtin() { 411 + let result = validate_theme_name("nord"); 412 + assert!(result.is_ok()); 413 + } 414 + 415 + #[test] 416 + fn validate_theme_name_default() { 417 + let result = validate_theme_name("default"); 418 + assert!(result.is_ok()); 419 + } 420 + 421 + #[test] 422 + fn validate_theme_name_invalid() { 423 + let result = validate_theme_name("nonexistent-theme"); 424 + assert!(result.is_err()); 425 + assert!( 426 + result 427 + .unwrap_err() 428 + .to_string() 429 + .contains("Theme 'nonexistent-theme' not found") 430 + ); 431 + } 432 + 433 + #[test] 434 + fn validation_result_is_valid() { 435 + let mut result = ValidationResult::new(); 436 + assert!(result.is_valid()); 437 + 438 + result.add_warning("test warning".to_string()); 439 + assert!(result.is_valid()); 440 + 441 + result.add_error("test error".to_string()); 442 + assert!(!result.is_valid()); 443 + } 444 + 445 + #[test] 446 + fn validation_result_has_issues() { 447 + let mut result = ValidationResult::new(); 448 + assert!(!result.has_issues()); 449 + 450 + result.add_warning("test warning".to_string()); 451 + assert!(result.has_issues()); 452 + } 453 + }