use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::{
McdPackage,
annotations::{load_manifest_annotations, validate_annotation_markers},
directives::TableDisplay,
document::{DocumentBlock, McdDocument, SourceSpan},
errors::{Diagnostic, McdError},
images::{load_manifest_images, validate_image_anchors},
provenance::load_manifest_provenance,
table_view::TableView,
tables::{DataTable, load_manifest_tables, row_key},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationResult {
pub valid: bool,
pub diagnostics: Vec<Diagnostic>,
}
impl ValidationResult {
#[must_use]
pub fn valid() -> Self {
Self {
valid: true,
diagnostics: Vec::new(),
}
}
}
pub fn validate_package(package: &McdPackage) -> crate::Result<ValidationResult> {
let manifest = package.manifest()?;
let document = McdDocument::from_package(package, &manifest)?;
let tables = load_manifest_tables(package, &manifest)?;
validate_foreign_keys(&tables)?;
let views = load_and_validate_views(package, &manifest, &tables)?;
validate_table_anchors(&document, &tables, &views)?;
let images = load_manifest_images(package, &manifest)?;
validate_image_anchors(&document, &images)?;
let annotations = load_manifest_annotations(package, &manifest, &document)?;
validate_annotation_markers(&document, &annotations)?;
load_manifest_provenance(package, &manifest)?;
Ok(ValidationResult::valid())
}
fn validate_foreign_keys(tables: &IndexMap<String, DataTable>) -> crate::Result<()> {
for table in tables.values() {
for foreign_key in &table.schema.foreign_keys {
let target = tables.get(&foreign_key.references.table).ok_or_else(|| {
McdError::from_diagnostic(
Diagnostic::error(
"schema.foreign_key.references.table.unresolved",
format!(
"Foreign key in table '{}' references unknown table '{}'.",
table.id, foreign_key.references.table
),
)
.with_source(table.source.clone()),
)
})?;
if foreign_key.references.columns != target.schema.primary_key {
return Err(McdError::from_diagnostic(
Diagnostic::error(
"schema.foreign_key.references.primary_key.mismatch",
format!(
"Foreign key in table '{}' must reference the primary key of table '{}'.",
table.id, target.id
),
)
.with_source(table.source.clone()),
));
}
for (local_column, target_column) in foreign_key
.columns
.iter()
.zip(foreign_key.references.columns.iter())
{
let local = table.schema.column(local_column).ok_or_else(|| {
McdError::from_diagnostic(
Diagnostic::error(
"schema.foreign_key.column.unknown",
format!(
"Foreign key in table '{}' references unknown local column '{}'.",
table.id, local_column
),
)
.with_source(table.source.clone()),
)
})?;
let target = target.schema.column(target_column).ok_or_else(|| {
McdError::from_diagnostic(
Diagnostic::error(
"schema.foreign_key.references.column.unknown",
format!(
"Foreign key in table '{}' references unknown column '{}' on table '{}'.",
table.id, target_column, foreign_key.references.table
),
)
.with_source(table.source.clone()),
)
})?;
if local.value_type != target.value_type {
return Err(McdError::from_diagnostic(
Diagnostic::error(
"schema.foreign_key.column.type_mismatch",
format!(
"Foreign key column '{}.{}' has type {:?}, but target '{}.{}' has type {:?}.",
table.id,
local_column,
local.value_type,
foreign_key.references.table,
target_column,
target.value_type
),
)
.with_source(table.source.clone()),
));
}
}
let target_keys = target
.rows
.iter()
.filter_map(|row| row_key(row, &foreign_key.references.columns))
.collect::<std::collections::HashSet<_>>();
for (row_index, row) in table.rows.iter().enumerate() {
let Some(key) = row_key(row, &foreign_key.columns) else {
continue;
};
if !target_keys.contains(&key) {
return Err(McdError::from_diagnostic(
Diagnostic::error(
"csv.foreign_key.unresolved",
format!(
"Foreign key in table '{}' references a missing row in table '{}'.",
table.id, foreign_key.references.table
),
)
.with_source(format!(
"{}:{}",
table.source,
row_index + 2
)),
));
}
}
}
}
Ok(())
}
fn load_and_validate_views(
package: &McdPackage,
manifest: &crate::Manifest,
tables: &IndexMap<String, DataTable>,
) -> crate::Result<IndexMap<String, IndexMap<String, TableView>>> {
let mut all_views = IndexMap::new();
for table_entry in &manifest.tables {
let table = tables.get(&table_entry.id).ok_or_else(|| {
McdError::from_diagnostic(
Diagnostic::error(
"table.internal.missing",
format!(
"Loaded table '{}' was not available for view validation.",
table_entry.id
),
)
.with_source("manifest.json"),
)
})?;
let mut table_views = IndexMap::new();
for (view_id, view_path) in &table_entry.views {
let view = TableView::from_package(package, view_path)?;
view.validate(view_id, &table_entry.id, &table.schema, view_path)?;
table_views.insert(view_id.clone(), view);
}
all_views.insert(table_entry.id.clone(), table_views);
}
Ok(all_views)
}
fn validate_table_anchors(
document: &McdDocument,
tables: &IndexMap<String, DataTable>,
views: &IndexMap<String, IndexMap<String, TableView>>,
) -> crate::Result<()> {
for block in &document.blocks {
let DocumentBlock::TableRef {
placement, source, ..
} = block
else {
continue;
};
if !tables.contains_key(&placement.table) {
return Err(anchor_error(
"table.anchor.unresolved",
format!(
"Table anchor references undeclared table '{}'.",
placement.table
),
document,
*source,
));
}
let Some(view_id) = &placement.view else {
if placement.display == TableDisplay::Chart {
return Err(anchor_error(
"chart.view.missing",
"Chart table anchors must reference a chart view.",
document,
*source,
));
}
continue;
};
let Some(table_views) = views.get(&placement.table) else {
return Err(anchor_error(
"view.anchor.unresolved",
format!(
"Table anchor references view '{}' but table '{}' declares no views.",
view_id, placement.table
),
document,
*source,
));
};
let Some(view) = table_views.get(view_id) else {
return Err(anchor_error(
if placement.display == TableDisplay::Chart {
"chart.view.unresolved"
} else {
"view.anchor.unresolved"
},
format!(
"Table anchor references unknown view '{}' for table '{}'.",
view_id, placement.table
),
document,
*source,
));
};
if placement.display == TableDisplay::Chart && view.display != TableDisplay::Chart {
return Err(anchor_error(
"chart.view.not_chart",
format!(
"Chart anchor references view '{}' but that view is not a chart view.",
view_id
),
document,
*source,
));
}
if placement.display == TableDisplay::Table && view.display != TableDisplay::Table {
return Err(anchor_error(
"view.display.mismatch",
format!(
"Table anchor references view '{}' but that view is a chart view.",
view_id
),
document,
*source,
));
}
if view.table != placement.table {
return Err(anchor_error(
"chart.table.mismatch",
format!(
"View '{}' references table '{}' but anchor references table '{}'.",
view_id, view.table, placement.table
),
document,
*source,
));
}
}
Ok(())
}
fn anchor_error(
code: impl Into<String>,
message: impl Into<String>,
document: &McdDocument,
source: Option<SourceSpan>,
) -> McdError {
let source = source
.map(|span| format!("{}:{span}", document.source_path))
.unwrap_or_else(|| document.source_path.clone());
McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Cursor, Write};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
#[test]
fn validates_table_and_chart_package() {
let package = package_with(
"quarter,revenue_gbp\nQ1,125000.00\n",
r#"{"id":"revenue","columns":[
{"name":"quarter","type":"string","nullable":false},
{"name":"revenue_gbp","type":"decimal","nullable":false}
]}"#,
r#"{"id":"default","table":"revenue","display":"table","columns":[{"name":"quarter"}]}"#,
r#"{"id":"chart","table":"revenue","display":"chart","chart":{
"type":"bar",
"x":{"column":"quarter"},
"y":{"column":"revenue_gbp"}
}}"#,
Some(
":::table\nref: t\ntable: revenue\nview: default\n:::\n\n:::table\nref: c\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
),
);
validate_package(&package).expect("valid package");
}
#[test]
fn rejects_csv_header_mismatch() {
let package = package_with(
"quarter,amount\nQ1,125000.00\n",
r#"{"id":"revenue","columns":[
{"name":"quarter","type":"string"},
{"name":"revenue_gbp","type":"decimal"}
]}"#,
r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
chart_view(),
None,
);
let err = validate_package(&package).expect_err("package should be invalid");
let diagnostic = err.diagnostic().expect("structured diagnostic");
assert_eq!(diagnostic.level, crate::errors::DiagnosticLevel::Error);
assert_eq!(diagnostic.code, "csv.header.mismatch");
assert!(
diagnostic
.message
.contains("CSV header does not match table schema")
);
assert_eq!(diagnostic.source.as_deref(), Some("tables/revenue.csv:1"));
assert_eq!(diagnostic.related, vec!["tables/revenue.schema.json"]);
}
#[test]
fn rejects_unresolved_table_anchor() {
let package = package_with(
"quarter,revenue_gbp\nQ1,125000.00\n",
schema(),
r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
chart_view(),
Some(":::table\ntable: missing\n:::\n"),
);
assert_validation_code(&package, "table.anchor.unresolved");
}
#[test]
fn rejects_unknown_view_column() {
let package = package_with(
"quarter,revenue_gbp\nQ1,125000.00\n",
schema(),
r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
chart_view(),
None,
);
assert_validation_code(&package, "view.column.unknown");
}
#[test]
fn rejects_unknown_chart_column() {
let package = package_with(
"quarter,revenue_gbp\nQ1,125000.00\n",
schema(),
r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
r#"{"id":"chart","table":"revenue","display":"chart","chart":{
"type":"bar",
"x":{"column":"quarter"},
"y":{"column":"missing"}
}}"#,
None,
);
assert_validation_code(&package, "chart.column.unknown");
}
#[test]
fn rejects_incompatible_chart_column_type() {
let package = package_with(
"quarter,revenue_gbp\nQ1,125000.00\n",
schema(),
r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
r#"{"id":"chart","table":"revenue","display":"chart","chart":{
"type":"bar",
"x":{"column":"revenue_gbp"},
"y":{"column":"quarter"}
}}"#,
None,
);
assert_validation_code(&package, "chart.column.type.incompatible");
}
#[test]
fn rejects_chart_anchor_referencing_non_chart_view() {
let package = package_with(
"quarter,revenue_gbp\nQ1,125000.00\n",
schema(),
r#"{"id":"default","table":"revenue","columns":[{"name":"quarter"}]}"#,
chart_view(),
Some(":::table\ntable: revenue\nview: default\ndisplay: chart\n:::\n"),
);
assert_validation_code(&package, "chart.view.not_chart");
}
#[test]
fn validates_foreign_key_references() {
let package = package_with_related_tables("c1,Alice\n", "o1,c1\n");
validate_package(&package).expect("foreign key should validate");
}
#[test]
fn rejects_unresolved_foreign_key_row() {
let package = package_with_related_tables("c1,Alice\n", "o1,missing\n");
assert_validation_code(&package, "csv.foreign_key.unresolved");
}
#[test]
fn rejects_foreign_key_referencing_non_primary_key() {
let package = McdPackage::from_bytes(&zip_bytes(&[
("mimetype", crate::package::MCD_MIMETYPE),
("manifest.json", related_manifest()),
("content/main.md", "# Orders\n"),
("tables/customers.csv", "customer_id,name\nc1,Alice\n"),
(
"tables/customers.schema.json",
r#"{"id":"customers","primaryKey":["customer_id"],"columns":[
{"name":"customer_id","type":"string"},
{"name":"name","type":"string"}
]}"#,
),
("tables/orders.csv", "order_id,customer_id\no1,c1\n"),
(
"tables/orders.schema.json",
r#"{"id":"orders","primaryKey":["order_id"],"foreignKeys":[{
"columns":["customer_id"],
"references":{"table":"customers","columns":["name"]}
}],"columns":[
{"name":"order_id","type":"string"},
{"name":"customer_id","type":"string"}
]}"#,
),
]))
.expect("package opens");
assert_validation_code(
&package,
"schema.foreign_key.references.primary_key.mismatch",
);
}
#[test]
fn validates_image_package() {
let package = image_package(
safe_svg(),
image_metadata(
"diagram",
r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
),
":::image\nasset: process-diagram\n:::\n",
r#""conformance":["MCD-Core","MCD-Images"]"#,
);
validate_package(&package).expect("valid image package");
}
#[test]
fn rejects_missing_image_asset() {
let package = image_package_without_asset(
image_metadata(
"diagram",
r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
),
":::image\nasset: process-diagram\n:::\n",
r#""conformance":["MCD-Core","MCD-Images"]"#,
);
assert_validation_code(&package, "asset.missing");
}
#[test]
fn rejects_missing_informative_image_alt() {
let package = image_package(
safe_svg(),
image_metadata(
"informative",
r#""caption":"Workflow diagram.","hash":null"#,
),
":::image\nasset: process-diagram\n:::\n",
r#""conformance":["MCD-Core","MCD-Images"]"#,
);
assert_validation_code(&package, "image.alt.missing");
}
#[test]
fn rejects_unresolved_image_anchor() {
let package = image_package(
safe_svg(),
image_metadata(
"diagram",
r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
),
":::image\nasset: missing-image\n:::\n",
r#""conformance":["MCD-Core","MCD-Images"]"#,
);
assert_validation_code(&package, "image.anchor.unresolved");
}
#[test]
fn rejects_svg_script_and_external_resource() {
let script_package = image_package(
r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
image_metadata(
"diagram",
r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
),
":::image\nasset: process-diagram\n:::\n",
r#""conformance":["MCD-Core","MCD-Images"]"#,
);
assert_validation_code(&script_package, "security.svg.active_content");
let external_package = image_package(
r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.com/x.png"/></svg>"#,
image_metadata(
"diagram",
r#""alt":"Workflow diagram","caption":"Workflow diagram.","hash":null"#,
),
":::image\nasset: process-diagram\n:::\n",
r#""conformance":["MCD-Core","MCD-Images"]"#,
);
assert_validation_code(&external_package, "security.svg.external_reference");
}
#[test]
fn rejects_image_only_table_under_strict() {
let package = image_package(
safe_svg(),
r#"{
"id":"process-diagram",
"asset":"assets/process-diagram.svg",
"mediaType":"image/svg+xml",
"role":"diagram",
"alt":"Image of a table.",
"caption":"Table image.",
"meaningfulContent":{"tableData":true}
}"#
.to_owned(),
":::image\nasset: process-diagram\n:::\n",
r#""conformance":["MCD-Core","MCD-Images","MCD-Strict"]"#,
);
assert_validation_code(&package, "image.meaningful_content.unlinked");
}
fn assert_validation_code(package: &McdPackage, code: &str) {
let err = validate_package(package).expect_err("package should be invalid");
assert_eq!(err.diagnostic().map(|d| d.code.as_str()), Some(code));
}
fn package_with(
csv: &str,
schema_json: &str,
table_view_json: &str,
chart_view_json: &str,
markdown: Option<&str>,
) -> McdPackage {
let markdown = markdown.unwrap_or(
":::table\ntable: revenue\nview: default\n:::\n\n:::table\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
);
McdPackage::from_bytes(&zip_bytes(&[
("mimetype", crate::package::MCD_MIMETYPE),
("manifest.json", manifest()),
("content/main.md", markdown),
("tables/revenue.csv", csv),
("tables/revenue.schema.json", schema_json),
("tables/revenue.view.json", table_view_json),
("tables/revenue.chart.view.json", chart_view_json),
]))
.expect("package opens")
}
fn image_package(
svg: &str,
image_json: String,
markdown: &str,
conformance: &str,
) -> McdPackage {
McdPackage::from_bytes(&zip_bytes_owned(vec![
("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
("manifest.json", image_manifest(conformance)),
("content/main.md", markdown.to_owned()),
("assets/process-diagram.svg", svg.to_owned()),
("images/process-diagram.image.json", image_json),
]))
.expect("package opens")
}
fn image_package_without_asset(
image_json: String,
markdown: &str,
conformance: &str,
) -> McdPackage {
McdPackage::from_bytes(&zip_bytes_owned(vec![
("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
("manifest.json", image_manifest(conformance)),
("content/main.md", markdown.to_owned()),
("images/process-diagram.image.json", image_json),
]))
.expect("package opens")
}
fn image_manifest(conformance: &str) -> String {
format!(
r#"{{
"format":"MCD",
"version":"0.1",
"profile":"MCD-Core",
{conformance},
"entrypoint":"content/main.md",
"images":[{{"id":"process-diagram","metadata":"images/process-diagram.image.json"}}]
}}"#
)
}
fn image_metadata(role: &str, extra_fields: &str) -> String {
format!(
r#"{{
"id":"process-diagram",
"asset":"assets/process-diagram.svg",
"mediaType":"image/svg+xml",
"role":"{role}",
{extra_fields}
}}"#
)
}
fn safe_svg() -> &'static str {
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>"##
}
fn manifest() -> &'static str {
r#"{
"format":"MCD",
"version":"0.1",
"profile":"MCD-Core",
"entrypoint":"content/main.md",
"tables":[{
"id":"revenue",
"data":"tables/revenue.csv",
"schema":"tables/revenue.schema.json",
"views":{
"default":"tables/revenue.view.json",
"chart":"tables/revenue.chart.view.json"
}
}]
}"#
}
fn package_with_related_tables(customers_csv_rows: &str, orders_csv_rows: &str) -> McdPackage {
let customers_csv = format!("customer_id,name\n{customers_csv_rows}");
let orders_csv = format!("order_id,customer_id\n{orders_csv_rows}");
McdPackage::from_bytes(&zip_bytes_owned(vec![
("mimetype", crate::package::MCD_MIMETYPE.to_owned()),
("manifest.json", related_manifest().to_owned()),
("content/main.md", "# Orders\n".to_owned()),
("tables/customers.csv", customers_csv),
(
"tables/customers.schema.json",
r#"{"id":"customers","primaryKey":["customer_id"],"columns":[
{"name":"customer_id","type":"string"},
{"name":"name","type":"string"}
]}"#
.to_owned(),
),
("tables/orders.csv", orders_csv),
(
"tables/orders.schema.json",
r#"{"id":"orders","primaryKey":["order_id"],"foreignKeys":[{
"columns":["customer_id"],
"references":{"table":"customers","columns":["customer_id"]}
}],"columns":[
{"name":"order_id","type":"string"},
{"name":"customer_id","type":"string"}
]}"#
.to_owned(),
),
]))
.expect("package opens")
}
fn related_manifest() -> &'static str {
r#"{
"format":"MCD",
"version":"0.1",
"profile":"MCD-Core",
"entrypoint":"content/main.md",
"tables":[
{"id":"customers","data":"tables/customers.csv","schema":"tables/customers.schema.json"},
{"id":"orders","data":"tables/orders.csv","schema":"tables/orders.schema.json"}
]
}"#
}
fn schema() -> &'static str {
r#"{"id":"revenue","columns":[
{"name":"quarter","type":"string"},
{"name":"revenue_gbp","type":"decimal"}
]}"#
}
fn chart_view() -> &'static str {
r#"{"id":"chart","table":"revenue","display":"chart","chart":{
"type":"bar",
"x":{"column":"quarter"},
"y":{"column":"revenue_gbp"}
}}"#
}
fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut writer = ZipWriter::new(cursor);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for (path, content) in entries {
writer.start_file(*path, options).expect("start file");
writer.write_all(content.as_bytes()).expect("write file");
}
writer.finish().expect("finish zip").into_inner()
}
fn zip_bytes_owned(entries: Vec<(&str, String)>) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut writer = ZipWriter::new(cursor);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for (path, content) in entries {
writer.start_file(path, options).expect("start file");
writer.write_all(content.as_bytes()).expect("write file");
}
writer.finish().expect("finish zip").into_inner()
}
}