1use 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#[derive(Debug, Error)]
28pub enum ManifestError {
29 #[error("invalid `module.json` JSON")]
31 InvalidJson(#[from] serde_json::Error),
32
33 #[error("`name` cannot be empty")]
35 EmptyName,
36
37 #[error("`entrypoint` is invalid")]
39 InvalidEntrypoint(#[source] RelativePathError),
40
41 #[error("`readme` is invalid")]
43 InvalidReadme(#[source] RelativePathError),
44
45 #[error("`exclude` entry `{pattern}` is invalid")]
47 InvalidExclude {
48 pattern: String,
50 #[source]
52 source: RelativePathError,
53 },
54
55 #[error("`readme` cannot be set to `true`; omit the field to use the default `README.md`")]
60 ReadmeTrue,
61
62 #[error("`dependencies` key `{0}` is not a valid WDL identifier")]
64 InvalidDependencyName(String),
65
66 #[error("duplicate `dependencies` key: `{0}` and `{1}` resolve to the same dependency")]
69 DuplicateDependencyName(String, String),
70
71 #[error(transparent)]
73 DependencySource(#[from] DependencySourceError),
74
75 #[error(transparent)]
77 License(#[from] LicenseError),
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
82pub enum Readme {
83 Default,
85 Path(RelativePath),
87 Disabled,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Tool {
95 pub name: String,
97 pub version: String,
99 pub license: LicenseExpression,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub homepage: Option<Url>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub doi: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub biotools: Option<String>,
110 #[serde(flatten)]
113 pub extra: serde_json::Map<String, serde_json::Value>,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct Manifest {
119 pub name: String,
121 pub version: Version,
123 pub license: LicenseExpression,
125 pub authors: Vec<String>,
127 pub description: Option<String>,
129 pub repository: Option<Url>,
131 pub homepage: Option<Url>,
133 pub entrypoint: Option<RelativePath>,
136 pub readme: Readme,
138 pub exclude: Vec<RelativePath>,
145 pub tools: Vec<Tool>,
147 pub dependencies: BTreeMap<DependencyName, DependencySource>,
149 pub extra: serde_json::Map<String, serde_json::Value>,
153}
154
155impl Manifest {
156 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 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#[derive(Debug, Deserialize)]
176struct ManifestFields {
177 name: String,
179 version: Version,
181 license: String,
183 #[serde(default)]
185 authors: Vec<String>,
186 #[serde(default)]
188 description: Option<String>,
189 #[serde(default)]
191 repository: Option<Url>,
192 #[serde(default)]
194 homepage: Option<Url>,
195 #[serde(default)]
197 entrypoint: Option<PathBuf>,
198 #[serde(default, deserialize_with = "deserialize_readme")]
200 readme: ReadmeFields,
201 #[serde(default)]
204 exclude: Vec<String>,
205 #[serde(default)]
207 tools: Vec<Tool>,
208 #[serde(default)]
210 dependencies: BTreeMap<String, DependencySource>,
211 #[serde(flatten)]
213 extra: serde_json::Map<String, serde_json::Value>,
214}
215
216#[derive(Debug, Default)]
219enum ReadmeFields {
220 Path(PathBuf),
222 Bool(bool),
224 #[default]
226 Default,
227}
228
229fn 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}