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    provenance::load_manifest_provenance,
14    table_view::TableView,
15    tables::{DataTable, load_manifest_tables, row_key},
16};
17
18/// Result of validating a package.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ValidationResult {
21    /// Whether the package is valid.
22    pub valid: bool,
23    /// Structured diagnostics.
24    pub diagnostics: Vec<Diagnostic>,
25}
26
27impl ValidationResult {
28    /// Construct a successful validation result.
29    #[must_use]
30    pub fn valid() -> Self {
31        Self {
32            valid: true,
33            diagnostics: Vec::new(),
34        }
35    }
36}
37
38/// Validate package-level, manifest, Markdown, table, schema, and table view rules.
39pub fn validate_package(package: &McdPackage) -> crate::Result<ValidationResult> {
40    let manifest = package.manifest()?;
41    let document = McdDocument::from_package(package, &manifest)?;
42    let tables = load_manifest_tables(package, &manifest)?;
43    validate_foreign_keys(&tables)?;
44    let views = load_and_validate_views(package, &manifest, &tables)?;
45    validate_table_anchors(&document, &tables, &views)?;
46    let images = load_manifest_images(package, &manifest)?;
47    validate_image_anchors(&document, &images)?;
48    let annotations = load_manifest_annotations(package, &manifest, &document)?;
49    validate_annotation_markers(&document, &annotations)?;
50    load_manifest_provenance(package, &manifest)?;
51    Ok(ValidationResult::valid())
52}
53
54fn validate_foreign_keys(tables: &IndexMap<String, DataTable>) -> crate::Result<()> {
55    for table in tables.values() {
56        for foreign_key in &table.schema.foreign_keys {
57            let target = tables.get(&foreign_key.references.table).ok_or_else(|| {
58                McdError::from_diagnostic(
59                    Diagnostic::error(
60                        "schema.foreign_key.references.table.unresolved",
61                        format!(
62                            "Foreign key in table '{}' references unknown table '{}'.",
63                            table.id, foreign_key.references.table
64                        ),
65                    )
66                    .with_source(table.source.clone()),
67                )
68            })?;
69
70            if foreign_key.references.columns != target.schema.primary_key {
71                return Err(McdError::from_diagnostic(
72                    Diagnostic::error(
73                        "schema.foreign_key.references.primary_key.mismatch",
74                        format!(
75                            "Foreign key in table '{}' must reference the primary key of table '{}'.",
76                            table.id, target.id
77                        ),
78                    )
79                    .with_source(table.source.clone()),
80                ));
81            }
82
83            for (local_column, target_column) in foreign_key
84                .columns
85                .iter()
86                .zip(foreign_key.references.columns.iter())
87            {
88                let local = table.schema.column(local_column).ok_or_else(|| {
89                    McdError::from_diagnostic(
90                        Diagnostic::error(
91                            "schema.foreign_key.column.unknown",
92                            format!(
93                                "Foreign key in table '{}' references unknown local column '{}'.",
94                                table.id, local_column
95                            ),
96                        )
97                        .with_source(table.source.clone()),
98                    )
99                })?;
100                let target = target.schema.column(target_column).ok_or_else(|| {
101                    McdError::from_diagnostic(
102                        Diagnostic::error(
103                            "schema.foreign_key.references.column.unknown",
104                            format!(
105                                "Foreign key in table '{}' references unknown column '{}' on table '{}'.",
106                                table.id, target_column, foreign_key.references.table
107                            ),
108                        )
109                        .with_source(table.source.clone()),
110                    )
111                })?;
112                if local.value_type != target.value_type {
113                    return Err(McdError::from_diagnostic(
114                        Diagnostic::error(
115                            "schema.foreign_key.column.type_mismatch",
116                            format!(
117                                "Foreign key column '{}.{}' has type {:?}, but target '{}.{}' has type {:?}.",
118                                table.id,
119                                local_column,
120                                local.value_type,
121                                foreign_key.references.table,
122                                target_column,
123                                target.value_type
124                            ),
125                        )
126                        .with_source(table.source.clone()),
127                    ));
128                }
129            }
130
131            let target_keys = target
132                .rows
133                .iter()
134                .filter_map(|row| row_key(row, &foreign_key.references.columns))
135                .collect::<std::collections::HashSet<_>>();
136
137            for (row_index, row) in table.rows.iter().enumerate() {
138                let Some(key) = row_key(row, &foreign_key.columns) else {
139                    continue;
140                };
141                if !target_keys.contains(&key) {
142                    return Err(McdError::from_diagnostic(
143                        Diagnostic::error(
144                            "csv.foreign_key.unresolved",
145                            format!(
146                                "Foreign key in table '{}' references a missing row in table '{}'.",
147                                table.id, foreign_key.references.table
148                            ),
149                        )
150                        .with_source(format!(
151                            "{}:{}",
152                            table.source,
153                            row_index + 2
154                        )),
155                    ));
156                }
157            }
158        }
159    }
160
161    Ok(())
162}
163
164fn load_and_validate_views(
165    package: &McdPackage,
166    manifest: &crate::Manifest,
167    tables: &IndexMap<String, DataTable>,
168) -> crate::Result<IndexMap<String, IndexMap<String, TableView>>> {
169    let mut all_views = IndexMap::new();
170
171    for table_entry in &manifest.tables {
172        let table = tables.get(&table_entry.id).ok_or_else(|| {
173            McdError::from_diagnostic(
174                Diagnostic::error(
175                    "table.internal.missing",
176                    format!(
177                        "Loaded table '{}' was not available for view validation.",
178                        table_entry.id
179                    ),
180                )
181                .with_source("manifest.json"),
182            )
183        })?;
184        let mut table_views = IndexMap::new();
185
186        for (view_id, view_path) in &table_entry.views {
187            let view = TableView::from_package(package, view_path)?;
188            view.validate(view_id, &table_entry.id, &table.schema, view_path)?;
189            table_views.insert(view_id.clone(), view);
190        }
191
192        all_views.insert(table_entry.id.clone(), table_views);
193    }
194
195    Ok(all_views)
196}
197
198fn validate_table_anchors(
199    document: &McdDocument,
200    tables: &IndexMap<String, DataTable>,
201    views: &IndexMap<String, IndexMap<String, TableView>>,
202) -> crate::Result<()> {
203    for block in &document.blocks {
204        let DocumentBlock::TableRef {
205            placement, source, ..
206        } = block
207        else {
208            continue;
209        };
210
211        if !tables.contains_key(&placement.table) {
212            return Err(anchor_error(
213                "table.anchor.unresolved",
214                format!(
215                    "Table anchor references undeclared table '{}'.",
216                    placement.table
217                ),
218                document,
219                *source,
220            ));
221        }
222
223        let Some(view_id) = &placement.view else {
224            if placement.display == TableDisplay::Chart {
225                return Err(anchor_error(
226                    "chart.view.missing",
227                    "Chart table anchors must reference a chart view.",
228                    document,
229                    *source,
230                ));
231            }
232            continue;
233        };
234
235        let Some(table_views) = views.get(&placement.table) else {
236            return Err(anchor_error(
237                "view.anchor.unresolved",
238                format!(
239                    "Table anchor references view '{}' but table '{}' declares no views.",
240                    view_id, placement.table
241                ),
242                document,
243                *source,
244            ));
245        };
246        let Some(view) = table_views.get(view_id) else {
247            return Err(anchor_error(
248                if placement.display == TableDisplay::Chart {
249                    "chart.view.unresolved"
250                } else {
251                    "view.anchor.unresolved"
252                },
253                format!(
254                    "Table anchor references unknown view '{}' for table '{}'.",
255                    view_id, placement.table
256                ),
257                document,
258                *source,
259            ));
260        };
261
262        if placement.display == TableDisplay::Chart && view.display != TableDisplay::Chart {
263            return Err(anchor_error(
264                "chart.view.not_chart",
265                format!(
266                    "Chart anchor references view '{}' but that view is not a chart view.",
267                    view_id
268                ),
269                document,
270                *source,
271            ));
272        }
273        if placement.display == TableDisplay::Table && view.display != TableDisplay::Table {
274            return Err(anchor_error(
275                "view.display.mismatch",
276                format!(
277                    "Table anchor references view '{}' but that view is a chart view.",
278                    view_id
279                ),
280                document,
281                *source,
282            ));
283        }
284        if view.table != placement.table {
285            return Err(anchor_error(
286                "chart.table.mismatch",
287                format!(
288                    "View '{}' references table '{}' but anchor references table '{}'.",
289                    view_id, view.table, placement.table
290                ),
291                document,
292                *source,
293            ));
294        }
295    }
296
297    Ok(())
298}
299
300fn anchor_error(
301    code: impl Into<String>,
302    message: impl Into<String>,
303    document: &McdDocument,
304    source: Option<SourceSpan>,
305) -> McdError {
306    let source = source
307        .map(|span| format!("{}:{span}", document.source_path))
308        .unwrap_or_else(|| document.source_path.clone());
309    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source))
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::io::{Cursor, Write};
316    use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
317
318    #[test]
319    fn validates_table_and_chart_package() {
320        let package = package_with(
321            "quarter,revenue_gbp\nQ1,125000.00\n",
322            r#"{"id":"revenue","columns":[
323                {"name":"quarter","type":"string","nullable":false},
324                {"name":"revenue_gbp","type":"decimal","nullable":false}
325            ]}"#,
326            r#"{"id":"default","table":"revenue","display":"table","columns":[{"name":"quarter"}]}"#,
327            r#"{"id":"chart","table":"revenue","display":"chart","chart":{
328                "type":"bar",
329                "x":{"column":"quarter"},
330                "y":{"column":"revenue_gbp"}
331            }}"#,
332            Some(
333                ":::table\nref: t\ntable: revenue\nview: default\n:::\n\n:::table\nref: c\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
334            ),
335        );
336
337        validate_package(&package).expect("valid package");
338    }
339
340    #[test]
341    fn rejects_csv_header_mismatch() {
342        let package = package_with(
343            "quarter,amount\nQ1,125000.00\n",
344            r#"{"id":"revenue","columns":[
345                {"name":"quarter","type":"string"},
346                {"name":"revenue_gbp","type":"decimal"}
347            ]}"#,
348            r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
349            chart_view(),
350            None,
351        );
352
353        let err = validate_package(&package).expect_err("package should be invalid");
354        let diagnostic = err.diagnostic().expect("structured diagnostic");
355        assert_eq!(diagnostic.level, crate::errors::DiagnosticLevel::Error);
356        assert_eq!(diagnostic.code, "csv.header.mismatch");
357        assert!(
358            diagnostic
359                .message
360                .contains("CSV header does not match table schema")
361        );
362        assert_eq!(diagnostic.source.as_deref(), Some("tables/revenue.csv:1"));
363        assert_eq!(diagnostic.related, vec!["tables/revenue.schema.json"]);
364    }
365
366    #[test]
367    fn rejects_unresolved_table_anchor() {
368        let package = package_with(
369            "quarter,revenue_gbp\nQ1,125000.00\n",
370            schema(),
371            r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
372            chart_view(),
373            Some(":::table\ntable: missing\n:::\n"),
374        );
375
376        assert_validation_code(&package, "table.anchor.unresolved");
377    }
378
379    #[test]
380    fn rejects_unknown_view_column() {
381        let package = package_with(
382            "quarter,revenue_gbp\nQ1,125000.00\n",
383            schema(),
384            r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
385            chart_view(),
386            None,
387        );
388
389        assert_validation_code(&package, "view.column.unknown");
390    }
391
392    #[test]
393    fn rejects_unknown_chart_column() {
394        let package = package_with(
395            "quarter,revenue_gbp\nQ1,125000.00\n",
396            schema(),
397            r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
398            r#"{"id":"chart","table":"revenue","display":"chart","chart":{
399                "type":"bar",
400                "x":{"column":"quarter"},
401                "y":{"column":"missing"}
402            }}"#,
403            None,
404        );
405
406        assert_validation_code(&package, "chart.column.unknown");
407    }
408
409    #[test]
410    fn rejects_incompatible_chart_column_type() {
411        let package = package_with(
412            "quarter,revenue_gbp\nQ1,125000.00\n",
413            schema(),
414            r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
415            r#"{"id":"chart","table":"revenue","display":"chart","chart":{
416                "type":"bar",
417                "x":{"column":"revenue_gbp"},
418                "y":{"column":"quarter"}
419            }}"#,
420            None,
421        );
422
423        assert_validation_code(&package, "chart.column.type.incompatible");
424    }
425
426    #[test]
427    fn rejects_chart_anchor_referencing_non_chart_view() {
428        let package = package_with(
429            "quarter,revenue_gbp\nQ1,125000.00\n",
430            schema(),
431            r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
432            chart_view(),
433            Some(":::table\ntable: revenue\nview: default\ndisplay: chart\n:::\n"),
434        );
435
436        assert_validation_code(&package, "chart.view.not_chart");
437    }
438
439    #[test]
440    fn validates_foreign_key_references() {
441        let package = package_with_related_tables("c1,Alice\n", "o1,c1\n");
442
443        validate_package(&package).expect("foreign key should validate");
444    }
445
446    #[test]
447    fn rejects_unresolved_foreign_key_row() {
448        let package = package_with_related_tables("c1,Alice\n", "o1,missing\n");
449
450        assert_validation_code(&package, "csv.foreign_key.unresolved");
451    }
452
453    #[test]
454    fn rejects_foreign_key_referencing_non_primary_key() {
455        let package = McdPackage::from_bytes(&zip_bytes(&[
456            ("mimetype", crate::package::MCD_MIMETYPE),
457            ("manifest.json", related_manifest()),
458            ("content/main.md", "# Orders\n"),
459            ("tables/customers.csv", "customer_id,name\nc1,Alice\n"),
460            (
461                "tables/customers.schema.json",
462                r#"{"id":"customers","primaryKey":["customer_id"],"columns":[
463                    {"name":"customer_id","type":"string"},
464                    {"name":"name","type":"string"}
465                ]}"#,
466            ),
467            ("tables/orders.csv", "order_id,customer_id\no1,c1\n"),
468            (
469                "tables/orders.schema.json",
470                r#"{"id":"orders","primaryKey":["order_id"],"foreignKeys":[{
471                    "columns":["customer_id"],
472                    "references":{"table":"customers","columns":["name"]}
473                }],"columns":[
474                    {"name":"order_id","type":"string"},
475                    {"name":"customer_id","type":"string"}
476                ]}"#,
477            ),
478        ]))
479        .expect("package opens");
480
481        assert_validation_code(
482            &package,
483            "schema.foreign_key.references.primary_key.mismatch",
484        );
485    }
486
487    #[test]
488    fn validates_image_package() {
489        let package = image_package(
490            safe_svg(),
491            image_metadata(
492                "diagram",
493                r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
494            ),
495            ":::image\nasset: process-diagram\n:::\n",
496            r#""conformance":["MCD-Core","MCD-Images"]"#,
497        );
498
499        validate_package(&package).expect("valid image package");
500    }
501
502    #[test]
503    fn rejects_missing_image_asset() {
504        let package = image_package_without_asset(
505            image_metadata(
506                "diagram",
507                r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
508            ),
509            ":::image\nasset: process-diagram\n:::\n",
510            r#""conformance":["MCD-Core","MCD-Images"]"#,
511        );
512
513        assert_validation_code(&package, "asset.missing");
514    }
515
516    #[test]
517    fn rejects_missing_informative_image_alt() {
518        let package = image_package(
519            safe_svg(),
520            image_metadata(
521                "informative",
522                r#""caption":"Workflow diagram.","hash":null"#,
523            ),
524            ":::image\nasset: process-diagram\n:::\n",
525            r#""conformance":["MCD-Core","MCD-Images"]"#,
526        );
527
528        assert_validation_code(&package, "image.alt.missing");
529    }
530
531    #[test]
532    fn rejects_unresolved_image_anchor() {
533        let package = image_package(
534            safe_svg(),
535            image_metadata(
536                "diagram",
537                r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
538            ),
539            ":::image\nasset: missing-image\n:::\n",
540            r#""conformance":["MCD-Core","MCD-Images"]"#,
541        );
542
543        assert_validation_code(&package, "image.anchor.unresolved");
544    }
545
546    #[test]
547    fn rejects_svg_script_and_external_resource() {
548        let script_package = image_package(
549            r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
550            image_metadata(
551                "diagram",
552                r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
553            ),
554            ":::image\nasset: process-diagram\n:::\n",
555            r#""conformance":["MCD-Core","MCD-Images"]"#,
556        );
557        assert_validation_code(&script_package, "security.svg.active_content");
558
559        let external_package = image_package(
560            r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.com/x.png"/></svg>"#,
561            image_metadata(
562                "diagram",
563                r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
564            ),
565            ":::image\nasset: process-diagram\n:::\n",
566            r#""conformance":["MCD-Core","MCD-Images"]"#,
567        );
568        assert_validation_code(&external_package, "security.svg.external_reference");
569    }
570
571    #[test]
572    fn rejects_image_only_table_under_strict() {
573        let package = image_package(
574            safe_svg(),
575            r#"{
576                "id":"process-diagram",
577                "asset":"assets/process-diagram.svg",
578                "mediaType":"image/svg+xml",
579                "role":"diagram",
580                "alt":"Image of a table.",
581                "caption":"Table image.",
582                "meaningfulContent":{"tableData":true}
583            }"#
584            .to_owned(),
585            ":::image\nasset: process-diagram\n:::\n",
586            r#""conformance":["MCD-Core","MCD-Images","MCD-Strict"]"#,
587        );
588
589        assert_validation_code(&package, "image.meaningful_content.unlinked");
590    }
591
592    fn assert_validation_code(package: &McdPackage, code: &str) {
593        let err = validate_package(package).expect_err("package should be invalid");
594        assert_eq!(err.diagnostic().map(|d| d.code.as_str()), Some(code));
595    }
596
597    fn package_with(
598        csv: &str,
599        schema_json: &str,
600        table_view_json: &str,
601        chart_view_json: &str,
602        markdown: Option<&str>,
603    ) -> McdPackage {
604        let markdown = markdown.unwrap_or(
605            ":::table\ntable: revenue\nview: default\n:::\n\n:::table\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
606        );
607        McdPackage::from_bytes(&zip_bytes(&[
608            ("mimetype", crate::package::MCD_MIMETYPE),
609            ("manifest.json", manifest()),
610            ("content/main.md", markdown),
611            ("tables/revenue.csv", csv),
612            ("tables/revenue.schema.json", schema_json),
613            ("tables/revenue.view.json", table_view_json),
614            ("tables/revenue.chart.view.json", chart_view_json),
615        ]))
616        .expect("package opens")
617    }
618
619    fn image_package(
620        svg: &str,
621        image_json: String,
622        markdown: &str,
623        conformance: &str,
624    ) -> McdPackage {
625        McdPackage::from_bytes(&zip_bytes_owned(vec![
626            ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
627            ("manifest.json", image_manifest(conformance)),
628            ("content/main.md", markdown.to_owned()),
629            ("assets/process-diagram.svg", svg.to_owned()),
630            ("images/process-diagram.image.json", image_json),
631        ]))
632        .expect("package opens")
633    }
634
635    fn image_package_without_asset(
636        image_json: String,
637        markdown: &str,
638        conformance: &str,
639    ) -> McdPackage {
640        McdPackage::from_bytes(&zip_bytes_owned(vec![
641            ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
642            ("manifest.json", image_manifest(conformance)),
643            ("content/main.md", markdown.to_owned()),
644            ("images/process-diagram.image.json", image_json),
645        ]))
646        .expect("package opens")
647    }
648
649    fn image_manifest(conformance: &str) -> String {
650        format!(
651            r#"{{
652                "format":"MCD",
653                "version":"0.1",
654                "profile":"MCD-Core",
655                {conformance},
656                "entrypoint":"content/main.md",
657                "images":[{{"id":"process-diagram","metadata":"images/process-diagram.image.json"}}]
658            }}"#
659        )
660    }
661
662    fn image_metadata(role: &str, extra_fields: &str) -> String {
663        format!(
664            r#"{{
665                "id":"process-diagram",
666                "asset":"assets/process-diagram.svg",
667                "mediaType":"image/svg+xml",
668                "role":"{role}",
669                {extra_fields}
670            }}"#
671        )
672    }
673
674    fn safe_svg() -> &'static str {
675        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>"##
676    }
677
678    fn manifest() -> &'static str {
679        r#"{
680            "format":"MCD",
681            "version":"0.1",
682            "profile":"MCD-Core",
683            "entrypoint":"content/main.md",
684            "tables":[{
685                "id":"revenue",
686                "data":"tables/revenue.csv",
687                "schema":"tables/revenue.schema.json",
688                "views":{
689                    "default":"tables/revenue.view.json",
690                    "chart":"tables/revenue.chart.view.json"
691                }
692            }]
693        }"#
694    }
695
696    fn package_with_related_tables(customers_csv_rows: &str, orders_csv_rows: &str) -> McdPackage {
697        let customers_csv = format!("customer_id,name\n{customers_csv_rows}");
698        let orders_csv = format!("order_id,customer_id\n{orders_csv_rows}");
699        McdPackage::from_bytes(&zip_bytes_owned(vec![
700            ("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
701            ("manifest.json", related_manifest().to_owned()),
702            ("content/main.md", "# Orders\n".to_owned()),
703            ("tables/customers.csv", customers_csv),
704            (
705                "tables/customers.schema.json",
706                r#"{"id":"customers","primaryKey":["customer_id"],"columns":[
707                    {"name":"customer_id","type":"string"},
708                    {"name":"name","type":"string"}
709                ]}"#
710                .to_owned(),
711            ),
712            ("tables/orders.csv", orders_csv),
713            (
714                "tables/orders.schema.json",
715                r#"{"id":"orders","primaryKey":["order_id"],"foreignKeys":[{
716                    "columns":["customer_id"],
717                    "references":{"table":"customers","columns":["customer_id"]}
718                }],"columns":[
719                    {"name":"order_id","type":"string"},
720                    {"name":"customer_id","type":"string"}
721                ]}"#
722                .to_owned(),
723            ),
724        ]))
725        .expect("package opens")
726    }
727
728    fn related_manifest() -> &'static str {
729        r#"{
730            "format":"MCD",
731            "version":"0.1",
732            "profile":"MCD-Core",
733            "entrypoint":"content/main.md",
734            "tables":[
735                {"id":"customers","data":"tables/customers.csv","schema":"tables/customers.schema.json"},
736                {"id":"orders","data":"tables/orders.csv","schema":"tables/orders.schema.json"}
737            ]
738        }"#
739    }
740
741    fn schema() -> &'static str {
742        r#"{"id":"revenue","columns":[
743            {"name":"quarter","type":"string"},
744            {"name":"revenue_gbp","type":"decimal"}
745        ]}"#
746    }
747
748    fn chart_view() -> &'static str {
749        r#"{"id":"chart","table":"revenue","display":"chart","chart":{
750            "type":"bar",
751            "x":{"column":"quarter"},
752            "y":{"column":"revenue_gbp"}
753        }}"#
754    }
755
756    fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
757        let cursor = Cursor::new(Vec::new());
758        let mut writer = ZipWriter::new(cursor);
759        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
760
761        for (path, content) in entries {
762            writer.start_file(*path, options).expect("start file");
763            writer.write_all(content.as_bytes()).expect("write file");
764        }
765
766        writer.finish().expect("finish zip").into_inner()
767    }
768
769    fn zip_bytes_owned(entries: Vec<(&str, String)>) -> Vec<u8> {
770        let cursor = Cursor::new(Vec::new());
771        let mut writer = ZipWriter::new(cursor);
772        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
773
774        for (path, content) in entries {
775            writer.start_file(path, options).expect("start file");
776            writer.write_all(content.as_bytes()).expect("write file");
777        }
778
779        writer.finish().expect("finish zip").into_inner()
780    }
781}