magical markdown slides
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}