1use std::collections::BTreeMap;
4use std::path::Path;
5use std::path::PathBuf;
6
7use serde::Deserialize;
8use serde::Deserializer;
9use serde::Serialize;
10use thiserror::Error;
11use url::Url;
12
13use crate::DEFAULT_ENTRYPOINT_FILENAME;
14use crate::DEFAULT_README_FILENAME;
15use crate::dependency::DependencyName;
16use crate::dependency::DependencyNameError;
17use crate::dependency::DependencySource;
18use crate::dependency::DependencySourceError;
19use crate::license::LicenseError;
20use crate::license::LicenseExpression;
21use crate::relative_path::RelativePath;
22use crate::relative_path::RelativePathError;
23
24#[derive(Debug, Error)]
29pub enum ManifestError {
30 #[error("invalid `module.json` JSON")]
32 InvalidJson(#[from] serde_json::Error),
33
34 #[error("`name` cannot be empty")]
36 EmptyName,
37
38 #[error("`entrypoint` is invalid")]
40 InvalidEntrypoint(#[source] RelativePathError),
41
42 #[error("`readme` is invalid")]
44 InvalidReadme(#[source] RelativePathError),
45
46 #[error("`exclude` entry `{pattern}` is invalid")]
48 InvalidExclude {
49 pattern: String,
51 #[source]
53 source: RelativePathError,
54 },
55
56 #[error("`readme` cannot be set to `true`; omit the field to use the default `README.md`")]
61 ReadmeTrue,
62
63 #[error("`dependencies` key `{name}` is not a valid WDL identifier")]
65 InvalidDependencyName {
66 name: String,
68 #[source]
70 source: DependencyNameError,
71 },
72
73 #[error("duplicate `dependencies` key: `{0}` and `{1}` resolve to the same dependency")]
76 DuplicateDependencyName(String, String),
77
78 #[error("tool identifier `{0}` is not a valid CURIE of the form `prefix:reference`")]
80 InvalidToolId(String),
81
82 #[error(transparent)]
84 DependencySource(#[from] DependencySourceError),
85
86 #[error(transparent)]
88 License(#[from] LicenseError),
89
90 #[error("failed to read `{path}`")]
92 Io {
93 path: PathBuf,
95 #[source]
97 source: std::io::Error,
98 },
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
103pub enum Readme {
104 Default,
106 Path(RelativePath),
108 Disabled,
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
115pub struct Tool {
116 pub name: String,
118 pub version: String,
120 pub license: LicenseExpression,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub url: Option<Url>,
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub ids: Vec<String>,
131 #[serde(flatten)]
134 pub extra: serde_json::Map<String, serde_json::Value>,
135}
136
137#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct Manifest {
140 pub name: String,
142 pub license: LicenseExpression,
144 pub authors: Vec<String>,
146 pub description: Option<String>,
148 pub repository: Option<Url>,
150 pub homepage: Option<Url>,
152 pub entrypoint: Option<RelativePath>,
155 pub readme: Readme,
157 pub exclude: Vec<RelativePath>,
164 pub tools: Vec<Tool>,
166 pub dependencies: BTreeMap<DependencyName, DependencySource>,
168 pub extra: serde_json::Map<String, serde_json::Value>,
172}
173
174impl Manifest {
175 pub fn parse(bytes: &[u8]) -> Result<Self, ManifestError> {
177 let raw: ManifestFields = crate::strict_json::from_slice(bytes)?;
178 raw.try_into()
179 }
180
181 pub fn entrypoint_filename(&self) -> &Path {
185 self.entrypoint
186 .as_ref()
187 .map(RelativePath::as_path)
188 .unwrap_or(Path::new(DEFAULT_ENTRYPOINT_FILENAME))
189 }
190
191 pub fn readme_filename(&self) -> Option<&Path> {
195 match &self.readme {
196 Readme::Default => Some(Path::new(DEFAULT_README_FILENAME)),
197 Readme::Path(path) => Some(path.as_path()),
198 Readme::Disabled => None,
199 }
200 }
201}
202
203#[derive(Debug, Deserialize)]
206struct ManifestFields {
207 name: String,
209 license: String,
211 #[serde(default)]
213 authors: Vec<String>,
214 #[serde(default)]
216 description: Option<String>,
217 #[serde(default)]
219 repository: Option<Url>,
220 #[serde(default)]
222 homepage: Option<Url>,
223 #[serde(default)]
225 entrypoint: Option<PathBuf>,
226 #[serde(default, deserialize_with = "deserialize_readme")]
228 readme: ReadmeFields,
229 #[serde(default)]
232 exclude: Vec<String>,
233 #[serde(default)]
235 tools: Vec<Tool>,
236 #[serde(default)]
238 dependencies: BTreeMap<String, DependencySource>,
239 #[serde(flatten)]
241 extra: serde_json::Map<String, serde_json::Value>,
242}
243
244#[derive(Debug, Default)]
247enum ReadmeFields {
248 Path(PathBuf),
250 Bool(bool),
252 #[default]
254 Default,
255}
256
257fn is_curie(s: &str) -> bool {
261 let Some((prefix, reference)) = s.split_once(':') else {
262 return false;
263 };
264 if reference.is_empty() {
265 return false;
266 }
267 let mut chars = prefix.chars();
268 match chars.next() {
269 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
270 _ => return false,
271 }
272 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
273}
274
275fn deserialize_readme<'de, D>(deserializer: D) -> Result<ReadmeFields, D::Error>
277where
278 D: Deserializer<'de>,
279{
280 let value = serde_json::Value::deserialize(deserializer)?;
281 match value {
282 serde_json::Value::String(s) => Ok(ReadmeFields::Path(PathBuf::from(s))),
283 serde_json::Value::Bool(b) => Ok(ReadmeFields::Bool(b)),
284 serde_json::Value::Null => Err(serde::de::Error::custom("`readme` cannot be null")),
285 other => Err(serde::de::Error::custom(format!(
286 "`readme` must be a string or `false`; got {other}"
287 ))),
288 }
289}
290
291impl TryFrom<ManifestFields> for Manifest {
292 type Error = ManifestError;
293
294 fn try_from(fields: ManifestFields) -> Result<Self, Self::Error> {
295 if fields.name.is_empty() {
296 return Err(ManifestError::EmptyName);
297 }
298
299 let license = fields.license.parse::<LicenseExpression>()?;
300
301 let entrypoint = fields
302 .entrypoint
303 .as_deref()
304 .map(RelativePath::try_from)
305 .transpose()
306 .map_err(ManifestError::InvalidEntrypoint)?;
307
308 let readme = match fields.readme {
309 ReadmeFields::Default => Readme::Default,
310 ReadmeFields::Path(p) => Readme::Path(
311 RelativePath::try_from(p.as_path()).map_err(ManifestError::InvalidReadme)?,
312 ),
313 ReadmeFields::Bool(false) => Readme::Disabled,
314 ReadmeFields::Bool(true) => return Err(ManifestError::ReadmeTrue),
315 };
316
317 let mut deps: BTreeMap<DependencyName, DependencySource> = BTreeMap::new();
318 for (key, value) in fields.dependencies {
319 let name = key
320 .parse()
321 .map_err(|source| ManifestError::InvalidDependencyName {
322 name: key.clone(),
323 source,
324 })?;
325 if let Some((existing, _)) = deps.get_key_value(&name) {
326 return Err(ManifestError::DuplicateDependencyName(
327 existing.manifest().to_string(),
328 name.manifest().to_string(),
329 ));
330 }
331 deps.insert(name, value);
332 }
333
334 let exclude = fields
335 .exclude
336 .into_iter()
337 .map(|pattern| {
338 pattern
339 .parse::<RelativePath>()
340 .map_err(|source| ManifestError::InvalidExclude { pattern, source })
341 })
342 .collect::<Result<Vec<_>, _>>()?;
343
344 for tool in &fields.tools {
345 for id in &tool.ids {
346 if !is_curie(id) {
347 return Err(ManifestError::InvalidToolId(id.clone()));
348 }
349 }
350 }
351
352 Ok(Self {
353 name: fields.name,
354 license,
355 authors: fields.authors,
356 description: fields.description,
357 repository: fields.repository,
358 homepage: fields.homepage,
359 entrypoint,
360 readme,
361 exclude,
362 tools: fields.tools,
363 dependencies: deps,
364 extra: fields.extra,
365 })
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn parse(s: &str) -> Result<Manifest, ManifestError> {
374 Manifest::parse(s.as_bytes())
375 }
376
377 #[test]
378 fn parses_minimal_manifest() {
379 let m = parse(
380 r#"{
381 "name": "spellbook",
382 "license": "MIT"
383 }"#,
384 )
385 .unwrap();
386 assert_eq!(m.name, "spellbook");
387 assert_eq!(m.license.as_str(), "MIT");
388 assert!(m.authors.is_empty());
389 assert!(matches!(m.readme, Readme::Default));
390 assert_eq!(m.entrypoint_filename(), Path::new("index.wdl"));
391 }
392
393 #[test]
394 fn parses_full_example() {
395 let m = parse(
396 r#"{
397 "name": "spellbook",
398 "license": "MIT OR Apache-2.0",
399 "authors": ["Jane Doe <jane.doe@example.com>"],
400 "description": "spellbook wrapper",
401 "repository": "https://github.com/openwdl/spellbook",
402 "homepage": "https://example.com",
403 "tools": [
404 {
405 "name": "spellcheck",
406 "version": "2.0.1",
407 "license": "MIT",
408 "url": "https://example.com/sc",
409 "ids": ["doi:10.21105/joss.04704", "biotools:csvkit"]
410 }
411 ],
412 "dependencies": {
413 "common": {
414 "git": "https://github.com/openwdl/common",
415 "version": "^1.0.0"
416 },
417 "local_utils": { "path": "../utils" }
418 }
419 }"#,
420 )
421 .unwrap();
422 assert_eq!(m.tools.len(), 1);
423 assert_eq!(
424 m.tools[0].url.as_ref().unwrap().as_str(),
425 "https://example.com/sc"
426 );
427 assert_eq!(
428 m.tools[0].ids,
429 ["doi:10.21105/joss.04704", "biotools:csvkit"]
430 );
431 assert_eq!(m.dependencies.len(), 2);
432 }
433
434 #[test]
435 fn rejects_non_curie_tool_id() {
436 let err = parse(
437 r#"{
438 "name": "spellbook",
439 "license": "MIT",
440 "tools": [
441 {
442 "name": "spellcheck",
443 "version": "2.0.1",
444 "license": "MIT",
445 "ids": ["not a curie"]
446 }
447 ]
448 }"#,
449 )
450 .unwrap_err();
451 assert!(
452 matches!(&err, ManifestError::InvalidToolId(id) if id == "not a curie"),
453 "expected `InvalidToolId`, got: {err}"
454 );
455 }
456
457 #[test]
458 fn accepts_various_curie_prefixes() {
459 assert!(is_curie("doi:10.21105/joss.04704"));
460 assert!(is_curie("biotools:csvkit"));
461 assert!(is_curie("_local:x"));
462 assert!(is_curie("a.b-c_d:ref"));
463 assert!(!is_curie("nocolon"));
464 assert!(!is_curie(":noprefix"));
465 assert!(!is_curie("prefix:"));
466 assert!(!is_curie("1bad:ref"));
467 }
468
469 #[test]
470 fn parses_readme_disabled() {
471 let m = parse(
472 r#"{
473 "name": "spellbook",
474 "license": "MIT",
475 "readme": false
476 }"#,
477 )
478 .unwrap();
479 assert!(matches!(m.readme, Readme::Disabled));
480 }
481
482 #[test]
483 fn parses_readme_path() {
484 let m = parse(
485 r#"{
486 "name": "spellbook",
487 "license": "MIT",
488 "readme": "docs/README.md"
489 }"#,
490 )
491 .unwrap();
492 assert!(matches!(m.readme, Readme::Path(_)));
493 }
494
495 #[test]
496 fn resolves_default_readme_filename() -> Result<(), ManifestError> {
497 let manifest = parse(r#"{"name":"spellbook","license":"MIT"}"#)?;
498 assert_eq!(
499 manifest.readme_filename(),
500 Some(Path::new(crate::DEFAULT_README_FILENAME))
501 );
502 Ok(())
503 }
504
505 #[test]
506 fn resolves_custom_readme_filename() -> Result<(), ManifestError> {
507 let manifest = parse(
508 r#"{
509 "name": "spellbook",
510 "license": "MIT",
511 "readme": "docs/guide.md"
512 }"#,
513 )?;
514 assert_eq!(manifest.readme_filename(), Some(Path::new("docs/guide.md")));
515 Ok(())
516 }
517
518 #[test]
519 fn disabled_readme_has_no_filename() -> Result<(), ManifestError> {
520 let manifest = parse(
521 r#"{
522 "name": "spellbook",
523 "license": "MIT",
524 "readme": false
525 }"#,
526 )?;
527 assert_eq!(manifest.readme_filename(), None);
528 Ok(())
529 }
530
531 #[test]
532 fn captures_unknown_top_level_fields() {
533 let m = parse(
534 r#"{
535 "name": "spellbook",
536 "license": "MIT",
537 "extra_field": 42,
538 "metadata": {"key": "value"}
539 }"#,
540 )
541 .unwrap();
542 assert!(m.extra.contains_key("extra_field"));
543 assert!(m.extra.contains_key("metadata"));
544 }
545
546 #[test]
547 fn rejects_empty_name() {
548 let err = parse(r#"{ "name": "", "license": "MIT" }"#).unwrap_err();
549 assert!(matches!(err, ManifestError::EmptyName));
550 }
551
552 #[test]
553 fn rejects_invalid_license() {
554 let err = parse(r#"{ "name": "spellbook", "license": "MIT-2.0" }"#).unwrap_err();
555 assert!(matches!(err, ManifestError::License(_)));
556 }
557
558 #[test]
559 fn rejects_absolute_entrypoint() {
560 let err = parse(
561 r#"{
562 "name": "spellbook",
563 "license": "MIT",
564 "entrypoint": "/abs/path.wdl"
565 }"#,
566 )
567 .unwrap_err();
568 assert!(matches!(err, ManifestError::InvalidEntrypoint(_)));
569 }
570
571 #[test]
572 fn rejects_readme_true() {
573 let err = parse(
574 r#"{
575 "name": "spellbook",
576 "license": "MIT",
577 "readme": true
578 }"#,
579 )
580 .unwrap_err();
581 assert!(matches!(err, ManifestError::ReadmeTrue));
582 }
583
584 #[test]
585 fn rejects_readme_null() {
586 let err = parse(
587 r#"{
588 "name": "spellbook",
589 "license": "MIT",
590 "readme": null
591 }"#,
592 )
593 .unwrap_err();
594 assert!(matches!(err, ManifestError::InvalidJson(_)));
595 }
596
597 #[test]
598 fn parses_exclude_field() {
599 let m = parse(
600 r#"{
601 "name": "spellbook",
602 "license": "MIT",
603 "exclude": ["internal/**", "scratch/*.wdl"]
604 }"#,
605 )
606 .unwrap();
607 assert_eq!(
608 m.exclude
609 .iter()
610 .map(RelativePath::as_str)
611 .collect::<Vec<_>>(),
612 vec!["internal/**", "scratch/*.wdl"]
613 );
614 }
615
616 #[test]
617 fn rejects_invalid_exclude_entry() {
618 let err = parse(
619 r#"{
620 "name": "spellbook",
621 "license": "MIT",
622 "exclude": ["internal/**", "/abs/path"]
623 }"#,
624 )
625 .unwrap_err();
626 match err {
627 ManifestError::InvalidExclude { pattern, .. } => {
628 assert_eq!(pattern, "/abs/path");
629 }
630 other => panic!("expected `InvalidExclude` variant; got {other:?}"),
631 }
632 }
633
634 #[test]
635 fn rejects_parent_dir_in_readme() {
636 let err = parse(
637 r#"{
638 "name": "spellbook",
639 "license": "MIT",
640 "readme": "../escape.md"
641 }"#,
642 )
643 .unwrap_err();
644 assert!(matches!(err, ManifestError::InvalidReadme(_)));
645 }
646
647 fn assert_duplicate_key_error(err: ManifestError) {
648 let inner = match err {
649 ManifestError::InvalidJson(e) => e.to_string(),
650 other => panic!("expected `InvalidJson` variant; got {other:?}"),
651 };
652 assert!(
653 inner.contains("duplicate object key"),
654 "wrong inner message: {inner}"
655 );
656 }
657
658 #[test]
659 fn rejects_duplicate_top_level_keys() {
660 assert_duplicate_key_error(
661 parse(
662 r#"{
663 "name": "spellbook",
664 "name": "duplicate",
665 "license": "MIT"
666 }"#,
667 )
668 .unwrap_err(),
669 );
670 }
671
672 #[test]
673 fn rejects_duplicate_nested_keys() {
674 assert_duplicate_key_error(
675 parse(
676 r#"{
677 "name": "spellbook",
678 "license": "MIT",
679 "tools": [
680 {"name": "x", "name": "y", "version": "1", "license": "MIT"}
681 ]
682 }"#,
683 )
684 .unwrap_err(),
685 );
686 }
687
688 #[test]
689 fn accepts_hyphenated_dep_key() {
690 let m = parse(
691 r#"{
692 "name": "spellbook",
693 "license": "MIT",
694 "dependencies": { "my-dep": {"path": "../local"} }
695 }"#,
696 )
697 .unwrap();
698 let key: DependencyName = "my-dep".parse().unwrap();
699 assert!(m.dependencies.contains_key(&key));
700 assert_eq!(key.manifest(), "my-dep");
701 assert_eq!(key.identifier(), "my_dep");
702 }
703
704 #[test]
705 fn rejects_exact_duplicate_dep_keys() {
706 let err = parse(
707 r#"{
708 "name": "spellbook",
709 "license": "MIT",
710 "dependencies": {
711 "dep": {"path": "../a"},
712 "dep": {"path": "../b"}
713 }
714 }"#,
715 )
716 .unwrap_err();
717 assert!(
718 matches!(err, ManifestError::InvalidJson(_)),
719 "exact duplicate JSON keys should be rejected by strict JSON parsing, got: {err}"
720 );
721 }
722
723 #[test]
724 fn rejects_duplicate_hyphen_underscore_dep_keys() {
725 let err = parse(
726 r#"{
727 "name": "spellbook",
728 "license": "MIT",
729 "dependencies": {
730 "spell-book": {"path": "../a"},
731 "spell_book": {"path": "../b"}
732 }
733 }"#,
734 )
735 .unwrap_err();
736 assert!(
737 matches!(err, ManifestError::DuplicateDependencyName(..)),
738 "expected `DuplicateDependencyName`, got: {err}"
739 );
740 }
741
742 #[test]
743 fn rejects_non_identifier_dep_key() {
744 let err = parse(
745 r#"{
746 "name": "spellbook",
747 "license": "MIT",
748 "dependencies": { "1bad": {"path": "../local"} }
749 }"#,
750 )
751 .unwrap_err();
752 assert!(matches!(err, ManifestError::InvalidDependencyName { .. }));
753 }
754}