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::DependencyName;
16use crate::DependencySource;
17use crate::DependencySourceError;
18use crate::LicenseError;
19use crate::LicenseExpression;
20use crate::RelativePath;
21use crate::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(transparent)]
68 DependencySource(#[from] DependencySourceError),
69
70 #[error(transparent)]
72 License(#[from] LicenseError),
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
77pub enum Readme {
78 Default,
80 Path(RelativePath),
82 Disabled,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Tool {
90 pub name: String,
92 pub version: String,
94 pub license: LicenseExpression,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub homepage: Option<Url>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub doi: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub biotools: Option<String>,
105 #[serde(flatten)]
108 pub extra: serde_json::Map<String, serde_json::Value>,
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct Manifest {
114 pub name: String,
116 pub version: Version,
118 pub license: LicenseExpression,
120 pub authors: Vec<String>,
122 pub description: Option<String>,
124 pub repository: Option<Url>,
126 pub homepage: Option<Url>,
128 pub entrypoint: Option<RelativePath>,
131 pub readme: Readme,
133 pub exclude: Vec<RelativePath>,
140 pub tools: Vec<Tool>,
142 pub dependencies: BTreeMap<DependencyName, DependencySource>,
144 pub extra: serde_json::Map<String, serde_json::Value>,
148}
149
150impl Manifest {
151 pub fn parse(bytes: &[u8]) -> Result<Self, ManifestError> {
153 let raw: ManifestFields = crate::strict_json::from_slice(bytes)?;
154 raw.try_into()
155 }
156
157 pub fn entrypoint_filename(&self) -> &Path {
161 self.entrypoint
162 .as_ref()
163 .map(RelativePath::as_path)
164 .unwrap_or(Path::new(DEFAULT_ENTRYPOINT_FILENAME))
165 }
166}
167
168#[derive(Debug, Deserialize)]
171struct ManifestFields {
172 name: String,
174 version: Version,
176 license: String,
178 #[serde(default)]
180 authors: Vec<String>,
181 #[serde(default)]
183 description: Option<String>,
184 #[serde(default)]
186 repository: Option<Url>,
187 #[serde(default)]
189 homepage: Option<Url>,
190 #[serde(default)]
192 entrypoint: Option<PathBuf>,
193 #[serde(default, deserialize_with = "deserialize_readme")]
195 readme: ReadmeFields,
196 #[serde(default)]
199 exclude: Vec<String>,
200 #[serde(default)]
202 tools: Vec<Tool>,
203 #[serde(default)]
205 dependencies: BTreeMap<String, DependencySource>,
206 #[serde(flatten)]
208 extra: serde_json::Map<String, serde_json::Value>,
209}
210
211#[derive(Debug, Default)]
214enum ReadmeFields {
215 Path(PathBuf),
217 Bool(bool),
219 #[default]
221 Default,
222}
223
224fn deserialize_readme<'de, D>(deserializer: D) -> Result<ReadmeFields, D::Error>
225where
226 D: Deserializer<'de>,
227{
228 let value = serde_json::Value::deserialize(deserializer)?;
229 match value {
230 serde_json::Value::String(s) => Ok(ReadmeFields::Path(PathBuf::from(s))),
231 serde_json::Value::Bool(b) => Ok(ReadmeFields::Bool(b)),
232 serde_json::Value::Null => Err(serde::de::Error::custom("`readme` cannot be null")),
233 other => Err(serde::de::Error::custom(format!(
234 "`readme` must be a string or `false`; got {other}"
235 ))),
236 }
237}
238
239impl TryFrom<ManifestFields> for Manifest {
240 type Error = ManifestError;
241
242 fn try_from(fields: ManifestFields) -> Result<Self, Self::Error> {
243 if fields.name.is_empty() {
244 return Err(ManifestError::EmptyName);
245 }
246
247 let license = LicenseExpression::try_from(fields.license)?;
248
249 let entrypoint = fields
250 .entrypoint
251 .map(RelativePath::try_from)
252 .transpose()
253 .map_err(ManifestError::InvalidEntrypoint)?;
254
255 let readme = match fields.readme {
256 ReadmeFields::Default => Readme::Default,
257 ReadmeFields::Path(p) => {
258 Readme::Path(RelativePath::try_from(p).map_err(ManifestError::InvalidReadme)?)
259 }
260 ReadmeFields::Bool(false) => Readme::Disabled,
261 ReadmeFields::Bool(true) => return Err(ManifestError::ReadmeTrue),
262 };
263
264 let mut deps = BTreeMap::new();
265 for (key, value) in fields.dependencies {
266 let name = DependencyName::try_from(key.clone())
267 .map_err(|_| ManifestError::InvalidDependencyName(key))?;
268 deps.insert(name, value);
269 }
270
271 let exclude = fields
272 .exclude
273 .into_iter()
274 .map(|pattern| {
275 RelativePath::try_from(pattern.clone())
276 .map_err(|source| ManifestError::InvalidExclude { pattern, source })
277 })
278 .collect::<Result<Vec<_>, _>>()?;
279
280 Ok(Self {
281 name: fields.name,
282 version: fields.version,
283 license,
284 authors: fields.authors,
285 description: fields.description,
286 repository: fields.repository,
287 homepage: fields.homepage,
288 entrypoint,
289 readme,
290 exclude,
291 tools: fields.tools,
292 dependencies: deps,
293 extra: fields.extra,
294 })
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn parse(s: &str) -> Result<Manifest, ManifestError> {
303 Manifest::parse(s.as_bytes())
304 }
305
306 #[test]
307 fn parses_minimal_manifest() {
308 let m = parse(
309 r#"{
310 "name": "spellbook",
311 "version": "1.2.0",
312 "license": "MIT"
313 }"#,
314 )
315 .unwrap();
316 assert_eq!(m.name, "spellbook");
317 assert_eq!(m.version.to_string(), "1.2.0");
318 assert_eq!(m.license.as_str(), "MIT");
319 assert!(m.authors.is_empty());
320 assert!(matches!(m.readme, Readme::Default));
321 assert_eq!(m.entrypoint_filename(), Path::new("index.wdl"));
322 }
323
324 #[test]
325 fn parses_full_example() {
326 let m = parse(
327 r#"{
328 "name": "spellbook",
329 "version": "1.2.0",
330 "license": "MIT OR Apache-2.0",
331 "authors": ["Jane Doe <jane.doe@example.com>"],
332 "description": "spellbook wrapper",
333 "repository": "https://github.com/openwdl/spellbook",
334 "homepage": "https://example.com",
335 "tools": [
336 {
337 "name": "spellcheck",
338 "version": "2.0.1",
339 "license": "MIT",
340 "homepage": "https://example.com/sc"
341 }
342 ],
343 "dependencies": {
344 "common": {
345 "git": "https://github.com/openwdl/common",
346 "version": "^1.0.0"
347 },
348 "local_utils": { "path": "../utils" }
349 }
350 }"#,
351 )
352 .unwrap();
353 assert_eq!(m.tools.len(), 1);
354 assert_eq!(m.dependencies.len(), 2);
355 }
356
357 #[test]
358 fn parses_readme_disabled() {
359 let m = parse(
360 r#"{
361 "name": "spellbook",
362 "version": "1.0.0",
363 "license": "MIT",
364 "readme": false
365 }"#,
366 )
367 .unwrap();
368 assert!(matches!(m.readme, Readme::Disabled));
369 }
370
371 #[test]
372 fn parses_readme_path() {
373 let m = parse(
374 r#"{
375 "name": "spellbook",
376 "version": "1.0.0",
377 "license": "MIT",
378 "readme": "docs/README.md"
379 }"#,
380 )
381 .unwrap();
382 assert!(matches!(m.readme, Readme::Path(_)));
383 }
384
385 #[test]
386 fn captures_unknown_top_level_fields() {
387 let m = parse(
388 r#"{
389 "name": "spellbook",
390 "version": "1.0.0",
391 "license": "MIT",
392 "extra_field": 42,
393 "metadata": {"key": "value"}
394 }"#,
395 )
396 .unwrap();
397 assert!(m.extra.contains_key("extra_field"));
398 assert!(m.extra.contains_key("metadata"));
399 }
400
401 #[test]
402 fn rejects_empty_name() {
403 let err = parse(r#"{ "name": "", "version": "1.0.0", "license": "MIT" }"#).unwrap_err();
404 assert!(matches!(err, ManifestError::EmptyName));
405 }
406
407 #[test]
408 fn rejects_invalid_license() {
409 let err = parse(r#"{ "name": "spellbook", "version": "1.0.0", "license": "MIT-2.0" }"#)
410 .unwrap_err();
411 assert!(matches!(err, ManifestError::License(_)));
412 }
413
414 #[test]
415 fn rejects_absolute_entrypoint() {
416 let err = parse(
417 r#"{
418 "name": "spellbook",
419 "version": "1.0.0",
420 "license": "MIT",
421 "entrypoint": "/abs/path.wdl"
422 }"#,
423 )
424 .unwrap_err();
425 assert!(matches!(err, ManifestError::InvalidEntrypoint(_)));
426 }
427
428 #[test]
429 fn rejects_readme_true() {
430 let err = parse(
431 r#"{
432 "name": "spellbook",
433 "version": "1.0.0",
434 "license": "MIT",
435 "readme": true
436 }"#,
437 )
438 .unwrap_err();
439 assert!(matches!(err, ManifestError::ReadmeTrue));
440 }
441
442 #[test]
443 fn rejects_readme_null() {
444 let err = parse(
445 r#"{
446 "name": "spellbook",
447 "version": "1.0.0",
448 "license": "MIT",
449 "readme": null
450 }"#,
451 )
452 .unwrap_err();
453 assert!(matches!(err, ManifestError::InvalidJson(_)));
454 }
455
456 #[test]
457 fn parses_exclude_field() {
458 let m = parse(
459 r#"{
460 "name": "spellbook",
461 "version": "1.0.0",
462 "license": "MIT",
463 "exclude": ["internal/**", "scratch/*.wdl"]
464 }"#,
465 )
466 .unwrap();
467 assert_eq!(
468 m.exclude
469 .iter()
470 .map(RelativePath::as_str)
471 .collect::<Vec<_>>(),
472 vec!["internal/**", "scratch/*.wdl"]
473 );
474 }
475
476 #[test]
477 fn rejects_invalid_exclude_entry() {
478 let err = parse(
479 r#"{
480 "name": "spellbook",
481 "version": "1.0.0",
482 "license": "MIT",
483 "exclude": ["internal/**", "/abs/path"]
484 }"#,
485 )
486 .unwrap_err();
487 match err {
488 ManifestError::InvalidExclude { pattern, .. } => {
489 assert_eq!(pattern, "/abs/path");
490 }
491 other => panic!("expected `InvalidExclude` variant; got {other:?}"),
492 }
493 }
494
495 #[test]
496 fn rejects_parent_dir_in_readme() {
497 let err = parse(
498 r#"{
499 "name": "spellbook",
500 "version": "1.0.0",
501 "license": "MIT",
502 "readme": "../escape.md"
503 }"#,
504 )
505 .unwrap_err();
506 assert!(matches!(err, ManifestError::InvalidReadme(_)));
507 }
508
509 fn assert_duplicate_key_error(err: ManifestError) {
510 let inner = match err {
511 ManifestError::InvalidJson(e) => e.to_string(),
512 other => panic!("expected `InvalidJson` variant; got {other:?}"),
513 };
514 assert!(
515 inner.contains("duplicate object key"),
516 "wrong inner message: {inner}"
517 );
518 }
519
520 #[test]
521 fn rejects_duplicate_top_level_keys() {
522 assert_duplicate_key_error(
523 parse(
524 r#"{
525 "name": "spellbook",
526 "name": "duplicate",
527 "version": "1.0.0",
528 "license": "MIT"
529 }"#,
530 )
531 .unwrap_err(),
532 );
533 }
534
535 #[test]
536 fn rejects_duplicate_nested_keys() {
537 assert_duplicate_key_error(
538 parse(
539 r#"{
540 "name": "spellbook",
541 "version": "1.0.0",
542 "license": "MIT",
543 "tools": [
544 {"name": "x", "name": "y", "version": "1", "license": "MIT"}
545 ]
546 }"#,
547 )
548 .unwrap_err(),
549 );
550 }
551
552 #[test]
553 fn accepts_hyphenated_dep_key() {
554 let m = parse(
555 r#"{
556 "name": "spellbook",
557 "version": "1.0.0",
558 "license": "MIT",
559 "dependencies": { "my-dep": {"path": "../local"} }
560 }"#,
561 )
562 .unwrap();
563 let key: DependencyName = "my-dep".parse().unwrap();
564 assert!(m.dependencies.contains_key(&key));
565 assert_eq!(key.manifest(), "my-dep");
566 assert_eq!(key.identifier(), "my_dep");
567 }
568
569 #[test]
570 fn rejects_non_identifier_dep_key() {
571 let err = parse(
572 r#"{
573 "name": "spellbook",
574 "version": "1.0.0",
575 "license": "MIT",
576 "dependencies": { "1bad": {"path": "../local"} }
577 }"#,
578 )
579 .unwrap_err();
580 assert!(matches!(err, ManifestError::InvalidDependencyName(_)));
581 }
582}