1use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 McdPackage,
8 annotations::{load_manifest_annotations, validate_annotation_markers},
9 directives::TableDisplay,
10 document::{DocumentBlock, McdDocument, SourceSpan},
11 errors::{Diagnostic, McdError},
12 images::{load_manifest_images, validate_image_anchors},
13 table_view::TableView,
14 tables::{DataTable, load_manifest_tables},
15};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ValidationResult {
20 pub valid: bool,
22 pub diagnostics: Vec<Diagnostic>,
24}
25
26impl ValidationResult {
27 #[must_use]
29 pub fn valid() -> Self {
30 Self {
31 valid: true,
32 diagnostics: Vec::new(),
33 }
34 }
35}
36
37pub fn validate_package(package: &McdPackage) -> crate::Result<ValidationResult> {
39 let manifest = package.manifest()?;
40 let document = McdDocument::from_package(package, &manifest)?;
41 let tables = load_manifest_tables(package, &manifest)?;
42 let views = load_and_validate_views(package, &manifest, &tables)?;
43 validate_table_anchors(&document, &tables, &views)?;
44 let images = load_manifest_images(package, &manifest)?;
45 validate_image_anchors(&document, &images)?;
46 let annotations = load_manifest_annotations(package, &manifest, &document)?;
47 validate_annotation_markers(&document, &annotations)?;
48 Ok(ValidationResult::valid())
49}
50
51fn load_and_validate_views(
52 package: &McdPackage,
53 manifest: &crate::Manifest,
54 tables: &IndexMap<String, DataTable>,
55) -> crate::Result<IndexMap<String, IndexMap<String, TableView>>> {
56 let mut all_views = IndexMap::new();
57
58 for table_entry in &manifest.tables {
59 let table = tables.get(&table_entry.id).ok_or_else(|| {
60 McdError::from_diagnostic(
61 Diagnostic::error(
62 "table.internal.missing",
63 format!(
64 "Loaded table '{}' was not available for view validation.",
65 table_entry.id
66 ),
67 )
68 .with_source("manifest.json"),
69 )
70 })?;
71 let mut table_views = IndexMap::new();
72
73 for (view_id, view_path) in &table_entry.views {
74 let view = TableView::from_package(package, view_path)?;
75 view.validate(view_id, &table_entry.id, &table.schema, view_path)?;
76 table_views.insert(view_id.clone(), view);
77 }
78
79 all_views.insert(table_entry.id.clone(), table_views);
80 }
81
82 Ok(all_views)
83}
84
85fn validate_table_anchors(
86 document: &McdDocument,
87 tables: &IndexMap<String, DataTable>,
88 views: &IndexMap<String, IndexMap<String, TableView>>,
89) -> crate::Result<()> {
90 for block in &document.blocks {
91 let DocumentBlock::TableRef {
92 placement, source, ..
93 } = block
94 else {
95 continue;
96 };
97
98 if !tables.contains_key(&placement.table) {
99 return Err(anchor_error(
100 "table.anchor.unresolved",
101 format!(
102 "Table anchor references undeclared table '{}'.",
103 placement.table
104 ),
105 document,
106 *source,
107 ));
108 }
109
110 let Some(view_id) = &placement.view else {
111 if placement.display == TableDisplay::Chart {
112 return Err(anchor_error(
113 "chart.view.missing",
114 "Chart table anchors must reference a chart view.",
115 document,
116 *source,
117 ));
118 }
119 continue;
120 };
121
122 let Some(table_views) = views.get(&placement.table) else {
123 return Err(anchor_error(
124 "view.anchor.unresolved",
125 format!(
126 "Table anchor references view '{}' but table '{}' declares no views.",
127 view_id, placement.table
128 ),
129 document,
130 *source,
131 ));
132 };
133 let Some(view) = table_views.get(view_id) else {
134 return Err(anchor_error(
135 if placement.display == TableDisplay::Chart {
136 "chart.view.unresolved"
137 } else {
138 "view.anchor.unresolved"
139 },
140 format!(
141 "Table anchor references unknown view '{}' for table '{}'.",
142 view_id, placement.table
143 ),
144 document,
145 *source,
146 ));
147 };
148
149 if placement.display == TableDisplay::Chart && view.display != TableDisplay::Chart {
150 return Err(anchor_error(
151 "chart.view.not_chart",
152 format!(
153 "Chart anchor references view '{}' but that view is not a chart view.",
154 view_id
155 ),
156 document,
157 *source,
158 ));
159 }
160 if placement.display == TableDisplay::Table && view.display != TableDisplay::Table {
161 return Err(anchor_error(
162 "view.display.mismatch",
163 format!(
164 "Table anchor references view '{}' but that view is a chart view.",
165 view_id
166 ),
167 document,
168 *source,
169 ));
170 }
171 if view.table != placement.table {
172 return Err(anchor_error(
173 "chart.table.mismatch",
174 format!(
175 "View '{}' references table '{}' but anchor references table '{}'.",
176 view_id, view.table, placement.table
177 ),
178 document,
179 *source,
180 ));
181 }
182 }
183
184 Ok(())
185}
186
187fn anchor_error(
188 code: impl Into<String>,
189 message: impl Into<String>,
190 document: &McdDocument,
191 source: Option<SourceSpan>,
192) -> McdError {
193 let source = source
194 .map(|span| format!("{}:{span}", document.source_path))
195 .unwrap_or_else(|| document.source_path.clone());
196 McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source))
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use std::io::{Cursor, Write};
203 use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
204
205 #[test]
206 fn validates_table_and_chart_package() {
207 let package = package_with(
208 "quarter,revenue_gbp\nQ1,125000.00\n",
209 r#"{"id":"revenue","columns":[
210 {"name":"quarter","type":"string","nullable":false},
211 {"name":"revenue_gbp","type":"decimal","nullable":false}
212 ]}"#,
213 r#"{"id":"default","table":"revenue","display":"table","columns":[{"name":"quarter"}]}"#,
214 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
215 "type":"bar",
216 "x":{"column":"quarter"},
217 "y":{"column":"revenue_gbp"}
218 }}"#,
219 Some(
220 ":::table\nref: t\ntable: revenue\nview: default\n:::\n\n:::table\nref: c\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
221 ),
222 );
223
224 validate_package(&package).expect("valid package");
225 }
226
227 #[test]
228 fn rejects_csv_header_mismatch() {
229 let package = package_with(
230 "quarter,amount\nQ1,125000.00\n",
231 r#"{"id":"revenue","columns":[
232 {"name":"quarter","type":"string"},
233 {"name":"revenue_gbp","type":"decimal"}
234 ]}"#,
235 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
236 chart_view(),
237 None,
238 );
239
240 let err = validate_package(&package).expect_err("package should be invalid");
241 let diagnostic = err.diagnostic().expect("structured diagnostic");
242 assert_eq!(diagnostic.level, crate::errors::DiagnosticLevel::Error);
243 assert_eq!(diagnostic.code, "csv.header.mismatch");
244 assert!(
245 diagnostic
246 .message
247 .contains("CSV header does not match table schema")
248 );
249 assert_eq!(diagnostic.source.as_deref(), Some("tables/revenue.csv:1"));
250 assert_eq!(diagnostic.related, vec!["tables/revenue.schema.json"]);
251 }
252
253 #[test]
254 fn rejects_unresolved_table_anchor() {
255 let package = package_with(
256 "quarter,revenue_gbp\nQ1,125000.00\n",
257 schema(),
258 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
259 chart_view(),
260 Some(":::table\ntable: missing\n:::\n"),
261 );
262
263 assert_validation_code(&package, "table.anchor.unresolved");
264 }
265
266 #[test]
267 fn rejects_unknown_view_column() {
268 let package = package_with(
269 "quarter,revenue_gbp\nQ1,125000.00\n",
270 schema(),
271 r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
272 chart_view(),
273 None,
274 );
275
276 assert_validation_code(&package, "view.column.unknown");
277 }
278
279 #[test]
280 fn rejects_unknown_chart_column() {
281 let package = package_with(
282 "quarter,revenue_gbp\nQ1,125000.00\n",
283 schema(),
284 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
285 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
286 "type":"bar",
287 "x":{"column":"quarter"},
288 "y":{"column":"missing"}
289 }}"#,
290 None,
291 );
292
293 assert_validation_code(&package, "chart.column.unknown");
294 }
295
296 #[test]
297 fn rejects_incompatible_chart_column_type() {
298 let package = package_with(
299 "quarter,revenue_gbp\nQ1,125000.00\n",
300 schema(),
301 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
302 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
303 "type":"bar",
304 "x":{"column":"revenue_gbp"},
305 "y":{"column":"quarter"}
306 }}"#,
307 None,
308 );
309
310 assert_validation_code(&package, "chart.column.type.incompatible");
311 }
312
313 #[test]
314 fn rejects_chart_anchor_referencing_non_chart_view() {
315 let package = package_with(
316 "quarter,revenue_gbp\nQ1,125000.00\n",
317 schema(),
318 r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
319 chart_view(),
320 Some(":::table\ntable: revenue\nview: default\ndisplay: chart\n:::\n"),
321 );
322
323 assert_validation_code(&package, "chart.view.not_chart");
324 }
325
326 #[test]
327 fn validates_image_package() {
328 let package = image_package(
329 safe_svg(),
330 image_metadata(
331 "diagram",
332 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
333 ),
334 ":::image\nasset: process-diagram\n:::\n",
335 r#""conformance":["MCD-Core","MCD-Images"]"#,
336 );
337
338 validate_package(&package).expect("valid image package");
339 }
340
341 #[test]
342 fn rejects_missing_image_asset() {
343 let package = image_package_without_asset(
344 image_metadata(
345 "diagram",
346 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
347 ),
348 ":::image\nasset: process-diagram\n:::\n",
349 r#""conformance":["MCD-Core","MCD-Images"]"#,
350 );
351
352 assert_validation_code(&package, "asset.missing");
353 }
354
355 #[test]
356 fn rejects_missing_informative_image_alt() {
357 let package = image_package(
358 safe_svg(),
359 image_metadata(
360 "informative",
361 r#""caption":"Workflow diagram.","hash":null"#,
362 ),
363 ":::image\nasset: process-diagram\n:::\n",
364 r#""conformance":["MCD-Core","MCD-Images"]"#,
365 );
366
367 assert_validation_code(&package, "image.alt.missing");
368 }
369
370 #[test]
371 fn rejects_unresolved_image_anchor() {
372 let package = image_package(
373 safe_svg(),
374 image_metadata(
375 "diagram",
376 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
377 ),
378 ":::image\nasset: missing-image\n:::\n",
379 r#""conformance":["MCD-Core","MCD-Images"]"#,
380 );
381
382 assert_validation_code(&package, "image.anchor.unresolved");
383 }
384
385 #[test]
386 fn rejects_svg_script_and_external_resource() {
387 let script_package = image_package(
388 r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
389 image_metadata(
390 "diagram",
391 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
392 ),
393 ":::image\nasset: process-diagram\n:::\n",
394 r#""conformance":["MCD-Core","MCD-Images"]"#,
395 );
396 assert_validation_code(&script_package, "security.svg.active_content");
397
398 let external_package = image_package(
399 r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.com/x.png"/></svg>"#,
400 image_metadata(
401 "diagram",
402 r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
403 ),
404 ":::image\nasset: process-diagram\n:::\n",
405 r#""conformance":["MCD-Core","MCD-Images"]"#,
406 );
407 assert_validation_code(&external_package, "security.svg.external_reference");
408 }
409
410 #[test]
411 fn rejects_image_only_table_under_strict() {
412 let package = image_package(
413 safe_svg(),
414 r#"{
415 "id":"process-diagram",
416 "asset":"assets/process-diagram.svg",
417 "mediaType":"image/svg+xml",
418 "role":"diagram",
419 "alt":"Image of a table.",
420 "caption":"Table image.",
421 "meaningfulContent":{"tableData":true}
422 }"#
423 .to_owned(),
424 ":::image\nasset: process-diagram\n:::\n",
425 r#""conformance":["MCD-Core","MCD-Images","MCD-Strict"]"#,
426 );
427
428 assert_validation_code(&package, "image.meaningful_content.unlinked");
429 }
430
431 fn assert_validation_code(package: &McdPackage, code: &str) {
432 let err = validate_package(package).expect_err("package should be invalid");
433 assert_eq!(err.diagnostic().map(|d| d.code.as_str()), Some(code));
434 }
435
436 fn package_with(
437 csv: &str,
438 schema_json: &str,
439 table_view_json: &str,
440 chart_view_json: &str,
441 markdown: Option<&str>,
442 ) -> McdPackage {
443 let markdown = markdown.unwrap_or(
444 ":::table\ntable: revenue\nview: default\n:::\n\n:::table\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
445 );
446 McdPackage::from_bytes(&zip_bytes(&[
447 ("mimetype", crate::package::MCD_MIMETYPE),
448 ("manifest.json", manifest()),
449 ("content/main.md", markdown),
450 ("tables/revenue.csv", csv),
451 ("tables/revenue.schema.json", schema_json),
452 ("tables/revenue.view.json", table_view_json),
453 ("tables/revenue.chart.view.json", chart_view_json),
454 ]))
455 .expect("package opens")
456 }
457
458 fn image_package(
459 svg: &str,
460 image_json: String,
461 markdown: &str,
462 conformance: &str,
463 ) -> McdPackage {
464 McdPackage::from_bytes(&zip_bytes_owned(vec![
465 ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
466 ("manifest.json", image_manifest(conformance)),
467 ("content/main.md", markdown.to_owned()),
468 ("assets/process-diagram.svg", svg.to_owned()),
469 ("images/process-diagram.image.json", image_json),
470 ]))
471 .expect("package opens")
472 }
473
474 fn image_package_without_asset(
475 image_json: String,
476 markdown: &str,
477 conformance: &str,
478 ) -> McdPackage {
479 McdPackage::from_bytes(&zip_bytes_owned(vec![
480 ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
481 ("manifest.json", image_manifest(conformance)),
482 ("content/main.md", markdown.to_owned()),
483 ("images/process-diagram.image.json", image_json),
484 ]))
485 .expect("package opens")
486 }
487
488 fn image_manifest(conformance: &str) -> String {
489 format!(
490 r#"{{
491 "format":"MCD",
492 "version":"0.1",
493 "profile":"MCD-Core",
494 {conformance},
495 "entrypoint":"content/main.md",
496 "images":[{{"id":"process-diagram","metadata":"images/process-diagram.image.json"}}]
497 }}"#
498 )
499 }
500
501 fn image_metadata(role: &str, extra_fields: &str) -> String {
502 format!(
503 r#"{{
504 "id":"process-diagram",
505 "asset":"assets/process-diagram.svg",
506 "mediaType":"image/svg+xml",
507 "role":"{role}",
508 {extra_fields}
509 }}"#
510 )
511 }
512
513 fn safe_svg() -> &'static str {
514 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60"><rect width="120" height="60" fill="#f2f2f2"/><text x="10" y="35">Process</text></svg>"##
515 }
516
517 fn manifest() -> &'static str {
518 r#"{
519 "format":"MCD",
520 "version":"0.1",
521 "profile":"MCD-Core",
522 "entrypoint":"content/main.md",
523 "tables":[{
524 "id":"revenue",
525 "data":"tables/revenue.csv",
526 "schema":"tables/revenue.schema.json",
527 "views":{
528 "default":"tables/revenue.view.json",
529 "chart":"tables/revenue.chart.view.json"
530 }
531 }]
532 }"#
533 }
534
535 fn schema() -> &'static str {
536 r#"{"id":"revenue","columns":[
537 {"name":"quarter","type":"string"},
538 {"name":"revenue_gbp","type":"decimal"}
539 ]}"#
540 }
541
542 fn chart_view() -> &'static str {
543 r#"{"id":"chart","table":"revenue","display":"chart","chart":{
544 "type":"bar",
545 "x":{"column":"quarter"},
546 "y":{"column":"revenue_gbp"}
547 }}"#
548 }
549
550 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
551 let cursor = Cursor::new(Vec::new());
552 let mut writer = ZipWriter::new(cursor);
553 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
554
555 for (path, content) in entries {
556 writer.start_file(*path, options).expect("start file");
557 writer.write_all(content.as_bytes()).expect("write file");
558 }
559
560 writer.finish().expect("finish zip").into_inner()
561 }
562
563 fn zip_bytes_owned(entries: Vec<(&str, String)>) -> Vec<u8> {
564 let cursor = Cursor::new(Vec::new());
565 let mut writer = ZipWriter::new(cursor);
566 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
567
568 for (path, content) in entries {
569 writer.start_file(path, options).expect("start file");
570 writer.write_all(content.as_bytes()).expect("write file");
571 }
572
573 writer.finish().expect("finish zip").into_inner()
574 }
575}