1use std::collections::HashSet;
4use std::fmt;
5
6use super::{Block, Content, Text};
7use crate::extensions::ExtensionBlock;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ValidationError {
19 pub path: String,
21
22 pub message: String,
24}
25
26impl fmt::Display for ValidationError {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 if self.path.is_empty() {
29 write!(f, "{}", self.message)
30 } else {
31 write!(f, "{}: {}", self.path, self.message)
32 }
33 }
34}
35
36impl std::error::Error for ValidationError {}
37
38#[must_use]
53pub fn validate_content(content: &Content) -> Vec<ValidationError> {
54 let mut errors = Vec::new();
55 let mut seen_ids = HashSet::new();
56
57 for (i, block) in content.blocks.iter().enumerate() {
58 let path = format!("blocks[{i}]");
59 validate_block(block, &path, &mut errors, &mut seen_ids, None);
60 }
61
62 errors
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum ParentContext {
68 List,
69 Table,
70 TableRow,
71 DefinitionList,
72 Figure,
73}
74
75struct ValidationContext<'a> {
77 errors: &'a mut Vec<ValidationError>,
78 seen_ids: &'a mut HashSet<String>,
79}
80
81impl ValidationContext<'_> {
82 fn add_error(&mut self, path: &str, message: impl Into<String>) {
83 self.errors.push(ValidationError {
84 path: path.to_string(),
85 message: message.into(),
86 });
87 }
88}
89
90fn validate_block(
91 block: &Block,
92 path: &str,
93 errors: &mut Vec<ValidationError>,
94 seen_ids: &mut HashSet<String>,
95 parent: Option<ParentContext>,
96) {
97 let mut ctx = ValidationContext { errors, seen_ids };
98
99 if let Some(id) = block.id() {
101 if !ctx.seen_ids.insert(id.to_string()) {
102 ctx.add_error(path, format!("duplicate block ID: {id}"));
103 }
104 }
105
106 match block {
107 Block::Paragraph { children, .. } => validate_text_children(children, path, ctx.errors),
108 Block::Heading {
109 level, children, ..
110 } => {
111 validate_heading(*level, children, path, ctx.errors);
112 }
113 Block::List { children, .. } => validate_list(children, path, &mut ctx),
114 Block::ListItem { children, .. } => validate_list_item(children, path, parent, &mut ctx),
115 Block::Blockquote { children, .. } => validate_container(children, path, &mut ctx),
116 Block::CodeBlock { children, .. } => validate_code_block(children, path, ctx.errors),
117 Block::HorizontalRule { .. } | Block::Break { .. } | Block::Signature(_) => {}
118 Block::Image(img) => validate_image(img, path, ctx.errors),
119 Block::Table { children, .. } => validate_table(children, path, &mut ctx),
120 Block::TableRow { children, .. } => validate_table_row(children, path, parent, &mut ctx),
121 Block::TableCell(cell) => validate_table_cell(cell, path, parent, ctx.errors),
122 Block::Math(math) => validate_math(math, path, ctx.errors),
123 Block::Extension(ext) => validate_extension(ext, path, &mut ctx),
124 Block::DefinitionList(dl) => validate_definition_list(&dl.children, path, &mut ctx),
126 Block::DefinitionItem { children, .. } => {
127 validate_definition_item(children, path, parent, &mut ctx);
128 }
129 Block::DefinitionTerm { children, .. } => {
130 validate_text_children(children, path, ctx.errors);
131 }
132 Block::DefinitionDescription { children, .. } => {
133 validate_container(children, path, &mut ctx);
134 }
135 Block::Measurement(m) => validate_measurement(m, path, ctx.errors),
136 Block::Svg(svg) => validate_svg(svg, path, ctx.errors),
137 Block::Barcode(bc) => validate_barcode(bc, path, ctx.errors),
138 Block::Figure(fig) => validate_figure(fig, path, &mut ctx),
139 Block::FigCaption(fc) => validate_figcaption(&fc.children, path, parent, ctx.errors),
140 Block::Admonition(adm) => validate_container(&adm.children, path, &mut ctx),
141 }
142}
143
144fn validate_heading(level: u8, children: &[Text], path: &str, errors: &mut Vec<ValidationError>) {
145 if !(1..=6).contains(&level) {
146 errors.push(ValidationError {
147 path: path.to_string(),
148 message: format!("heading level must be 1-6, got {level}"),
149 });
150 }
151 validate_text_children(children, path, errors);
152}
153
154fn validate_list(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
155 for (i, child) in children.iter().enumerate() {
156 let child_path = format!("{path}.children[{i}]");
157 if !matches!(child, Block::ListItem { .. }) {
158 ctx.add_error(
159 &child_path,
160 format!("list children must be listItem, got {}", child.block_type()),
161 );
162 }
163 validate_block(
164 child,
165 &child_path,
166 ctx.errors,
167 ctx.seen_ids,
168 Some(ParentContext::List),
169 );
170 }
171}
172
173fn validate_list_item(
174 children: &[Block],
175 path: &str,
176 parent: Option<ParentContext>,
177 ctx: &mut ValidationContext<'_>,
178) {
179 if parent != Some(ParentContext::List) {
180 ctx.add_error(path, "listItem must be a child of list");
181 }
182 for (i, child) in children.iter().enumerate() {
183 let child_path = format!("{path}.children[{i}]");
184 validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
185 }
186}
187
188fn validate_container(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
189 for (i, child) in children.iter().enumerate() {
190 let child_path = format!("{path}.children[{i}]");
191 validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
192 }
193}
194
195fn validate_code_block(children: &[Text], path: &str, errors: &mut Vec<ValidationError>) {
196 if children.len() != 1 {
197 errors.push(ValidationError {
198 path: path.to_string(),
199 message: format!(
200 "codeBlock should have exactly 1 text node, got {}",
201 children.len()
202 ),
203 });
204 }
205 for child in children {
206 if !child.marks.is_empty() {
207 errors.push(ValidationError {
208 path: path.to_string(),
209 message: "codeBlock text should not have marks".to_string(),
210 });
211 }
212 }
213}
214
215fn validate_image(img: &super::block::ImageBlock, path: &str, errors: &mut Vec<ValidationError>) {
216 if img.src.is_empty() {
217 errors.push(ValidationError {
218 path: path.to_string(),
219 message: "image src is required".to_string(),
220 });
221 }
222 if img.alt.is_empty() {
223 errors.push(ValidationError {
224 path: path.to_string(),
225 message: "image alt is required".to_string(),
226 });
227 }
228}
229
230fn validate_table(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
231 for (i, child) in children.iter().enumerate() {
232 let child_path = format!("{path}.children[{i}]");
233 if !matches!(child, Block::TableRow { .. }) {
234 ctx.add_error(
235 &child_path,
236 format!(
237 "table children must be tableRow, got {}",
238 child.block_type()
239 ),
240 );
241 }
242 validate_block(
243 child,
244 &child_path,
245 ctx.errors,
246 ctx.seen_ids,
247 Some(ParentContext::Table),
248 );
249 }
250}
251
252fn validate_table_row(
253 children: &[Block],
254 path: &str,
255 parent: Option<ParentContext>,
256 ctx: &mut ValidationContext<'_>,
257) {
258 if parent != Some(ParentContext::Table) {
259 ctx.add_error(path, "tableRow must be a child of table");
260 }
261 for (i, child) in children.iter().enumerate() {
262 let child_path = format!("{path}.children[{i}]");
263 if !matches!(child, Block::TableCell(_)) {
264 ctx.add_error(
265 &child_path,
266 format!(
267 "tableRow children must be tableCell, got {}",
268 child.block_type()
269 ),
270 );
271 }
272 validate_block(
273 child,
274 &child_path,
275 ctx.errors,
276 ctx.seen_ids,
277 Some(ParentContext::TableRow),
278 );
279 }
280}
281
282fn validate_table_cell(
283 cell: &super::block::TableCellBlock,
284 path: &str,
285 parent: Option<ParentContext>,
286 errors: &mut Vec<ValidationError>,
287) {
288 if parent != Some(ParentContext::TableRow) {
289 errors.push(ValidationError {
290 path: path.to_string(),
291 message: "tableCell must be a child of tableRow".to_string(),
292 });
293 }
294 if cell.colspan == 0 {
295 errors.push(ValidationError {
296 path: path.to_string(),
297 message: "tableCell colspan must be at least 1".to_string(),
298 });
299 }
300 if cell.rowspan == 0 {
301 errors.push(ValidationError {
302 path: path.to_string(),
303 message: "tableCell rowspan must be at least 1".to_string(),
304 });
305 }
306 validate_text_children(&cell.children, path, errors);
307}
308
309fn validate_math(math: &super::block::MathBlock, path: &str, errors: &mut Vec<ValidationError>) {
310 if math.value.is_empty() {
311 errors.push(ValidationError {
312 path: path.to_string(),
313 message: "math value is required".to_string(),
314 });
315 }
316}
317
318fn validate_extension(ext: &ExtensionBlock, path: &str, ctx: &mut ValidationContext<'_>) {
319 if ext.namespace.is_empty() {
321 ctx.add_error(path, "extension namespace is required");
322 }
323 if ext.block_type.is_empty() {
324 ctx.add_error(path, "extension block type is required");
325 }
326
327 for (i, child) in ext.children.iter().enumerate() {
329 let child_path = format!("{path}.children[{i}]");
330 validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
331 }
332
333 if let Some(fallback) = &ext.fallback {
335 let fallback_path = format!("{path}.fallback");
336 validate_block(fallback, &fallback_path, ctx.errors, ctx.seen_ids, None);
337 }
338}
339
340fn validate_text_children(children: &[Text], path: &str, errors: &mut Vec<ValidationError>) {
341 for (i, text) in children.iter().enumerate() {
342 if text.value.is_empty() {
343 errors.push(ValidationError {
344 path: format!("{path}.children[{i}]"),
345 message: "text value cannot be empty".to_string(),
346 });
347 }
348 }
349}
350
351fn validate_definition_list(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
352 for (i, child) in children.iter().enumerate() {
353 let child_path = format!("{path}.children[{i}]");
354 if !matches!(child, Block::DefinitionItem { .. }) {
355 ctx.add_error(
356 &child_path,
357 format!(
358 "definitionList children must be definitionItem, got {}",
359 child.block_type()
360 ),
361 );
362 }
363 validate_block(
364 child,
365 &child_path,
366 ctx.errors,
367 ctx.seen_ids,
368 Some(ParentContext::DefinitionList),
369 );
370 }
371}
372
373fn validate_definition_item(
374 children: &[Block],
375 path: &str,
376 parent: Option<ParentContext>,
377 ctx: &mut ValidationContext<'_>,
378) {
379 if parent != Some(ParentContext::DefinitionList) {
380 ctx.add_error(path, "definitionItem must be a child of definitionList");
381 }
382 for (i, child) in children.iter().enumerate() {
383 let child_path = format!("{path}.children[{i}]");
384 validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
385 }
386}
387
388fn validate_measurement(
389 m: &super::block::MeasurementBlock,
390 path: &str,
391 errors: &mut Vec<ValidationError>,
392) {
393 if m.display.is_empty() {
394 errors.push(ValidationError {
395 path: path.to_string(),
396 message: "measurement display is required".to_string(),
397 });
398 }
399}
400
401fn validate_svg(svg: &super::block::SvgBlock, path: &str, errors: &mut Vec<ValidationError>) {
402 match (&svg.src, &svg.content) {
404 (Some(_), Some(_)) => {
405 errors.push(ValidationError {
406 path: path.to_string(),
407 message: "svg must have either src or content, not both".to_string(),
408 });
409 }
410 (None, None) => {
411 errors.push(ValidationError {
412 path: path.to_string(),
413 message: "svg must have either src or content".to_string(),
414 });
415 }
416 _ => {}
417 }
418}
419
420fn validate_barcode(
421 bc: &super::block::BarcodeBlock,
422 path: &str,
423 errors: &mut Vec<ValidationError>,
424) {
425 if bc.data.is_empty() {
426 errors.push(ValidationError {
427 path: path.to_string(),
428 message: "barcode data is required".to_string(),
429 });
430 }
431 let alt_lower = bc.alt.to_lowercase();
433 if bc.alt.is_empty() || alt_lower == "barcode" || alt_lower == "qr code" || alt_lower == "image"
434 {
435 errors.push(ValidationError {
436 path: path.to_string(),
437 message: "barcode alt must be meaningful (not just 'barcode' or 'image')".to_string(),
438 });
439 }
440}
441
442fn validate_figure(fig: &super::block::FigureBlock, path: &str, ctx: &mut ValidationContext<'_>) {
443 for (i, child) in fig.children.iter().enumerate() {
444 let child_path = format!("{path}.children[{i}]");
445 validate_block(
446 child,
447 &child_path,
448 ctx.errors,
449 ctx.seen_ids,
450 Some(ParentContext::Figure),
451 );
452 }
453
454 if let Some(ref subfigures) = fig.subfigures {
456 for (i, subfig) in subfigures.iter().enumerate() {
457 let subfig_path = format!("{path}.subfigures[{i}]");
458
459 if let Some(ref id) = subfig.id {
461 if !ctx.seen_ids.insert(id.clone()) {
462 ctx.add_error(&subfig_path, format!("duplicate block ID: {id}"));
463 }
464 }
465
466 for (j, child) in subfig.children.iter().enumerate() {
468 let child_path = format!("{subfig_path}.children[{j}]");
469 validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
470 }
471 }
472 }
473}
474
475fn validate_figcaption(
476 children: &[Text],
477 path: &str,
478 parent: Option<ParentContext>,
479 errors: &mut Vec<ValidationError>,
480) {
481 if parent != Some(ParentContext::Figure) {
482 errors.push(ValidationError {
483 path: path.to_string(),
484 message: "figcaption should be a child of figure".to_string(),
485 });
486 }
487 validate_text_children(children, path, errors);
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::content::{BlockAttributes, Mark, Text};
494
495 #[test]
496 fn test_valid_content() {
497 let content = Content::new(vec![
498 Block::heading(1, vec![Text::plain("Title")]),
499 Block::paragraph(vec![Text::plain("Body")]),
500 ]);
501 let errors = validate_content(&content);
502 assert!(errors.is_empty());
503 }
504
505 #[test]
506 fn test_duplicate_ids() {
507 let content = Content::new(vec![
508 Block::Paragraph {
509 id: Some("dup".to_string()),
510 children: vec![Text::plain("First")],
511 attributes: BlockAttributes::default(),
512 },
513 Block::Paragraph {
514 id: Some("dup".to_string()),
515 children: vec![Text::plain("Second")],
516 attributes: BlockAttributes::default(),
517 },
518 ]);
519 let errors = validate_content(&content);
520 assert_eq!(errors.len(), 1);
521 assert!(errors[0].message.contains("duplicate"));
522 }
523
524 #[test]
525 fn test_invalid_heading_level() {
526 let content = Content::new(vec![Block::Heading {
527 id: None,
528 level: 7,
529 children: vec![Text::plain("Too deep")],
530 attributes: BlockAttributes::default(),
531 }]);
532 let errors = validate_content(&content);
533 assert_eq!(errors.len(), 1);
534 assert!(errors[0].message.contains("level"));
535 }
536
537 #[test]
538 fn test_list_item_outside_list() {
539 let content = Content::new(vec![Block::list_item(vec![Block::paragraph(vec![
540 Text::plain("Orphan"),
541 ])])]);
542 let errors = validate_content(&content);
543 assert_eq!(errors.len(), 1);
544 assert!(errors[0].message.contains("child of list"));
545 }
546
547 #[test]
548 fn test_list_with_wrong_children() {
549 let content = Content::new(vec![Block::List {
550 id: None,
551 ordered: false,
552 start: None,
553 children: vec![Block::paragraph(vec![Text::plain("Wrong")])],
554 attributes: BlockAttributes::default(),
555 }]);
556 let errors = validate_content(&content);
557 assert_eq!(errors.len(), 1);
558 assert!(errors[0].message.contains("listItem"));
559 }
560
561 #[test]
562 fn test_code_block_with_marks() {
563 let content = Content::new(vec![Block::CodeBlock {
564 id: None,
565 language: Some("rust".to_string()),
566 highlighting: None,
567 tokens: None,
568 children: vec![Text::with_marks("code", vec![Mark::Bold])],
569 attributes: BlockAttributes::default(),
570 }]);
571 let errors = validate_content(&content);
572 assert_eq!(errors.len(), 1);
573 assert!(errors[0].message.contains("marks"));
574 }
575
576 #[test]
577 fn test_empty_image() {
578 let content = Content::new(vec![Block::Image(super::super::block::ImageBlock {
579 id: None,
580 src: String::new(),
581 alt: String::new(),
582 title: None,
583 width: None,
584 height: None,
585 })]);
586 let errors = validate_content(&content);
587 assert_eq!(errors.len(), 2);
588 }
589
590 #[test]
591 fn test_valid_table() {
592 let content = Content::new(vec![Block::table(vec![Block::table_row(
593 vec![Block::table_cell(vec![Text::plain("Cell")])],
594 false,
595 )])]);
596 let errors = validate_content(&content);
597 assert!(errors.is_empty());
598 }
599
600 #[test]
601 fn test_table_row_outside_table() {
602 let content = Content::new(vec![Block::table_row(
603 vec![Block::table_cell(vec![Text::plain("Orphan")])],
604 false,
605 )]);
606 let errors = validate_content(&content);
607 assert!(errors.iter().any(|e| e.message.contains("child of table")));
608 }
609
610 #[test]
613 fn test_valid_definition_list() {
614 let content = Content::new(vec![Block::definition_list(vec![Block::definition_item(
615 vec![
616 Block::definition_term(vec![Text::plain("Term")]),
617 Block::definition_description(vec![Block::paragraph(vec![Text::plain("Desc")])]),
618 ],
619 )])]);
620 let errors = validate_content(&content);
621 assert!(errors.is_empty());
622 }
623
624 #[test]
625 fn test_definition_item_outside_list() {
626 let content = Content::new(vec![Block::definition_item(vec![Block::definition_term(
627 vec![Text::plain("Orphan term")],
628 )])]);
629 let errors = validate_content(&content);
630 assert!(errors
631 .iter()
632 .any(|e| e.message.contains("child of definitionList")));
633 }
634
635 #[test]
636 fn test_definition_list_with_wrong_children() {
637 let content = Content::new(vec![Block::DefinitionList(
638 super::super::block::DefinitionListBlock::new(vec![Block::paragraph(vec![
639 Text::plain("Wrong"),
640 ])]),
641 )]);
642 let errors = validate_content(&content);
643 assert!(errors.iter().any(|e| e.message.contains("definitionItem")));
644 }
645
646 #[test]
647 fn test_svg_with_both_src_and_content() {
648 let content = Content::new(vec![Block::Svg(super::super::block::SvgBlock {
649 id: None,
650 src: Some("file.svg".to_string()),
651 content: Some("<svg>...</svg>".to_string()),
652 width: None,
653 height: None,
654 alt: None,
655 })]);
656 let errors = validate_content(&content);
657 assert!(errors
658 .iter()
659 .any(|e| e.message.contains("either src or content, not both")));
660 }
661
662 #[test]
663 fn test_svg_with_neither_src_nor_content() {
664 let content = Content::new(vec![Block::Svg(super::super::block::SvgBlock {
665 id: None,
666 src: None,
667 content: None,
668 width: None,
669 height: None,
670 alt: None,
671 })]);
672 let errors = validate_content(&content);
673 assert!(errors
674 .iter()
675 .any(|e| e.message.contains("either src or content")));
676 }
677
678 #[test]
679 fn test_barcode_with_generic_alt() {
680 use super::super::block::{BarcodeBlock, BarcodeFormat};
681
682 let content = Content::new(vec![Block::Barcode(BarcodeBlock::new(
683 BarcodeFormat::Qr,
684 "https://example.com",
685 "barcode", ))]);
687 let errors = validate_content(&content);
688 assert!(errors.iter().any(|e| e.message.contains("meaningful")));
689 }
690
691 #[test]
692 fn test_barcode_with_good_alt() {
693 use super::super::block::{BarcodeBlock, BarcodeFormat};
694
695 let content = Content::new(vec![Block::Barcode(BarcodeBlock::new(
696 BarcodeFormat::Qr,
697 "https://example.com",
698 "Link to example.com homepage",
699 ))]);
700 let errors = validate_content(&content);
701 assert!(errors.is_empty());
702 }
703
704 #[test]
705 fn test_valid_figure() {
706 let content = Content::new(vec![Block::figure(vec![
707 Block::image("photo.png", "A photo"),
708 Block::figcaption(vec![Text::plain("Figure 1")]),
709 ])]);
710 let errors = validate_content(&content);
711 assert!(errors.is_empty());
712 }
713
714 #[test]
715 fn test_figcaption_outside_figure() {
716 let content = Content::new(vec![Block::figcaption(vec![Text::plain("Orphan caption")])]);
717 let errors = validate_content(&content);
718 assert!(errors.iter().any(|e| e.message.contains("child of figure")));
719 }
720
721 #[test]
722 fn test_subfigure_with_invalid_block() {
723 use super::super::block::{FigureBlock, Subfigure};
724
725 let fig = FigureBlock {
726 id: None,
727 numbering: None,
728 subfigures: Some(vec![Subfigure {
729 id: Some("sub-a".to_string()),
730 label: Some("(a)".to_string()),
731 children: vec![Block::Image(super::super::block::ImageBlock {
732 id: None,
733 src: String::new(), alt: String::new(), title: None,
736 width: None,
737 height: None,
738 })],
739 }]),
740 children: vec![Block::image("main.png", "Main image")],
741 attributes: BlockAttributes::default(),
742 };
743
744 let content = Content::new(vec![Block::Figure(fig)]);
745 let errors = validate_content(&content);
746 assert!(
747 !errors.is_empty(),
748 "subfigure with invalid block should produce errors"
749 );
750 assert!(errors.iter().any(|e| e.message.contains("src")));
752 assert!(errors.iter().any(|e| e.message.contains("alt")));
753 }
754
755 #[test]
756 fn test_subfigure_duplicate_id() {
757 use super::super::block::{FigureBlock, Subfigure};
758
759 let fig = FigureBlock {
760 id: Some("fig-1".to_string()),
761 numbering: None,
762 subfigures: Some(vec![Subfigure {
763 id: Some("fig-1".to_string()), label: None,
765 children: vec![Block::paragraph(vec![Text::plain("subfig")])],
766 }]),
767 children: vec![Block::paragraph(vec![Text::plain("content")])],
768 attributes: BlockAttributes::default(),
769 };
770
771 let content = Content::new(vec![Block::Figure(fig)]);
772 let errors = validate_content(&content);
773 assert!(errors.iter().any(|e| e.message.contains("duplicate")));
774 }
775
776 #[test]
777 fn test_heading_level_clamped_on_deser() {
778 let json = r#"{"type":"heading","level":0,"children":[{"value":"Zero"}]}"#;
779 let block: Block = serde_json::from_str(json).unwrap();
780 if let Block::Heading { level, .. } = block {
781 assert_eq!(level, 1, "level 0 should be clamped to 1");
782 } else {
783 panic!("Expected Heading");
784 }
785
786 let json = r#"{"type":"heading","level":99,"children":[{"value":"High"}]}"#;
787 let block: Block = serde_json::from_str(json).unwrap();
788 if let Block::Heading { level, .. } = block {
789 assert_eq!(level, 6, "level 99 should be clamped to 6");
790 } else {
791 panic!("Expected Heading");
792 }
793 }
794
795 #[test]
796 fn test_measurement_empty_display() {
797 let content = Content::new(vec![Block::Measurement(
798 super::super::block::MeasurementBlock {
799 id: None,
800 value: 42.0,
801 uncertainty: None,
802 uncertainty_notation: None,
803 exponent: None,
804 display: String::new(), unit: None,
806 },
807 )]);
808 let errors = validate_content(&content);
809 assert!(errors.iter().any(|e| e.message.contains("display")));
810 }
811}