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