Skip to main content

mcd_core/
manifest.rs

1//! Manifest parsing and basic validation.
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    errors::{Diagnostic, McdError, Result},
8    package::validate_internal_path,
9};
10
11/// Root `manifest.json` model.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Manifest {
14    /// Format name. Must be `MCD`.
15    pub format: String,
16    /// Format version. Alpha currently supports `0.1`.
17    pub version: String,
18    /// Conformance profile.
19    pub profile: McdProfile,
20    /// Optional conformance claims.
21    #[serde(default)]
22    pub conformance: Vec<ConformanceClaim>,
23    /// Markdown entrypoint path.
24    pub entrypoint: String,
25    /// Optional title.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub title: Option<String>,
28    /// Optional encoding declaration.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub encoding: Option<String>,
31    /// Declared tables.
32    #[serde(default)]
33    pub tables: Vec<TableManifestEntry>,
34    /// Declared image metadata objects.
35    #[serde(default)]
36    pub images: Vec<ImageManifestEntry>,
37    /// Declared annotation metadata objects.
38    #[serde(default)]
39    pub annotations: Vec<AnnotationManifestEntry>,
40    /// Declared asset files or directories.
41    #[serde(default)]
42    pub assets: Vec<AssetManifestEntry>,
43    /// Declared external data resources that are intentionally not packaged.
44    #[serde(default, rename = "externalData")]
45    pub external_data: Vec<ExternalDataManifestEntry>,
46    /// Optional provenance metadata sidecar path.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub provenance: Option<String>,
49    /// Optional layout file paths.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub layout: Option<LayoutManifestEntry>,
52}
53
54impl Manifest {
55    /// Parse a manifest from JSON bytes and validate required basics.
56    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    /// Basic manifest validation that does not require package file existence checks.
63    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/// Supported MCD conformance profiles.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub enum McdProfile {
197    /// Semantic source profile.
198    #[serde(rename = "MCD-Core")]
199    Core,
200    /// Rendered profile.
201    #[serde(rename = "MCD-Rendered")]
202    Rendered,
203    /// Verified profile.
204    #[serde(rename = "MCD-Verified")]
205    Verified,
206    /// Signed profile.
207    #[serde(rename = "MCD-Signed")]
208    Signed,
209}
210
211/// Optional conformance claims.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub enum ConformanceClaim {
214    /// Core semantic conformance.
215    #[serde(rename = "MCD-Core")]
216    Core,
217    /// Image metadata conformance.
218    #[serde(rename = "MCD-Images")]
219    Images,
220    /// Chart conformance.
221    #[serde(rename = "MCD-Charts")]
222    Charts,
223    /// Strict machine-readable conformance.
224    #[serde(rename = "MCD-Strict")]
225    Strict,
226}
227
228/// Manifest declaration for a table.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct TableManifestEntry {
231    /// Stable table id.
232    pub id: String,
233    /// CSV data path.
234    pub data: String,
235    /// Table schema JSON path.
236    pub schema: String,
237    /// Named table views.
238    #[serde(default)]
239    pub views: IndexMap<String, String>,
240}
241
242/// Manifest declaration for an image metadata object.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct ImageManifestEntry {
245    /// Stable image id.
246    pub id: String,
247    /// Image metadata JSON path.
248    pub metadata: String,
249}
250
251/// Manifest declaration for an annotation metadata object.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct AnnotationManifestEntry {
254    /// Stable annotation id.
255    pub id: String,
256    /// Annotation metadata JSON path.
257    pub metadata: String,
258}
259
260/// Manifest declaration for an asset path or asset directory.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct AssetManifestEntry {
263    /// Optional stable asset id.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub id: Option<String>,
266    /// Asset file path or directory prefix.
267    pub path: String,
268}
269
270/// Manifest declaration for a large or external data resource.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct ExternalDataManifestEntry {
274    /// Stable external resource id.
275    pub id: String,
276    /// Absolute URI for the resource.
277    pub uri: String,
278    /// Declared media type, such as `text/csv` or `application/parquet`.
279    pub media_type: String,
280    /// Optional integrity hash for deterministic retrieval.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub hash: Option<String>,
283    /// Optional expected resource size in bytes.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub size_bytes: Option<u64>,
286    /// Optional human-readable description.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub description: Option<String>,
289    /// Optional access metadata.
290    #[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/// Access notes for an external data resource.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct ExternalDataAccess {
361    /// Whether access requires network IO.
362    #[serde(default)]
363    pub requires_network: bool,
364    /// Whether access requires credentials or authentication.
365    #[serde(default)]
366    pub requires_authentication: bool,
367    /// Optional human-readable access notes.
368    #[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/// Optional layout paths in the manifest.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct LayoutManifestEntry {
442    /// Styles JSON path.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub styles: Option<String>,
445    /// Page map JSON path.
446    #[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}