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