1use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 errors::{Diagnostic, McdError, Result},
8 package::validate_internal_path,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Manifest {
14 pub format: String,
16 pub version: String,
18 pub profile: McdProfile,
20 #[serde(default)]
22 pub conformance: Vec<ConformanceClaim>,
23 pub entrypoint: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub title: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub encoding: Option<String>,
31 #[serde(default)]
33 pub tables: Vec<TableManifestEntry>,
34 #[serde(default)]
36 pub images: Vec<ImageManifestEntry>,
37 #[serde(default)]
39 pub annotations: Vec<AnnotationManifestEntry>,
40 #[serde(default)]
42 pub assets: Vec<AssetManifestEntry>,
43 #[serde(default, rename = "externalData")]
45 pub external_data: Vec<ExternalDataManifestEntry>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub provenance: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub layout: Option<LayoutManifestEntry>,
52}
53
54impl Manifest {
55 pub fn from_slice(bytes: &[u8]) -> Result<Self> {
57 let manifest: Self = serde_json::from_slice(bytes)?;
58 manifest.validate_basic()?;
59 Ok(manifest)
60 }
61
62 pub fn validate_basic(&self) -> Result<()> {
64 if self.format != "MCD" {
65 return Err(McdError::from_diagnostic(
66 Diagnostic::error(
67 "manifest.format.unsupported",
68 "Manifest format must be MCD.",
69 )
70 .with_source("manifest.json"),
71 ));
72 }
73
74 if self.version != "0.1" {
75 return Err(McdError::from_diagnostic(
76 Diagnostic::error(
77 "manifest.version.unsupported",
78 "Manifest version must be 0.1 for this alpha parser.",
79 )
80 .with_source("manifest.json"),
81 ));
82 }
83
84 validate_manifest_path("manifest.entrypoint.invalid", &self.entrypoint)?;
85
86 let mut ids = std::collections::HashSet::new();
87 for table in &self.tables {
88 if table.id.trim().is_empty() {
89 return Err(McdError::from_diagnostic(
90 Diagnostic::error("manifest.table.id.empty", "Table id cannot be empty.")
91 .with_source("manifest.json"),
92 ));
93 }
94 if !ids.insert(table.id.clone()) {
95 return Err(McdError::from_diagnostic(
96 Diagnostic::error(
97 "manifest.table.id.duplicate",
98 format!("Duplicate table id '{}'.", table.id),
99 )
100 .with_source("manifest.json"),
101 ));
102 }
103 validate_manifest_path("manifest.table.data.invalid", &table.data)?;
104 validate_manifest_path("manifest.table.schema.invalid", &table.schema)?;
105 for path in table.views.values() {
106 validate_manifest_path("manifest.table.view.invalid", path)?;
107 }
108 }
109
110 let mut ids = std::collections::HashSet::new();
111 for image in &self.images {
112 if image.id.trim().is_empty() {
113 return Err(McdError::from_diagnostic(
114 Diagnostic::error("manifest.image.id.empty", "Image id cannot be empty.")
115 .with_source("manifest.json"),
116 ));
117 }
118 if !ids.insert(image.id.clone()) {
119 return Err(McdError::from_diagnostic(
120 Diagnostic::error(
121 "manifest.image.id.duplicate",
122 format!("Duplicate image id '{}'.", image.id),
123 )
124 .with_source("manifest.json"),
125 ));
126 }
127 validate_manifest_path("manifest.image.metadata.invalid", &image.metadata)?;
128 }
129
130 let mut ids = std::collections::HashSet::new();
131 for annotation in &self.annotations {
132 if annotation.id.trim().is_empty() {
133 return Err(McdError::from_diagnostic(
134 Diagnostic::error(
135 "manifest.annotation.id.empty",
136 "Annotation id cannot be empty.",
137 )
138 .with_source("manifest.json"),
139 ));
140 }
141 if !ids.insert(annotation.id.clone()) {
142 return Err(McdError::from_diagnostic(
143 Diagnostic::error(
144 "manifest.annotation.id.duplicate",
145 format!("Duplicate annotation id '{}'.", annotation.id),
146 )
147 .with_source("manifest.json"),
148 ));
149 }
150 validate_manifest_path("manifest.annotation.metadata.invalid", &annotation.metadata)?;
151 }
152
153 for asset in &self.assets {
154 validate_manifest_path("manifest.asset.path.invalid", &asset.path)?;
155 }
156
157 let mut ids = std::collections::HashSet::new();
158 for external_data in &self.external_data {
159 external_data.validate(&mut ids)?;
160 }
161
162 if let Some(provenance) = &self.provenance {
163 validate_manifest_path("manifest.provenance.invalid", provenance)?;
164 }
165
166 if let Some(layout) = &self.layout {
167 if let Some(styles) = &layout.styles {
168 validate_manifest_path("manifest.layout.styles.invalid", styles)?;
169 }
170 if let Some(page_map) = &layout.page_map {
171 validate_manifest_path("manifest.layout.page_map.invalid", page_map)?;
172 }
173 }
174
175 Ok(())
176 }
177}
178
179fn manifest_error(code: impl Into<String>, message: impl Into<String>) -> McdError {
180 McdError::from_diagnostic(
181 Diagnostic::error(code, message).with_source("manifest.json".to_owned()),
182 )
183}
184
185fn validate_manifest_path(code: &'static str, path: &str) -> Result<()> {
186 validate_internal_path(path).map(|_| ()).map_err(|_| {
187 McdError::from_diagnostic(
188 Diagnostic::error(code, format!("Invalid internal package path '{path}'."))
189 .with_source("manifest.json"),
190 )
191 })
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub enum McdProfile {
197 #[serde(rename = "MCD-Core")]
199 Core,
200 #[serde(rename = "MCD-Rendered")]
202 Rendered,
203 #[serde(rename = "MCD-Verified")]
205 Verified,
206 #[serde(rename = "MCD-Signed")]
208 Signed,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub enum ConformanceClaim {
214 #[serde(rename = "MCD-Core")]
216 Core,
217 #[serde(rename = "MCD-Images")]
219 Images,
220 #[serde(rename = "MCD-Charts")]
222 Charts,
223 #[serde(rename = "MCD-Strict")]
225 Strict,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct TableManifestEntry {
231 pub id: String,
233 pub data: String,
235 pub schema: String,
237 #[serde(default)]
239 pub views: IndexMap<String, String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct ImageManifestEntry {
245 pub id: String,
247 pub metadata: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct AnnotationManifestEntry {
254 pub id: String,
256 pub metadata: String,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct AssetManifestEntry {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub id: Option<String>,
266 pub path: String,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct ExternalDataManifestEntry {
274 pub id: String,
276 pub uri: String,
278 pub media_type: String,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub hash: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub size_bytes: Option<u64>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub description: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub access: Option<ExternalDataAccess>,
292}
293
294impl ExternalDataManifestEntry {
295 fn validate(&self, ids: &mut std::collections::HashSet<String>) -> Result<()> {
296 if self.id.trim().is_empty() {
297 return Err(manifest_error(
298 "manifest.external_data.id.empty",
299 "External data id cannot be empty.",
300 ));
301 }
302 if !ids.insert(self.id.clone()) {
303 return Err(manifest_error(
304 "manifest.external_data.id.duplicate",
305 format!("Duplicate external data id '{}'.", self.id),
306 ));
307 }
308 if !is_valid_id(&self.id) {
309 return Err(manifest_error(
310 "manifest.external_data.id.invalid",
311 format!("Invalid external data id '{}'.", self.id),
312 ));
313 }
314 if !is_supported_external_uri(&self.uri) {
315 return Err(manifest_error(
316 "manifest.external_data.uri.invalid",
317 format!(
318 "External data URI '{}' must be an absolute http, https, s3, gs, file, or ipfs URI.",
319 self.uri
320 ),
321 ));
322 }
323 if !is_media_type(&self.media_type) {
324 return Err(manifest_error(
325 "manifest.external_data.media_type.invalid",
326 format!(
327 "External data media type '{}' is not valid.",
328 self.media_type
329 ),
330 ));
331 }
332 if let Some(hash) = &self.hash
333 && !is_sha256(hash)
334 {
335 return Err(manifest_error(
336 "manifest.external_data.hash.invalid",
337 "External data hash must use sha256:<64 lowercase hex characters>.",
338 ));
339 }
340 if self
341 .description
342 .as_deref()
343 .is_some_and(|description| description.trim().is_empty())
344 {
345 return Err(manifest_error(
346 "manifest.external_data.description.empty",
347 "External data description cannot be empty.",
348 ));
349 }
350 if let Some(access) = &self.access {
351 access.validate()?;
352 }
353 Ok(())
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct ExternalDataAccess {
361 #[serde(default)]
363 pub requires_network: bool,
364 #[serde(default)]
366 pub requires_authentication: bool,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub notes: Option<String>,
370}
371
372impl ExternalDataAccess {
373 fn validate(&self) -> Result<()> {
374 if self
375 .notes
376 .as_deref()
377 .is_some_and(|notes| notes.trim().is_empty())
378 {
379 return Err(manifest_error(
380 "manifest.external_data.access.notes.empty",
381 "External data access notes cannot be empty.",
382 ));
383 }
384 Ok(())
385 }
386}
387
388pub(crate) fn is_valid_id(id: &str) -> bool {
389 let mut chars = id.chars();
390 let Some(first) = chars.next() else {
391 return false;
392 };
393 first.is_ascii_alphanumeric()
394 && chars.all(|character| {
395 character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-')
396 })
397}
398
399pub(crate) fn is_supported_external_uri(uri: &str) -> bool {
400 let uri = uri.trim();
401 if uri.is_empty() || uri.contains(char::is_whitespace) {
402 return false;
403 }
404 let Some((scheme, rest)) = uri.split_once(':') else {
405 return false;
406 };
407 if scheme.is_empty()
408 || !scheme.chars().all(|character| {
409 character.is_ascii_alphanumeric() || matches!(character, '+' | '.' | '-')
410 })
411 {
412 return false;
413 }
414 if !rest.starts_with("//") || rest.len() <= 2 {
415 return false;
416 }
417 matches!(scheme, "http" | "https" | "s3" | "gs" | "file" | "ipfs")
418}
419
420pub(crate) fn is_sha256(value: &str) -> bool {
421 value.strip_prefix("sha256:").is_some_and(|hex| {
422 hex.len() == 64
423 && hex
424 .chars()
425 .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase())
426 })
427}
428
429pub(crate) fn is_media_type(value: &str) -> bool {
430 let Some((kind, subtype)) = value.split_once('/') else {
431 return false;
432 };
433 !kind.is_empty()
434 && !subtype.is_empty()
435 && !kind.contains(char::is_whitespace)
436 && !subtype.contains(char::is_whitespace)
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct LayoutManifestEntry {
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub styles: Option<String>,
445 #[serde(default, rename = "pageMap", skip_serializing_if = "Option::is_none")]
447 pub page_map: Option<String>,
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn parses_minimal_manifest() {
456 let manifest = Manifest::from_slice(
457 br#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md"}"#,
458 )
459 .expect("manifest parses");
460
461 assert_eq!(manifest.entrypoint, "content/main.md");
462 assert!(manifest.tables.is_empty());
463 }
464
465 #[test]
466 fn rejects_duplicate_table_ids() {
467 let err = Manifest::from_slice(
468 br#"{
469 "format":"MCD",
470 "version":"0.1",
471 "profile":"MCD-Core",
472 "entrypoint":"content/main.md",
473 "tables":[
474 {"id":"revenue","data":"tables/a.csv","schema":"tables/a.schema.json"},
475 {"id":"revenue","data":"tables/b.csv","schema":"tables/b.schema.json"}
476 ]
477 }"#,
478 )
479 .expect_err("duplicate ids should fail");
480
481 assert_eq!(
482 err.diagnostic().map(|d| d.code.as_str()),
483 Some("manifest.table.id.duplicate")
484 );
485 }
486
487 #[test]
488 fn parses_external_data_references() {
489 let manifest = Manifest::from_slice(
490 br#"{
491 "format":"MCD",
492 "version":"0.1",
493 "profile":"MCD-Core",
494 "entrypoint":"content/main.md",
495 "externalData":[{
496 "id":"sensor-log",
497 "uri":"https://example.com/data/sensor-log.csv",
498 "mediaType":"text/csv",
499 "hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000",
500 "sizeBytes":128,
501 "description":"Canonical sensor log.",
502 "access":{
503 "requiresNetwork":true,
504 "requiresAuthentication":false,
505 "notes":"Public HTTPS dataset."
506 }
507 }]
508 }"#,
509 )
510 .expect("manifest parses");
511
512 assert_eq!(manifest.external_data.len(), 1);
513 assert_eq!(manifest.external_data[0].id, "sensor-log");
514 assert_eq!(manifest.external_data[0].media_type, "text/csv");
515 }
516
517 #[test]
518 fn rejects_invalid_external_data_references() {
519 let err = Manifest::from_slice(
520 br#"{
521 "format":"MCD",
522 "version":"0.1",
523 "profile":"MCD-Core",
524 "entrypoint":"content/main.md",
525 "externalData":[{
526 "id":"sensor-log",
527 "uri":"data/sensor-log.csv",
528 "mediaType":"text/csv"
529 }]
530 }"#,
531 )
532 .expect_err("relative URI should fail");
533
534 assert_eq!(
535 err.diagnostic().map(|d| d.code.as_str()),
536 Some("manifest.external_data.uri.invalid")
537 );
538
539 let err = Manifest::from_slice(
540 br#"{
541 "format":"MCD",
542 "version":"0.1",
543 "profile":"MCD-Core",
544 "entrypoint":"content/main.md",
545 "externalData":[
546 {"id":"sensor-log","uri":"https://example.com/a.csv","mediaType":"text/csv"},
547 {"id":"sensor-log","uri":"https://example.com/b.csv","mediaType":"text/csv"}
548 ]
549 }"#,
550 )
551 .expect_err("duplicate id should fail");
552
553 assert_eq!(
554 err.diagnostic().map(|d| d.code.as_str()),
555 Some("manifest.external_data.id.duplicate")
556 );
557 }
558
559 #[test]
560 fn parses_provenance_sidecar_path() {
561 let manifest = Manifest::from_slice(
562 br#"{
563 "format":"MCD",
564 "version":"0.1",
565 "profile":"MCD-Core",
566 "entrypoint":"content/main.md",
567 "provenance":"provenance/provenance.json"
568 }"#,
569 )
570 .expect("manifest parses");
571
572 assert_eq!(
573 manifest.provenance.as_deref(),
574 Some("provenance/provenance.json")
575 );
576 }
577
578 #[test]
579 fn rejects_invalid_provenance_sidecar_path() {
580 let err = Manifest::from_slice(
581 br#"{
582 "format":"MCD",
583 "version":"0.1",
584 "profile":"MCD-Core",
585 "entrypoint":"content/main.md",
586 "provenance":"../provenance.json"
587 }"#,
588 )
589 .expect_err("unsafe path should fail");
590
591 assert_eq!(
592 err.diagnostic().map(|d| d.code.as_str()),
593 Some("manifest.provenance.invalid")
594 );
595 }
596}