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 #[error("failed to read `{path}`")]
81 Io {
82 path: PathBuf,
84 #[source]
86 source: std::io::Error,
87 },
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
92pub enum Readme {
93 Default,
95 Path(RelativePath),
97 Disabled,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104pub struct Tool {
105 pub name: String,
107 pub version: String,
109 pub license: LicenseExpression,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub homepage: Option<Url>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub doi: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub biotools: Option<String>,
120 #[serde(flatten)]
123 pub extra: serde_json::Map<String, serde_json::Value>,
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
128pub struct Manifest {
129 pub name: String,
131 pub version: Version,
133 pub license: LicenseExpression,
135 pub authors: Vec<String>,
137 pub description: Option<String>,
139 pub repository: Option<Url>,
141 pub homepage: Option<Url>,
143 pub entrypoint: Option<RelativePath>,
146 pub readme: Readme,
148 pub exclude: Vec<RelativePath>,
155 pub tools: Vec<Tool>,
157 pub dependencies: BTreeMap<DependencyName, DependencySource>,
159 pub extra: serde_json::Map<String, serde_json::Value>,
163}
164
165impl Manifest {
166 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 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#[derive(Debug, Deserialize)]
186struct ManifestFields {
187 name: String,
189 version: Version,
191 license: String,
193 #[serde(default)]
195 authors: Vec<String>,
196 #[serde(default)]
198 description: Option<String>,
199 #[serde(default)]
201 repository: Option<Url>,
202 #[serde(default)]
204 homepage: Option<Url>,
205 #[serde(default)]
207 entrypoint: Option<PathBuf>,
208 #[serde(default, deserialize_with = "deserialize_readme")]
210 readme: ReadmeFields,
211 #[serde(default)]
214 exclude: Vec<String>,
215 #[serde(default)]
217 tools: Vec<Tool>,
218 #[serde(default)]
220 dependencies: BTreeMap<String, DependencySource>,
221 #[serde(flatten)]
223 extra: serde_json::Map<String, serde_json::Value>,
224}
225
226#[derive(Debug, Default)]
229enum ReadmeFields {
230 Path(PathBuf),
232 Bool(bool),
234 #[default]
236 Default,
237}
238
239fn 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}