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