magical markdown slides
at main 13 kB view raw
1use crate::error::{Result, SlideError}; 2use crate::metadata::Meta; 3use crate::parser::parse_slides_with_meta; 4use crate::theme::{Base16Scheme, ThemeColors, ThemeRegistry}; 5 6use std::path::Path; 7 8/// Validation result containing errors and warnings 9#[derive(Debug, Clone, Default)] 10pub struct ValidationResult { 11 pub errors: Vec<String>, 12 pub warnings: Vec<String>, 13} 14 15impl ValidationResult { 16 pub fn new() -> Self { 17 Self::default() 18 } 19 20 pub fn add_error(&mut self, error: String) { 21 self.errors.push(error); 22 } 23 24 pub fn add_warning(&mut self, warning: String) { 25 self.warnings.push(warning); 26 } 27 28 pub fn is_valid(&self) -> bool { 29 self.errors.is_empty() 30 } 31 32 pub fn has_issues(&self) -> bool { 33 !self.errors.is_empty() || !self.warnings.is_empty() 34 } 35} 36 37/// Validate a slide deck markdown file 38/// 39/// Checks for: 40/// - File readability 41/// - Valid frontmatter (YAML/TOML) 42/// - Slide parsing 43/// - Empty slide deck 44/// - Theme references 45pub fn validate_slides(file_path: &Path, strict: bool) -> ValidationResult { 46 let mut result = ValidationResult::new(); 47 48 let markdown = match std::fs::read_to_string(file_path) { 49 Ok(content) => content, 50 Err(e) => { 51 result.add_error(format!("Failed to read file '{}': {}", file_path.display(), e)); 52 return result; 53 } 54 }; 55 56 let (meta, slides) = match parse_slides_with_meta(&markdown) { 57 Ok((m, s)) => (m, s), 58 Err(e) => { 59 result.add_error(format!("Parse error: {e}")); 60 return result; 61 } 62 }; 63 64 if slides.is_empty() { 65 result.add_error("No slides found in file".to_string()); 66 return result; 67 } 68 69 if strict { 70 validate_metadata(&meta, &mut result); 71 validate_slide_content(&slides, &mut result); 72 } 73 74 result 75} 76 77/// Validate metadata fields 78fn validate_metadata(meta: &Meta, result: &mut ValidationResult) { 79 if meta.theme != "default" && !ThemeRegistry::available_themes().contains(&meta.theme.as_str()) { 80 result.add_warning(format!( 81 "Theme '{}' is not a built-in theme. Available themes: {}", 82 meta.theme, 83 ThemeRegistry::available_themes().join(", ") 84 )); 85 } 86 87 if meta.author == "Unknown" { 88 result.add_warning("No author specified in frontmatter".to_string()); 89 } 90} 91 92/// Validate slide content 93fn validate_slide_content(slides: &[crate::slide::Slide], result: &mut ValidationResult) { 94 for (idx, slide) in slides.iter().enumerate() { 95 if slide.blocks.is_empty() { 96 result.add_warning(format!("Slide {} is empty", idx + 1)); 97 } 98 } 99} 100 101/// Validate a theme file 102/// 103/// Checks for: 104/// - File readability 105/// - Valid YAML format 106/// - Base16 schema compliance 107/// - Color format validity 108pub fn validate_theme_file(file_path: &Path) -> ValidationResult { 109 let mut result = ValidationResult::new(); 110 111 let yaml_content = match std::fs::read_to_string(file_path) { 112 Ok(content) => content, 113 Err(e) => { 114 result.add_error(format!("Failed to read theme file '{}': {}", file_path.display(), e)); 115 return result; 116 } 117 }; 118 119 let scheme: Base16Scheme = match serde_yml::from_str(&yaml_content) { 120 Ok(s) => s, 121 Err(e) => { 122 result.add_error(format!("Failed to parse YAML: {e}")); 123 return result; 124 } 125 }; 126 127 validate_base16_scheme(&scheme, &mut result); 128 129 if result.is_valid() { 130 let colors = vec![ 131 ("base00", &scheme.palette.base00), 132 ("base01", &scheme.palette.base01), 133 ("base02", &scheme.palette.base02), 134 ("base03", &scheme.palette.base03), 135 ("base04", &scheme.palette.base04), 136 ("base05", &scheme.palette.base05), 137 ("base06", &scheme.palette.base06), 138 ("base07", &scheme.palette.base07), 139 ("base08", &scheme.palette.base08), 140 ("base09", &scheme.palette.base09), 141 ("base0A", &scheme.palette.base0a), 142 ("base0B", &scheme.palette.base0b), 143 ("base0C", &scheme.palette.base0c), 144 ("base0D", &scheme.palette.base0d), 145 ("base0E", &scheme.palette.base0e), 146 ("base0F", &scheme.palette.base0f), 147 ]; 148 149 for (name, color) in colors { 150 validate_hex_color(name, color, &mut result); 151 } 152 } 153 154 result 155} 156 157/// Validate base16 scheme structure 158fn validate_base16_scheme(scheme: &Base16Scheme, result: &mut ValidationResult) { 159 if scheme.system != "base16" { 160 result.add_error(format!("Invalid system '{}', expected 'base16'", scheme.system)); 161 } 162 163 if scheme.name.is_empty() { 164 result.add_error("Theme name is empty".to_string()); 165 } 166 167 if scheme.author.is_empty() { 168 result.add_warning("Theme author is empty".to_string()); 169 } 170 171 let valid_variants = ["dark", "light"]; 172 if !valid_variants.contains(&scheme.variant.as_str()) { 173 result.add_warning(format!("Variant '{}' should be 'dark' or 'light'", scheme.variant)); 174 } 175} 176 177/// Validate hex color format 178fn validate_hex_color(name: &str, hex: &str, result: &mut ValidationResult) { 179 let hex = hex.trim_start_matches('#'); 180 181 if hex.len() != 6 { 182 result.add_error(format!( 183 "Color {} has invalid length {} (expected 6 hex digits)", 184 name, 185 hex.len() 186 )); 187 return; 188 } 189 190 if !hex.chars().all(|c| c.is_ascii_hexdigit()) { 191 result.add_error(format!("Color {name} contains invalid hex characters")); 192 } 193} 194 195/// Validate theme by name 196/// 197/// Checks if the theme exists in the built-in registry 198pub fn validate_theme_name(name: &str) -> Result<ThemeColors> { 199 let available = ThemeRegistry::available_themes(); 200 201 if available.contains(&name) || name == "default" { 202 Ok(ThemeRegistry::get(name)) 203 } else { 204 Err(SlideError::theme_error(format!( 205 "Theme '{}' not found. Available themes: {}", 206 name, 207 available.join(", ") 208 ))) 209 } 210} 211 212#[cfg(test)] 213mod tests { 214 use super::*; 215 216 #[test] 217 fn validate_slides_nonexistent_file() { 218 let path = Path::new("/nonexistent/file.md"); 219 let result = validate_slides(path, false); 220 assert!(!result.is_valid()); 221 assert!(!result.errors.is_empty()); 222 assert!(result.errors[0].contains("Failed to read file")); 223 } 224 225 #[test] 226 fn validate_slides_empty_content() { 227 let temp_dir = std::env::temp_dir(); 228 let test_file = temp_dir.join("test_empty_validation.md"); 229 std::fs::write(&test_file, "").expect("Failed to write test file"); 230 231 let result = validate_slides(&test_file, false); 232 assert!(!result.is_valid()); 233 assert!(result.errors.iter().any(|e| e.contains("No slides found"))); 234 235 std::fs::remove_file(&test_file).ok(); 236 } 237 238 #[test] 239 fn validate_slides_valid_content() { 240 let temp_dir = std::env::temp_dir(); 241 let test_file = temp_dir.join("test_valid_validation.md"); 242 let content = "# Test Slide\n\nThis is a test paragraph."; 243 std::fs::write(&test_file, content).expect("Failed to write test file"); 244 245 let result = validate_slides(&test_file, false); 246 assert!(result.is_valid()); 247 248 std::fs::remove_file(&test_file).ok(); 249 } 250 251 #[test] 252 fn validate_slides_invalid_frontmatter() { 253 let temp_dir = std::env::temp_dir(); 254 let test_file = temp_dir.join("test_invalid_frontmatter.md"); 255 let content = "---\ninvalid yaml: [unclosed\n---\n# Slide"; 256 std::fs::write(&test_file, content).expect("Failed to write test file"); 257 258 let result = validate_slides(&test_file, false); 259 assert!(!result.is_valid()); 260 assert!(result.errors.iter().any(|e| e.contains("Parse error"))); 261 262 std::fs::remove_file(&test_file).ok(); 263 } 264 265 #[test] 266 fn validate_slides_with_warnings_strict() { 267 let temp_dir = std::env::temp_dir(); 268 let test_file = temp_dir.join("test_warnings_validation.md"); 269 let content = "---\ntheme: nonexistent-theme\nauthor: Unknown\n---\n# Slide 1\n\nContent"; 270 std::fs::write(&test_file, content).expect("Failed to write test file"); 271 272 let result = validate_slides(&test_file, true); 273 assert!(result.is_valid()); 274 assert!(!result.warnings.is_empty()); 275 276 std::fs::remove_file(&test_file).ok(); 277 } 278 279 #[test] 280 fn validate_theme_file_invalid_yaml() { 281 let temp_dir = std::env::temp_dir(); 282 let test_file = temp_dir.join("test_invalid_theme.yml"); 283 let content = "invalid: yaml: content: [unclosed"; 284 std::fs::write(&test_file, content).expect("Failed to write test file"); 285 286 let result = validate_theme_file(&test_file); 287 assert!(!result.is_valid()); 288 assert!(result.errors.iter().any(|e| e.contains("Failed to parse YAML"))); 289 290 std::fs::remove_file(&test_file).ok(); 291 } 292 293 #[test] 294 fn validate_theme_file_invalid_system() { 295 let temp_dir = std::env::temp_dir(); 296 let test_file = temp_dir.join("test_invalid_system.yml"); 297 let content = r###" 298system: "base32" 299name: "Test" 300author: "Test Author" 301variant: "dark" 302palette: 303 base00: "#000000" 304 base01: "#111111" 305 base02: "#222222" 306 base03: "#333333" 307 base04: "#444444" 308 base05: "#555555" 309 base06: "#666666" 310 base07: "#777777" 311 base08: "#888888" 312 base09: "#999999" 313 base0A: "#aaaaaa" 314 base0B: "#bbbbbb" 315 base0C: "#cccccc" 316 base0D: "#dddddd" 317 base0E: "#eeeeee" 318 base0F: "#ffffff" 319"###; 320 std::fs::write(&test_file, content).expect("Failed to write test file"); 321 322 let result = validate_theme_file(&test_file); 323 assert!(!result.is_valid()); 324 assert!( 325 result 326 .errors 327 .iter() 328 .any(|e| e.contains("Invalid system") && e.contains("base32")) 329 ); 330 331 std::fs::remove_file(&test_file).ok(); 332 } 333 334 #[test] 335 fn validate_theme_file_invalid_color() { 336 let temp_dir = std::env::temp_dir(); 337 let test_file = temp_dir.join("test_invalid_color.yml"); 338 let content = r###" 339system: "base16" 340name: "Test" 341author: "Test Author" 342variant: "dark" 343palette: 344 base00: "#000000" 345 base01: "#111111" 346 base02: "#222222" 347 base03: "#333333" 348 base04: "#GGGGGG" 349 base05: "#555555" 350 base06: "#666666" 351 base07: "#777777" 352 base08: "#888888" 353 base09: "#999999" 354 base0A: "#aaaaaa" 355 base0B: "#bbbbbb" 356 base0C: "#cccccc" 357 base0D: "#dddddd" 358 base0E: "#eeeeee" 359 base0F: "#ffffff" 360"###; 361 std::fs::write(&test_file, content).expect("Failed to write test file"); 362 363 let result = validate_theme_file(&test_file); 364 assert!(!result.is_valid()); 365 assert!( 366 result 367 .errors 368 .iter() 369 .any(|e| e.contains("base04") && e.contains("invalid hex")) 370 ); 371 372 std::fs::remove_file(&test_file).ok(); 373 } 374 375 #[test] 376 fn validate_theme_file_valid() { 377 let temp_dir = std::env::temp_dir(); 378 let test_file = temp_dir.join("test_valid_theme.yml"); 379 let content = r###" 380system: "base16" 381name: "Test Theme" 382author: "Test Author" 383variant: "dark" 384palette: 385 base00: "#000000" 386 base01: "#111111" 387 base02: "#222222" 388 base03: "#333333" 389 base04: "#444444" 390 base05: "#555555" 391 base06: "#666666" 392 base07: "#777777" 393 base08: "#888888" 394 base09: "#999999" 395 base0A: "#aaaaaa" 396 base0B: "#bbbbbb" 397 base0C: "#cccccc" 398 base0D: "#dddddd" 399 base0E: "#eeeeee" 400 base0F: "#ffffff" 401"###; 402 std::fs::write(&test_file, content).expect("Failed to write test file"); 403 404 let result = validate_theme_file(&test_file); 405 assert!(result.is_valid()); 406 407 std::fs::remove_file(&test_file).ok(); 408 } 409 410 #[test] 411 fn validate_theme_name_builtin() { 412 let result = validate_theme_name("nord"); 413 assert!(result.is_ok()); 414 } 415 416 #[test] 417 fn validate_theme_name_default() { 418 let result = validate_theme_name("default"); 419 assert!(result.is_ok()); 420 } 421 422 #[test] 423 fn validate_theme_name_invalid() { 424 let result = validate_theme_name("nonexistent-theme"); 425 assert!(result.is_err()); 426 assert!( 427 result 428 .unwrap_err() 429 .to_string() 430 .contains("Theme 'nonexistent-theme' not found") 431 ); 432 } 433 434 #[test] 435 fn validation_result_is_valid() { 436 let mut result = ValidationResult::new(); 437 assert!(result.is_valid()); 438 439 result.add_warning("test warning".to_string()); 440 assert!(result.is_valid()); 441 442 result.add_error("test error".to_string()); 443 assert!(!result.is_valid()); 444 } 445 446 #[test] 447 fn validation_result_has_issues() { 448 let mut result = ValidationResult::new(); 449 assert!(!result.has_issues()); 450 451 result.add_warning("test warning".to_string()); 452 assert!(result.has_issues()); 453 } 454}