Skip to main content

wdl_modules/
manifest.rs

1//! `module.json` manifest parsing and validation.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::path::PathBuf;
6
7use serde::Deserialize;
8use serde::Deserializer;
9use serde::Serialize;
10use thiserror::Error;
11use url::Url;
12
13use crate::DEFAULT_ENTRYPOINT_FILENAME;
14use crate::DEFAULT_README_FILENAME;
15use crate::dependency::DependencyName;
16use crate::dependency::DependencyNameError;
17use crate::dependency::DependencySource;
18use crate::dependency::DependencySourceError;
19use crate::license::LicenseError;
20use crate::license::LicenseExpression;
21use crate::relative_path::RelativePath;
22use crate::relative_path::RelativePathError;
23
24/// An error parsing a [`Manifest`].
25///
26/// Parsing is strict per the spec; trailing commas, comments, BOM, and
27/// duplicate object keys at any nesting depth are all rejected.
28#[derive(Debug, Error)]
29pub enum ManifestError {
30    /// The bytes did not parse as JSON.
31    #[error("invalid `module.json` JSON")]
32    InvalidJson(#[from] serde_json::Error),
33
34    /// The `name` field is empty.
35    #[error("`name` cannot be empty")]
36    EmptyName,
37
38    /// The `entrypoint` path failed relative-path validation.
39    #[error("`entrypoint` is invalid")]
40    InvalidEntrypoint(#[source] RelativePathError),
41
42    /// The `readme` path failed relative-path validation.
43    #[error("`readme` is invalid")]
44    InvalidReadme(#[source] RelativePathError),
45
46    /// An `exclude` entry failed relative-path validation.
47    #[error("`exclude` entry `{pattern}` is invalid")]
48    InvalidExclude {
49        /// The offending pattern as written in the manifest.
50        pattern: String,
51        /// The underlying validation error.
52        #[source]
53        source: RelativePathError,
54    },
55
56    /// The `readme` field was set to the literal `true`. The schema only
57    /// accepts a string, the literal `false`, or absence; `true` is
58    /// rejected with a dedicated message because it is a common authoring
59    /// mistake (mirroring `false` to mean "enable the default readme").
60    #[error("`readme` cannot be set to `true`; omit the field to use the default `README.md`")]
61    ReadmeTrue,
62
63    /// A dependency key is not a valid WDL identifier.
64    #[error("`dependencies` key `{name}` is not a valid WDL identifier")]
65    InvalidDependencyName {
66        /// The offending dependency key.
67        name: String,
68        /// Why the key is not a valid dependency name.
69        #[source]
70        source: DependencyNameError,
71    },
72
73    /// Two dependency keys resolve to the same dependency (either
74    /// identical or equivalent after hyphen-to-underscore normalization).
75    #[error("duplicate `dependencies` key: `{0}` and `{1}` resolve to the same dependency")]
76    DuplicateDependencyName(String, String),
77
78    /// A `tools[].ids` entry is not a valid CURIE.
79    #[error("tool identifier `{0}` is not a valid CURIE of the form `prefix:reference`")]
80    InvalidToolId(String),
81
82    /// A dependency declaration is invalid.
83    #[error(transparent)]
84    DependencySource(#[from] DependencySourceError),
85
86    /// The `license` field is not a valid SPDX expression.
87    #[error(transparent)]
88    License(#[from] LicenseError),
89
90    /// An I/O error occurred reading the manifest from disk.
91    #[error("failed to read `{path}`")]
92    Io {
93        /// The path that failed to read.
94        path: PathBuf,
95        /// The underlying I/O error.
96        #[source]
97        source: std::io::Error,
98    },
99}
100
101/// The `readme` field of a manifest.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub enum Readme {
104    /// The `readme` field was omitted; engines look for `README.md`.
105    Default,
106    /// The `readme` field is a relative path to a markdown file.
107    Path(RelativePath),
108    /// The `readme` field is the literal `false`; no readme is associated
109    /// with the module.
110    Disabled,
111}
112
113/// A `tools[]` entry.
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
115pub struct Tool {
116    /// The tool name.
117    pub name: String,
118    /// The tool version.
119    pub version: String,
120    /// The tool's SPDX license identifier.
121    pub license: LicenseExpression,
122    /// URL for the tool's homepage, documentation, repository, or
123    /// canonical project page.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub url: Option<Url>,
126    /// External identifiers for the tool, each a [CURIE](https://www.w3.org/TR/curie/)
127    /// of the form `prefix:reference` (e.g. `doi:10.21105/joss.04704`,
128    /// `biotools:csvkit`). Validated at parse time.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub ids: Vec<String>,
131    /// Unknown fields, preserved for round-trip and inspection by
132    /// downstream linters.
133    #[serde(flatten)]
134    pub extra: serde_json::Map<String, serde_json::Value>,
135}
136
137/// A parsed `module.json`.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct Manifest {
140    /// The module's display name. Not used for dependency resolution.
141    pub name: String,
142    /// The module's SPDX license expression.
143    pub license: LicenseExpression,
144    /// The author descriptions.
145    pub authors: Vec<String>,
146    /// A brief description of the module.
147    pub description: Option<String>,
148    /// The canonical Git URL for the module's source repository.
149    pub repository: Option<Url>,
150    /// A URL for the module's documentation or landing page.
151    pub homepage: Option<Url>,
152    /// The path to the module's entrypoint WDL file, relative to the
153    /// module root. Defaults to [`DEFAULT_ENTRYPOINT_FILENAME`] if absent.
154    pub entrypoint: Option<RelativePath>,
155    /// The module's readme.
156    pub readme: Readme,
157    /// Gitignore-style glob patterns identifying files within the module
158    /// that consumers may not reach via symbolic import. Each entry is a
159    /// validated [`RelativePath`]; absolute paths, `..` segments, and
160    /// other invalid forms are rejected at parse time. Has no effect on
161    /// content hashing, signing, validation, or quoted within-module
162    /// imports.
163    pub exclude: Vec<RelativePath>,
164    /// The upstream tools wrapped by the module.
165    pub tools: Vec<Tool>,
166    /// The module's dependencies, keyed by consumer-chosen name.
167    pub dependencies: BTreeMap<DependencyName, DependencySource>,
168    /// Unknown top-level fields. The spec requires implementations to
169    /// ignore unrecognized fields; capturing them here lets downstream
170    /// linters surface typos without a re-parse.
171    pub extra: serde_json::Map<String, serde_json::Value>,
172}
173
174impl Manifest {
175    /// Parses a `module.json` from raw bytes.
176    pub fn parse(bytes: &[u8]) -> Result<Self, ManifestError> {
177        let raw: ManifestFields = crate::strict_json::from_slice(bytes)?;
178        raw.try_into()
179    }
180
181    /// Returns the entrypoint filename, falling back to
182    /// [`DEFAULT_ENTRYPOINT_FILENAME`] when
183    /// [`entrypoint`](Self::entrypoint) is unset.
184    pub fn entrypoint_filename(&self) -> &Path {
185        self.entrypoint
186            .as_ref()
187            .map(RelativePath::as_path)
188            .unwrap_or(Path::new(DEFAULT_ENTRYPOINT_FILENAME))
189    }
190
191    /// Returns the readme filename, falling back to
192    /// [`DEFAULT_README_FILENAME`] when [`readme`](Self::readme) is
193    /// [`Readme::Default`], or `None` when it is [`Readme::Disabled`].
194    pub fn readme_filename(&self) -> Option<&Path> {
195        match &self.readme {
196            Readme::Default => Some(Path::new(DEFAULT_README_FILENAME)),
197            Readme::Path(path) => Some(path.as_path()),
198            Readme::Disabled => None,
199        }
200    }
201}
202
203/// Flat field set of a manifest, deserialized straight from JSON before
204/// post-deserialization validation projects it onto [`Manifest`].
205#[derive(Debug, Deserialize)]
206struct ManifestFields {
207    /// The module's display name.
208    name: String,
209    /// The module's SPDX license.
210    license: String,
211    /// The author descriptions.
212    #[serde(default)]
213    authors: Vec<String>,
214    /// A brief description of the module.
215    #[serde(default)]
216    description: Option<String>,
217    /// The canonical Git URL for the module's source repository.
218    #[serde(default)]
219    repository: Option<Url>,
220    /// A URL for the module's documentation or landing page.
221    #[serde(default)]
222    homepage: Option<Url>,
223    /// The path to the module's entrypoint WDL file.
224    #[serde(default)]
225    entrypoint: Option<PathBuf>,
226    /// The `readme` field, accepting a string, `false`, or absence.
227    #[serde(default, deserialize_with = "deserialize_readme")]
228    readme: ReadmeFields,
229    /// Gitignore-style glob patterns identifying files outside the public
230    /// import surface.
231    #[serde(default)]
232    exclude: Vec<String>,
233    /// The upstream tools.
234    #[serde(default)]
235    tools: Vec<Tool>,
236    /// The module's dependencies.
237    #[serde(default)]
238    dependencies: BTreeMap<String, DependencySource>,
239    /// Unknown top-level fields.
240    #[serde(flatten)]
241    extra: serde_json::Map<String, serde_json::Value>,
242}
243
244/// The `readme` field's JSON shape; one of a string, `false`, or absent.
245/// The values `null` and `true` are rejected at parse time.
246#[derive(Debug, Default)]
247enum ReadmeFields {
248    /// A relative path to a readme file.
249    Path(PathBuf),
250    /// The literal `false`, disabling the readme.
251    Bool(bool),
252    /// The field was absent.
253    #[default]
254    Default,
255}
256
257/// Returns true when `s` is a CURIE of the form `prefix:reference`,
258/// where the prefix matches `[A-Za-z_][A-Za-z0-9._-]*` and the reference
259/// is non-empty. Mirrors the pattern in the module manifest JSON schema.
260fn is_curie(s: &str) -> bool {
261    let Some((prefix, reference)) = s.split_once(':') else {
262        return false;
263    };
264    if reference.is_empty() {
265        return false;
266    }
267    let mut chars = prefix.chars();
268    match chars.next() {
269        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
270        _ => return false,
271    }
272    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
273}
274
275/// Deserializes the `readme` field, accepting `false` or a string path.
276fn deserialize_readme<'de, D>(deserializer: D) -> Result<ReadmeFields, D::Error>
277where
278    D: Deserializer<'de>,
279{
280    let value = serde_json::Value::deserialize(deserializer)?;
281    match value {
282        serde_json::Value::String(s) => Ok(ReadmeFields::Path(PathBuf::from(s))),
283        serde_json::Value::Bool(b) => Ok(ReadmeFields::Bool(b)),
284        serde_json::Value::Null => Err(serde::de::Error::custom("`readme` cannot be null")),
285        other => Err(serde::de::Error::custom(format!(
286            "`readme` must be a string or `false`; got {other}"
287        ))),
288    }
289}
290
291impl TryFrom<ManifestFields> for Manifest {
292    type Error = ManifestError;
293
294    fn try_from(fields: ManifestFields) -> Result<Self, Self::Error> {
295        if fields.name.is_empty() {
296            return Err(ManifestError::EmptyName);
297        }
298
299        let license = fields.license.parse::<LicenseExpression>()?;
300
301        let entrypoint = fields
302            .entrypoint
303            .as_deref()
304            .map(RelativePath::try_from)
305            .transpose()
306            .map_err(ManifestError::InvalidEntrypoint)?;
307
308        let readme = match fields.readme {
309            ReadmeFields::Default => Readme::Default,
310            ReadmeFields::Path(p) => Readme::Path(
311                RelativePath::try_from(p.as_path()).map_err(ManifestError::InvalidReadme)?,
312            ),
313            ReadmeFields::Bool(false) => Readme::Disabled,
314            ReadmeFields::Bool(true) => return Err(ManifestError::ReadmeTrue),
315        };
316
317        let mut deps: BTreeMap<DependencyName, DependencySource> = BTreeMap::new();
318        for (key, value) in fields.dependencies {
319            let name = key
320                .parse()
321                .map_err(|source| ManifestError::InvalidDependencyName {
322                    name: key.clone(),
323                    source,
324                })?;
325            if let Some((existing, _)) = deps.get_key_value(&name) {
326                return Err(ManifestError::DuplicateDependencyName(
327                    existing.manifest().to_string(),
328                    name.manifest().to_string(),
329                ));
330            }
331            deps.insert(name, value);
332        }
333
334        let exclude = fields
335            .exclude
336            .into_iter()
337            .map(|pattern| {
338                pattern
339                    .parse::<RelativePath>()
340                    .map_err(|source| ManifestError::InvalidExclude { pattern, source })
341            })
342            .collect::<Result<Vec<_>, _>>()?;
343
344        for tool in &fields.tools {
345            for id in &tool.ids {
346                if !is_curie(id) {
347                    return Err(ManifestError::InvalidToolId(id.clone()));
348                }
349            }
350        }
351
352        Ok(Self {
353            name: fields.name,
354            license,
355            authors: fields.authors,
356            description: fields.description,
357            repository: fields.repository,
358            homepage: fields.homepage,
359            entrypoint,
360            readme,
361            exclude,
362            tools: fields.tools,
363            dependencies: deps,
364            extra: fields.extra,
365        })
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn parse(s: &str) -> Result<Manifest, ManifestError> {
374        Manifest::parse(s.as_bytes())
375    }
376
377    #[test]
378    fn parses_minimal_manifest() {
379        let m = parse(
380            r#"{
381                "name": "spellbook",
382                "license": "MIT"
383            }"#,
384        )
385        .unwrap();
386        assert_eq!(m.name, "spellbook");
387        assert_eq!(m.license.as_str(), "MIT");
388        assert!(m.authors.is_empty());
389        assert!(matches!(m.readme, Readme::Default));
390        assert_eq!(m.entrypoint_filename(), Path::new("index.wdl"));
391    }
392
393    #[test]
394    fn parses_full_example() {
395        let m = parse(
396            r#"{
397                "name": "spellbook",
398                "license": "MIT OR Apache-2.0",
399                "authors": ["Jane Doe <jane.doe@example.com>"],
400                "description": "spellbook wrapper",
401                "repository": "https://github.com/openwdl/spellbook",
402                "homepage": "https://example.com",
403                "tools": [
404                    {
405                        "name": "spellcheck",
406                        "version": "2.0.1",
407                        "license": "MIT",
408                        "url": "https://example.com/sc",
409                        "ids": ["doi:10.21105/joss.04704", "biotools:csvkit"]
410                    }
411                ],
412                "dependencies": {
413                    "common": {
414                        "git": "https://github.com/openwdl/common",
415                        "version": "^1.0.0"
416                    },
417                    "local_utils": { "path": "../utils" }
418                }
419            }"#,
420        )
421        .unwrap();
422        assert_eq!(m.tools.len(), 1);
423        assert_eq!(
424            m.tools[0].url.as_ref().unwrap().as_str(),
425            "https://example.com/sc"
426        );
427        assert_eq!(
428            m.tools[0].ids,
429            ["doi:10.21105/joss.04704", "biotools:csvkit"]
430        );
431        assert_eq!(m.dependencies.len(), 2);
432    }
433
434    #[test]
435    fn rejects_non_curie_tool_id() {
436        let err = parse(
437            r#"{
438                "name": "spellbook",
439                "license": "MIT",
440                "tools": [
441                    {
442                        "name": "spellcheck",
443                        "version": "2.0.1",
444                        "license": "MIT",
445                        "ids": ["not a curie"]
446                    }
447                ]
448            }"#,
449        )
450        .unwrap_err();
451        assert!(
452            matches!(&err, ManifestError::InvalidToolId(id) if id == "not a curie"),
453            "expected `InvalidToolId`, got: {err}"
454        );
455    }
456
457    #[test]
458    fn accepts_various_curie_prefixes() {
459        assert!(is_curie("doi:10.21105/joss.04704"));
460        assert!(is_curie("biotools:csvkit"));
461        assert!(is_curie("_local:x"));
462        assert!(is_curie("a.b-c_d:ref"));
463        assert!(!is_curie("nocolon"));
464        assert!(!is_curie(":noprefix"));
465        assert!(!is_curie("prefix:"));
466        assert!(!is_curie("1bad:ref"));
467    }
468
469    #[test]
470    fn parses_readme_disabled() {
471        let m = parse(
472            r#"{
473                "name": "spellbook",
474                "license": "MIT",
475                "readme": false
476            }"#,
477        )
478        .unwrap();
479        assert!(matches!(m.readme, Readme::Disabled));
480    }
481
482    #[test]
483    fn parses_readme_path() {
484        let m = parse(
485            r#"{
486                "name": "spellbook",
487                "license": "MIT",
488                "readme": "docs/README.md"
489            }"#,
490        )
491        .unwrap();
492        assert!(matches!(m.readme, Readme::Path(_)));
493    }
494
495    #[test]
496    fn resolves_default_readme_filename() -> Result<(), ManifestError> {
497        let manifest = parse(r#"{"name":"spellbook","license":"MIT"}"#)?;
498        assert_eq!(
499            manifest.readme_filename(),
500            Some(Path::new(crate::DEFAULT_README_FILENAME))
501        );
502        Ok(())
503    }
504
505    #[test]
506    fn resolves_custom_readme_filename() -> Result<(), ManifestError> {
507        let manifest = parse(
508            r#"{
509                "name": "spellbook",
510                "license": "MIT",
511                "readme": "docs/guide.md"
512            }"#,
513        )?;
514        assert_eq!(manifest.readme_filename(), Some(Path::new("docs/guide.md")));
515        Ok(())
516    }
517
518    #[test]
519    fn disabled_readme_has_no_filename() -> Result<(), ManifestError> {
520        let manifest = parse(
521            r#"{
522                "name": "spellbook",
523                "license": "MIT",
524                "readme": false
525            }"#,
526        )?;
527        assert_eq!(manifest.readme_filename(), None);
528        Ok(())
529    }
530
531    #[test]
532    fn captures_unknown_top_level_fields() {
533        let m = parse(
534            r#"{
535                "name": "spellbook",
536                "license": "MIT",
537                "extra_field": 42,
538                "metadata": {"key": "value"}
539            }"#,
540        )
541        .unwrap();
542        assert!(m.extra.contains_key("extra_field"));
543        assert!(m.extra.contains_key("metadata"));
544    }
545
546    #[test]
547    fn rejects_empty_name() {
548        let err = parse(r#"{ "name": "", "license": "MIT" }"#).unwrap_err();
549        assert!(matches!(err, ManifestError::EmptyName));
550    }
551
552    #[test]
553    fn rejects_invalid_license() {
554        let err = parse(r#"{ "name": "spellbook", "license": "MIT-2.0" }"#).unwrap_err();
555        assert!(matches!(err, ManifestError::License(_)));
556    }
557
558    #[test]
559    fn rejects_absolute_entrypoint() {
560        let err = parse(
561            r#"{
562                "name": "spellbook",
563                "license": "MIT",
564                "entrypoint": "/abs/path.wdl"
565            }"#,
566        )
567        .unwrap_err();
568        assert!(matches!(err, ManifestError::InvalidEntrypoint(_)));
569    }
570
571    #[test]
572    fn rejects_readme_true() {
573        let err = parse(
574            r#"{
575                "name": "spellbook",
576                "license": "MIT",
577                "readme": true
578            }"#,
579        )
580        .unwrap_err();
581        assert!(matches!(err, ManifestError::ReadmeTrue));
582    }
583
584    #[test]
585    fn rejects_readme_null() {
586        let err = parse(
587            r#"{
588                "name": "spellbook",
589                "license": "MIT",
590                "readme": null
591            }"#,
592        )
593        .unwrap_err();
594        assert!(matches!(err, ManifestError::InvalidJson(_)));
595    }
596
597    #[test]
598    fn parses_exclude_field() {
599        let m = parse(
600            r#"{
601                "name": "spellbook",
602                "license": "MIT",
603                "exclude": ["internal/**", "scratch/*.wdl"]
604            }"#,
605        )
606        .unwrap();
607        assert_eq!(
608            m.exclude
609                .iter()
610                .map(RelativePath::as_str)
611                .collect::<Vec<_>>(),
612            vec!["internal/**", "scratch/*.wdl"]
613        );
614    }
615
616    #[test]
617    fn rejects_invalid_exclude_entry() {
618        let err = parse(
619            r#"{
620                "name": "spellbook",
621                "license": "MIT",
622                "exclude": ["internal/**", "/abs/path"]
623            }"#,
624        )
625        .unwrap_err();
626        match err {
627            ManifestError::InvalidExclude { pattern, .. } => {
628                assert_eq!(pattern, "/abs/path");
629            }
630            other => panic!("expected `InvalidExclude` variant; got {other:?}"),
631        }
632    }
633
634    #[test]
635    fn rejects_parent_dir_in_readme() {
636        let err = parse(
637            r#"{
638                "name": "spellbook",
639                "license": "MIT",
640                "readme": "../escape.md"
641            }"#,
642        )
643        .unwrap_err();
644        assert!(matches!(err, ManifestError::InvalidReadme(_)));
645    }
646
647    fn assert_duplicate_key_error(err: ManifestError) {
648        let inner = match err {
649            ManifestError::InvalidJson(e) => e.to_string(),
650            other => panic!("expected `InvalidJson` variant; got {other:?}"),
651        };
652        assert!(
653            inner.contains("duplicate object key"),
654            "wrong inner message: {inner}"
655        );
656    }
657
658    #[test]
659    fn rejects_duplicate_top_level_keys() {
660        assert_duplicate_key_error(
661            parse(
662                r#"{
663                    "name": "spellbook",
664                    "name": "duplicate",
665                    "license": "MIT"
666                }"#,
667            )
668            .unwrap_err(),
669        );
670    }
671
672    #[test]
673    fn rejects_duplicate_nested_keys() {
674        assert_duplicate_key_error(
675            parse(
676                r#"{
677                    "name": "spellbook",
678                    "license": "MIT",
679                    "tools": [
680                        {"name": "x", "name": "y", "version": "1", "license": "MIT"}
681                    ]
682                }"#,
683            )
684            .unwrap_err(),
685        );
686    }
687
688    #[test]
689    fn accepts_hyphenated_dep_key() {
690        let m = parse(
691            r#"{
692                "name": "spellbook",
693                "license": "MIT",
694                "dependencies": { "my-dep": {"path": "../local"} }
695            }"#,
696        )
697        .unwrap();
698        let key: DependencyName = "my-dep".parse().unwrap();
699        assert!(m.dependencies.contains_key(&key));
700        assert_eq!(key.manifest(), "my-dep");
701        assert_eq!(key.identifier(), "my_dep");
702    }
703
704    #[test]
705    fn rejects_exact_duplicate_dep_keys() {
706        let err = parse(
707            r#"{
708                "name": "spellbook",
709                "license": "MIT",
710                "dependencies": {
711                    "dep": {"path": "../a"},
712                    "dep": {"path": "../b"}
713                }
714            }"#,
715        )
716        .unwrap_err();
717        assert!(
718            matches!(err, ManifestError::InvalidJson(_)),
719            "exact duplicate JSON keys should be rejected by strict JSON parsing, got: {err}"
720        );
721    }
722
723    #[test]
724    fn rejects_duplicate_hyphen_underscore_dep_keys() {
725        let err = parse(
726            r#"{
727                "name": "spellbook",
728                "license": "MIT",
729                "dependencies": {
730                    "spell-book": {"path": "../a"},
731                    "spell_book": {"path": "../b"}
732                }
733            }"#,
734        )
735        .unwrap_err();
736        assert!(
737            matches!(err, ManifestError::DuplicateDependencyName(..)),
738            "expected `DuplicateDependencyName`, got: {err}"
739        );
740    }
741
742    #[test]
743    fn rejects_non_identifier_dep_key() {
744        let err = parse(
745            r#"{
746                "name": "spellbook",
747                "license": "MIT",
748                "dependencies": { "1bad": {"path": "../local"} }
749            }"#,
750        )
751        .unwrap_err();
752        assert!(matches!(err, ManifestError::InvalidDependencyName { .. }));
753    }
754}