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 = DependencyName::try_from(key.clone())
274                .map_err(|_| ManifestError::InvalidDependencyName(key))?;
275            if let Some((existing, _)) = deps.get_key_value(&name) {
276                return Err(ManifestError::DuplicateDependencyName(
277                    existing.manifest().to_string(),
278                    name.manifest().to_string(),
279                ));
280            }
281            deps.insert(name, value);
282        }
283
284        let exclude = fields
285            .exclude
286            .into_iter()
287            .map(|pattern| {
288                pattern
289                    .parse::<RelativePath>()
290                    .map_err(|source| ManifestError::InvalidExclude { pattern, source })
291            })
292            .collect::<Result<Vec<_>, _>>()?;
293
294        Ok(Self {
295            name: fields.name,
296            version: fields.version,
297            license,
298            authors: fields.authors,
299            description: fields.description,
300            repository: fields.repository,
301            homepage: fields.homepage,
302            entrypoint,
303            readme,
304            exclude,
305            tools: fields.tools,
306            dependencies: deps,
307            extra: fields.extra,
308        })
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn parse(s: &str) -> Result<Manifest, ManifestError> {
317        Manifest::parse(s.as_bytes())
318    }
319
320    #[test]
321    fn parses_minimal_manifest() {
322        let m = parse(
323            r#"{
324                "name": "spellbook",
325                "version": "1.2.0",
326                "license": "MIT"
327            }"#,
328        )
329        .unwrap();
330        assert_eq!(m.name, "spellbook");
331        assert_eq!(m.version.to_string(), "1.2.0");
332        assert_eq!(m.license.as_str(), "MIT");
333        assert!(m.authors.is_empty());
334        assert!(matches!(m.readme, Readme::Default));
335        assert_eq!(m.entrypoint_filename(), Path::new("index.wdl"));
336    }
337
338    #[test]
339    fn parses_full_example() {
340        let m = parse(
341            r#"{
342                "name": "spellbook",
343                "version": "1.2.0",
344                "license": "MIT OR Apache-2.0",
345                "authors": ["Jane Doe <jane.doe@example.com>"],
346                "description": "spellbook wrapper",
347                "repository": "https://github.com/openwdl/spellbook",
348                "homepage": "https://example.com",
349                "tools": [
350                    {
351                        "name": "spellcheck",
352                        "version": "2.0.1",
353                        "license": "MIT",
354                        "homepage": "https://example.com/sc"
355                    }
356                ],
357                "dependencies": {
358                    "common": {
359                        "git": "https://github.com/openwdl/common",
360                        "version": "^1.0.0"
361                    },
362                    "local_utils": { "path": "../utils" }
363                }
364            }"#,
365        )
366        .unwrap();
367        assert_eq!(m.tools.len(), 1);
368        assert_eq!(m.dependencies.len(), 2);
369    }
370
371    #[test]
372    fn parses_readme_disabled() {
373        let m = parse(
374            r#"{
375                "name": "spellbook",
376                "version": "1.0.0",
377                "license": "MIT",
378                "readme": false
379            }"#,
380        )
381        .unwrap();
382        assert!(matches!(m.readme, Readme::Disabled));
383    }
384
385    #[test]
386    fn parses_readme_path() {
387        let m = parse(
388            r#"{
389                "name": "spellbook",
390                "version": "1.0.0",
391                "license": "MIT",
392                "readme": "docs/README.md"
393            }"#,
394        )
395        .unwrap();
396        assert!(matches!(m.readme, Readme::Path(_)));
397    }
398
399    #[test]
400    fn captures_unknown_top_level_fields() {
401        let m = parse(
402            r#"{
403                "name": "spellbook",
404                "version": "1.0.0",
405                "license": "MIT",
406                "extra_field": 42,
407                "metadata": {"key": "value"}
408            }"#,
409        )
410        .unwrap();
411        assert!(m.extra.contains_key("extra_field"));
412        assert!(m.extra.contains_key("metadata"));
413    }
414
415    #[test]
416    fn rejects_empty_name() {
417        let err = parse(r#"{ "name": "", "version": "1.0.0", "license": "MIT" }"#).unwrap_err();
418        assert!(matches!(err, ManifestError::EmptyName));
419    }
420
421    #[test]
422    fn rejects_invalid_license() {
423        let err = parse(r#"{ "name": "spellbook", "version": "1.0.0", "license": "MIT-2.0" }"#)
424            .unwrap_err();
425        assert!(matches!(err, ManifestError::License(_)));
426    }
427
428    #[test]
429    fn rejects_absolute_entrypoint() {
430        let err = parse(
431            r#"{
432                "name": "spellbook",
433                "version": "1.0.0",
434                "license": "MIT",
435                "entrypoint": "/abs/path.wdl"
436            }"#,
437        )
438        .unwrap_err();
439        assert!(matches!(err, ManifestError::InvalidEntrypoint(_)));
440    }
441
442    #[test]
443    fn rejects_readme_true() {
444        let err = parse(
445            r#"{
446                "name": "spellbook",
447                "version": "1.0.0",
448                "license": "MIT",
449                "readme": true
450            }"#,
451        )
452        .unwrap_err();
453        assert!(matches!(err, ManifestError::ReadmeTrue));
454    }
455
456    #[test]
457    fn rejects_readme_null() {
458        let err = parse(
459            r#"{
460                "name": "spellbook",
461                "version": "1.0.0",
462                "license": "MIT",
463                "readme": null
464            }"#,
465        )
466        .unwrap_err();
467        assert!(matches!(err, ManifestError::InvalidJson(_)));
468    }
469
470    #[test]
471    fn parses_exclude_field() {
472        let m = parse(
473            r#"{
474                "name": "spellbook",
475                "version": "1.0.0",
476                "license": "MIT",
477                "exclude": ["internal/**", "scratch/*.wdl"]
478            }"#,
479        )
480        .unwrap();
481        assert_eq!(
482            m.exclude
483                .iter()
484                .map(RelativePath::as_str)
485                .collect::<Vec<_>>(),
486            vec!["internal/**", "scratch/*.wdl"]
487        );
488    }
489
490    #[test]
491    fn rejects_invalid_exclude_entry() {
492        let err = parse(
493            r#"{
494                "name": "spellbook",
495                "version": "1.0.0",
496                "license": "MIT",
497                "exclude": ["internal/**", "/abs/path"]
498            }"#,
499        )
500        .unwrap_err();
501        match err {
502            ManifestError::InvalidExclude { pattern, .. } => {
503                assert_eq!(pattern, "/abs/path");
504            }
505            other => panic!("expected `InvalidExclude` variant; got {other:?}"),
506        }
507    }
508
509    #[test]
510    fn rejects_parent_dir_in_readme() {
511        let err = parse(
512            r#"{
513                "name": "spellbook",
514                "version": "1.0.0",
515                "license": "MIT",
516                "readme": "../escape.md"
517            }"#,
518        )
519        .unwrap_err();
520        assert!(matches!(err, ManifestError::InvalidReadme(_)));
521    }
522
523    fn assert_duplicate_key_error(err: ManifestError) {
524        let inner = match err {
525            ManifestError::InvalidJson(e) => e.to_string(),
526            other => panic!("expected `InvalidJson` variant; got {other:?}"),
527        };
528        assert!(
529            inner.contains("duplicate object key"),
530            "wrong inner message: {inner}"
531        );
532    }
533
534    #[test]
535    fn rejects_duplicate_top_level_keys() {
536        assert_duplicate_key_error(
537            parse(
538                r#"{
539                    "name": "spellbook",
540                    "name": "duplicate",
541                    "version": "1.0.0",
542                    "license": "MIT"
543                }"#,
544            )
545            .unwrap_err(),
546        );
547    }
548
549    #[test]
550    fn rejects_duplicate_nested_keys() {
551        assert_duplicate_key_error(
552            parse(
553                r#"{
554                    "name": "spellbook",
555                    "version": "1.0.0",
556                    "license": "MIT",
557                    "tools": [
558                        {"name": "x", "name": "y", "version": "1", "license": "MIT"}
559                    ]
560                }"#,
561            )
562            .unwrap_err(),
563        );
564    }
565
566    #[test]
567    fn accepts_hyphenated_dep_key() {
568        let m = parse(
569            r#"{
570                "name": "spellbook",
571                "version": "1.0.0",
572                "license": "MIT",
573                "dependencies": { "my-dep": {"path": "../local"} }
574            }"#,
575        )
576        .unwrap();
577        let key: DependencyName = "my-dep".parse().unwrap();
578        assert!(m.dependencies.contains_key(&key));
579        assert_eq!(key.manifest(), "my-dep");
580        assert_eq!(key.identifier(), "my_dep");
581    }
582
583    #[test]
584    fn rejects_exact_duplicate_dep_keys() {
585        let err = parse(
586            r#"{
587                "name": "spellbook",
588                "version": "1.0.0",
589                "license": "MIT",
590                "dependencies": {
591                    "dep": {"path": "../a"},
592                    "dep": {"path": "../b"}
593                }
594            }"#,
595        )
596        .unwrap_err();
597        assert!(
598            matches!(err, ManifestError::InvalidJson(_)),
599            "exact duplicate JSON keys should be rejected by strict JSON parsing, got: {err}"
600        );
601    }
602
603    #[test]
604    fn rejects_duplicate_hyphen_underscore_dep_keys() {
605        let err = parse(
606            r#"{
607                "name": "spellbook",
608                "version": "1.0.0",
609                "license": "MIT",
610                "dependencies": {
611                    "spell-book": {"path": "../a"},
612                    "spell_book": {"path": "../b"}
613                }
614            }"#,
615        )
616        .unwrap_err();
617        assert!(
618            matches!(err, ManifestError::DuplicateDependencyName(..)),
619            "expected `DuplicateDependencyName`, got: {err}"
620        );
621    }
622
623    #[test]
624    fn rejects_non_identifier_dep_key() {
625        let err = parse(
626            r#"{
627                "name": "spellbook",
628                "version": "1.0.0",
629                "license": "MIT",
630                "dependencies": { "1bad": {"path": "../local"} }
631            }"#,
632        )
633        .unwrap_err();
634        assert!(matches!(err, ManifestError::InvalidDependencyName(_)));
635    }
636}