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 = DependencyName::try_from(key.clone())
274 .map_err(|_| ManifestError::InvalidDependencyName(key))?;
275 if let Some((existing, _)) = deps.get_key_value(&name) {
276 return Err(ManifestError::DuplicateDependencyName(
277 existing.manifest().to_string(),
278 name.manifest().to_string(),
279 ));
280 }
281 deps.insert(name, value);
282 }
283
284 let exclude = fields
285 .exclude
286 .into_iter()
287 .map(|pattern| {
288 pattern
289 .parse::<RelativePath>()
290 .map_err(|source| ManifestError::InvalidExclude { pattern, source })
291 })
292 .collect::<Result<Vec<_>, _>>()?;
293
294 Ok(Self {
295 name: fields.name,
296 version: fields.version,
297 license,
298 authors: fields.authors,
299 description: fields.description,
300 repository: fields.repository,
301 homepage: fields.homepage,
302 entrypoint,
303 readme,
304 exclude,
305 tools: fields.tools,
306 dependencies: deps,
307 extra: fields.extra,
308 })
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 fn parse(s: &str) -> Result<Manifest, ManifestError> {
317 Manifest::parse(s.as_bytes())
318 }
319
320 #[test]
321 fn parses_minimal_manifest() {
322 let m = parse(
323 r#"{
324 "name": "spellbook",
325 "version": "1.2.0",
326 "license": "MIT"
327 }"#,
328 )
329 .unwrap();
330 assert_eq!(m.name, "spellbook");
331 assert_eq!(m.version.to_string(), "1.2.0");
332 assert_eq!(m.license.as_str(), "MIT");
333 assert!(m.authors.is_empty());
334 assert!(matches!(m.readme, Readme::Default));
335 assert_eq!(m.entrypoint_filename(), Path::new("index.wdl"));
336 }
337
338 #[test]
339 fn parses_full_example() {
340 let m = parse(
341 r#"{
342 "name": "spellbook",
343 "version": "1.2.0",
344 "license": "MIT OR Apache-2.0",
345 "authors": ["Jane Doe <jane.doe@example.com>"],
346 "description": "spellbook wrapper",
347 "repository": "https://github.com/openwdl/spellbook",
348 "homepage": "https://example.com",
349 "tools": [
350 {
351 "name": "spellcheck",
352 "version": "2.0.1",
353 "license": "MIT",
354 "homepage": "https://example.com/sc"
355 }
356 ],
357 "dependencies": {
358 "common": {
359 "git": "https://github.com/openwdl/common",
360 "version": "^1.0.0"
361 },
362 "local_utils": { "path": "../utils" }
363 }
364 }"#,
365 )
366 .unwrap();
367 assert_eq!(m.tools.len(), 1);
368 assert_eq!(m.dependencies.len(), 2);
369 }
370
371 #[test]
372 fn parses_readme_disabled() {
373 let m = parse(
374 r#"{
375 "name": "spellbook",
376 "version": "1.0.0",
377 "license": "MIT",
378 "readme": false
379 }"#,
380 )
381 .unwrap();
382 assert!(matches!(m.readme, Readme::Disabled));
383 }
384
385 #[test]
386 fn parses_readme_path() {
387 let m = parse(
388 r#"{
389 "name": "spellbook",
390 "version": "1.0.0",
391 "license": "MIT",
392 "readme": "docs/README.md"
393 }"#,
394 )
395 .unwrap();
396 assert!(matches!(m.readme, Readme::Path(_)));
397 }
398
399 #[test]
400 fn captures_unknown_top_level_fields() {
401 let m = parse(
402 r#"{
403 "name": "spellbook",
404 "version": "1.0.0",
405 "license": "MIT",
406 "extra_field": 42,
407 "metadata": {"key": "value"}
408 }"#,
409 )
410 .unwrap();
411 assert!(m.extra.contains_key("extra_field"));
412 assert!(m.extra.contains_key("metadata"));
413 }
414
415 #[test]
416 fn rejects_empty_name() {
417 let err = parse(r#"{ "name": "", "version": "1.0.0", "license": "MIT" }"#).unwrap_err();
418 assert!(matches!(err, ManifestError::EmptyName));
419 }
420
421 #[test]
422 fn rejects_invalid_license() {
423 let err = parse(r#"{ "name": "spellbook", "version": "1.0.0", "license": "MIT-2.0" }"#)
424 .unwrap_err();
425 assert!(matches!(err, ManifestError::License(_)));
426 }
427
428 #[test]
429 fn rejects_absolute_entrypoint() {
430 let err = parse(
431 r#"{
432 "name": "spellbook",
433 "version": "1.0.0",
434 "license": "MIT",
435 "entrypoint": "/abs/path.wdl"
436 }"#,
437 )
438 .unwrap_err();
439 assert!(matches!(err, ManifestError::InvalidEntrypoint(_)));
440 }
441
442 #[test]
443 fn rejects_readme_true() {
444 let err = parse(
445 r#"{
446 "name": "spellbook",
447 "version": "1.0.0",
448 "license": "MIT",
449 "readme": true
450 }"#,
451 )
452 .unwrap_err();
453 assert!(matches!(err, ManifestError::ReadmeTrue));
454 }
455
456 #[test]
457 fn rejects_readme_null() {
458 let err = parse(
459 r#"{
460 "name": "spellbook",
461 "version": "1.0.0",
462 "license": "MIT",
463 "readme": null
464 }"#,
465 )
466 .unwrap_err();
467 assert!(matches!(err, ManifestError::InvalidJson(_)));
468 }
469
470 #[test]
471 fn parses_exclude_field() {
472 let m = parse(
473 r#"{
474 "name": "spellbook",
475 "version": "1.0.0",
476 "license": "MIT",
477 "exclude": ["internal/**", "scratch/*.wdl"]
478 }"#,
479 )
480 .unwrap();
481 assert_eq!(
482 m.exclude
483 .iter()
484 .map(RelativePath::as_str)
485 .collect::<Vec<_>>(),
486 vec!["internal/**", "scratch/*.wdl"]
487 );
488 }
489
490 #[test]
491 fn rejects_invalid_exclude_entry() {
492 let err = parse(
493 r#"{
494 "name": "spellbook",
495 "version": "1.0.0",
496 "license": "MIT",
497 "exclude": ["internal/**", "/abs/path"]
498 }"#,
499 )
500 .unwrap_err();
501 match err {
502 ManifestError::InvalidExclude { pattern, .. } => {
503 assert_eq!(pattern, "/abs/path");
504 }
505 other => panic!("expected `InvalidExclude` variant; got {other:?}"),
506 }
507 }
508
509 #[test]
510 fn rejects_parent_dir_in_readme() {
511 let err = parse(
512 r#"{
513 "name": "spellbook",
514 "version": "1.0.0",
515 "license": "MIT",
516 "readme": "../escape.md"
517 }"#,
518 )
519 .unwrap_err();
520 assert!(matches!(err, ManifestError::InvalidReadme(_)));
521 }
522
523 fn assert_duplicate_key_error(err: ManifestError) {
524 let inner = match err {
525 ManifestError::InvalidJson(e) => e.to_string(),
526 other => panic!("expected `InvalidJson` variant; got {other:?}"),
527 };
528 assert!(
529 inner.contains("duplicate object key"),
530 "wrong inner message: {inner}"
531 );
532 }
533
534 #[test]
535 fn rejects_duplicate_top_level_keys() {
536 assert_duplicate_key_error(
537 parse(
538 r#"{
539 "name": "spellbook",
540 "name": "duplicate",
541 "version": "1.0.0",
542 "license": "MIT"
543 }"#,
544 )
545 .unwrap_err(),
546 );
547 }
548
549 #[test]
550 fn rejects_duplicate_nested_keys() {
551 assert_duplicate_key_error(
552 parse(
553 r#"{
554 "name": "spellbook",
555 "version": "1.0.0",
556 "license": "MIT",
557 "tools": [
558 {"name": "x", "name": "y", "version": "1", "license": "MIT"}
559 ]
560 }"#,
561 )
562 .unwrap_err(),
563 );
564 }
565
566 #[test]
567 fn accepts_hyphenated_dep_key() {
568 let m = parse(
569 r#"{
570 "name": "spellbook",
571 "version": "1.0.0",
572 "license": "MIT",
573 "dependencies": { "my-dep": {"path": "../local"} }
574 }"#,
575 )
576 .unwrap();
577 let key: DependencyName = "my-dep".parse().unwrap();
578 assert!(m.dependencies.contains_key(&key));
579 assert_eq!(key.manifest(), "my-dep");
580 assert_eq!(key.identifier(), "my_dep");
581 }
582
583 #[test]
584 fn rejects_exact_duplicate_dep_keys() {
585 let err = parse(
586 r#"{
587 "name": "spellbook",
588 "version": "1.0.0",
589 "license": "MIT",
590 "dependencies": {
591 "dep": {"path": "../a"},
592 "dep": {"path": "../b"}
593 }
594 }"#,
595 )
596 .unwrap_err();
597 assert!(
598 matches!(err, ManifestError::InvalidJson(_)),
599 "exact duplicate JSON keys should be rejected by strict JSON parsing, got: {err}"
600 );
601 }
602
603 #[test]
604 fn rejects_duplicate_hyphen_underscore_dep_keys() {
605 let err = parse(
606 r#"{
607 "name": "spellbook",
608 "version": "1.0.0",
609 "license": "MIT",
610 "dependencies": {
611 "spell-book": {"path": "../a"},
612 "spell_book": {"path": "../b"}
613 }
614 }"#,
615 )
616 .unwrap_err();
617 assert!(
618 matches!(err, ManifestError::DuplicateDependencyName(..)),
619 "expected `DuplicateDependencyName`, got: {err}"
620 );
621 }
622
623 #[test]
624 fn rejects_non_identifier_dep_key() {
625 let err = parse(
626 r#"{
627 "name": "spellbook",
628 "version": "1.0.0",
629 "license": "MIT",
630 "dependencies": { "1bad": {"path": "../local"} }
631 }"#,
632 )
633 .unwrap_err();
634 assert!(matches!(err, ManifestError::InvalidDependencyName(_)));
635 }
636}