Skip to main content

mcd_core/
validate.rs

1//! Cross-file validation entry points.
2
3use 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/// Result of validating a package.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ValidationResult {
20    /// Whether the package is valid.
21    pub valid: bool,
22    /// Structured diagnostics.
23    pub diagnostics: Vec<Diagnostic>,
24}
25
26impl ValidationResult {
27    /// Construct a successful validation result.
28    #[must_use]
29    pub fn valid() -> Self {
30        Self {
31            valid: true,
32            diagnostics: Vec::new(),
33        }
34    }
35}
36
37/// Validate package-level, manifest, Markdown, table, schema, and table view rules.
38pub 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}