use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use semver::Version;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use thiserror::Error;
use url::Url;
use crate::DEFAULT_ENTRYPOINT_FILENAME;
use crate::dependency::DependencyName;
use crate::dependency::DependencySource;
use crate::dependency::DependencySourceError;
use crate::license::LicenseError;
use crate::license::LicenseExpression;
use crate::relative_path::RelativePath;
use crate::relative_path::RelativePathError;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("invalid `module.json` JSON")]
InvalidJson(#[from] serde_json::Error),
#[error("`name` cannot be empty")]
EmptyName,
#[error("`entrypoint` is invalid")]
InvalidEntrypoint(#[source] RelativePathError),
#[error("`readme` is invalid")]
InvalidReadme(#[source] RelativePathError),
#[error("`exclude` entry `{pattern}` is invalid")]
InvalidExclude {
pattern: String,
#[source]
source: RelativePathError,
},
#[error("`readme` cannot be set to `true`; omit the field to use the default `README.md`")]
ReadmeTrue,
#[error("`dependencies` key `{0}` is not a valid WDL identifier")]
InvalidDependencyName(String),
#[error("duplicate `dependencies` key: `{0}` and `{1}` resolve to the same dependency")]
DuplicateDependencyName(String, String),
#[error(transparent)]
DependencySource(#[from] DependencySourceError),
#[error(transparent)]
License(#[from] LicenseError),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Readme {
Default,
Path(RelativePath),
Disabled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tool {
pub name: String,
pub version: String,
pub license: LicenseExpression,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub biotools: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Manifest {
pub name: String,
pub version: Version,
pub license: LicenseExpression,
pub authors: Vec<String>,
pub description: Option<String>,
pub repository: Option<Url>,
pub homepage: Option<Url>,
pub entrypoint: Option<RelativePath>,
pub readme: Readme,
pub exclude: Vec<RelativePath>,
pub tools: Vec<Tool>,
pub dependencies: BTreeMap<DependencyName, DependencySource>,
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Manifest {
pub fn parse(bytes: &[u8]) -> Result<Self, ManifestError> {
let raw: ManifestFields = crate::strict_json::from_slice(bytes)?;
raw.try_into()
}
pub fn entrypoint_filename(&self) -> &Path {
self.entrypoint
.as_ref()
.map(RelativePath::as_path)
.unwrap_or(Path::new(DEFAULT_ENTRYPOINT_FILENAME))
}
}
#[derive(Debug, Deserialize)]
struct ManifestFields {
name: String,
version: Version,
license: String,
#[serde(default)]
authors: Vec<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
repository: Option<Url>,
#[serde(default)]
homepage: Option<Url>,
#[serde(default)]
entrypoint: Option<PathBuf>,
#[serde(default, deserialize_with = "deserialize_readme")]
readme: ReadmeFields,
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
tools: Vec<Tool>,
#[serde(default)]
dependencies: BTreeMap<String, DependencySource>,
#[serde(flatten)]
extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Default)]
enum ReadmeFields {
Path(PathBuf),
Bool(bool),
#[default]
Default,
}
fn deserialize_readme<'de, D>(deserializer: D) -> Result<ReadmeFields, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::String(s) => Ok(ReadmeFields::Path(PathBuf::from(s))),
serde_json::Value::Bool(b) => Ok(ReadmeFields::Bool(b)),
serde_json::Value::Null => Err(serde::de::Error::custom("`readme` cannot be null")),
other => Err(serde::de::Error::custom(format!(
"`readme` must be a string or `false`; got {other}"
))),
}
}
impl TryFrom<ManifestFields> for Manifest {
type Error = ManifestError;
fn try_from(fields: ManifestFields) -> Result<Self, Self::Error> {
if fields.name.is_empty() {
return Err(ManifestError::EmptyName);
}
let license = fields.license.parse::<LicenseExpression>()?;
let entrypoint = fields
.entrypoint
.as_deref()
.map(RelativePath::try_from)
.transpose()
.map_err(ManifestError::InvalidEntrypoint)?;
let readme = match fields.readme {
ReadmeFields::Default => Readme::Default,
ReadmeFields::Path(p) => Readme::Path(
RelativePath::try_from(p.as_path()).map_err(ManifestError::InvalidReadme)?,
),
ReadmeFields::Bool(false) => Readme::Disabled,
ReadmeFields::Bool(true) => return Err(ManifestError::ReadmeTrue),
};
let mut deps: BTreeMap<DependencyName, DependencySource> = BTreeMap::new();
for (key, value) in fields.dependencies {
let name = DependencyName::try_from(key.clone())
.map_err(|_| ManifestError::InvalidDependencyName(key))?;
if let Some((existing, _)) = deps.get_key_value(&name) {
return Err(ManifestError::DuplicateDependencyName(
existing.manifest().to_string(),
name.manifest().to_string(),
));
}
deps.insert(name, value);
}
let exclude = fields
.exclude
.into_iter()
.map(|pattern| {
pattern
.parse::<RelativePath>()
.map_err(|source| ManifestError::InvalidExclude { pattern, source })
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
name: fields.name,
version: fields.version,
license,
authors: fields.authors,
description: fields.description,
repository: fields.repository,
homepage: fields.homepage,
entrypoint,
readme,
exclude,
tools: fields.tools,
dependencies: deps,
extra: fields.extra,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Result<Manifest, ManifestError> {
Manifest::parse(s.as_bytes())
}
#[test]
fn parses_minimal_manifest() {
let m = parse(
r#"{
"name": "spellbook",
"version": "1.2.0",
"license": "MIT"
}"#,
)
.unwrap();
assert_eq!(m.name, "spellbook");
assert_eq!(m.version.to_string(), "1.2.0");
assert_eq!(m.license.as_str(), "MIT");
assert!(m.authors.is_empty());
assert!(matches!(m.readme, Readme::Default));
assert_eq!(m.entrypoint_filename(), Path::new("index.wdl"));
}
#[test]
fn parses_full_example() {
let m = parse(
r#"{
"name": "spellbook",
"version": "1.2.0",
"license": "MIT OR Apache-2.0",
"authors": ["Jane Doe <jane.doe@example.com>"],
"description": "spellbook wrapper",
"repository": "https://github.com/openwdl/spellbook",
"homepage": "https://example.com",
"tools": [
{
"name": "spellcheck",
"version": "2.0.1",
"license": "MIT",
"homepage": "https://example.com/sc"
}
],
"dependencies": {
"common": {
"git": "https://github.com/openwdl/common",
"version": "^1.0.0"
},
"local_utils": { "path": "../utils" }
}
}"#,
)
.unwrap();
assert_eq!(m.tools.len(), 1);
assert_eq!(m.dependencies.len(), 2);
}
#[test]
fn parses_readme_disabled() {
let m = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"readme": false
}"#,
)
.unwrap();
assert!(matches!(m.readme, Readme::Disabled));
}
#[test]
fn parses_readme_path() {
let m = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"readme": "docs/README.md"
}"#,
)
.unwrap();
assert!(matches!(m.readme, Readme::Path(_)));
}
#[test]
fn captures_unknown_top_level_fields() {
let m = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"extra_field": 42,
"metadata": {"key": "value"}
}"#,
)
.unwrap();
assert!(m.extra.contains_key("extra_field"));
assert!(m.extra.contains_key("metadata"));
}
#[test]
fn rejects_empty_name() {
let err = parse(r#"{ "name": "", "version": "1.0.0", "license": "MIT" }"#).unwrap_err();
assert!(matches!(err, ManifestError::EmptyName));
}
#[test]
fn rejects_invalid_license() {
let err = parse(r#"{ "name": "spellbook", "version": "1.0.0", "license": "MIT-2.0" }"#)
.unwrap_err();
assert!(matches!(err, ManifestError::License(_)));
}
#[test]
fn rejects_absolute_entrypoint() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"entrypoint": "/abs/path.wdl"
}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::InvalidEntrypoint(_)));
}
#[test]
fn rejects_readme_true() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"readme": true
}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::ReadmeTrue));
}
#[test]
fn rejects_readme_null() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"readme": null
}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::InvalidJson(_)));
}
#[test]
fn parses_exclude_field() {
let m = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"exclude": ["internal/**", "scratch/*.wdl"]
}"#,
)
.unwrap();
assert_eq!(
m.exclude
.iter()
.map(RelativePath::as_str)
.collect::<Vec<_>>(),
vec!["internal/**", "scratch/*.wdl"]
);
}
#[test]
fn rejects_invalid_exclude_entry() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"exclude": ["internal/**", "/abs/path"]
}"#,
)
.unwrap_err();
match err {
ManifestError::InvalidExclude { pattern, .. } => {
assert_eq!(pattern, "/abs/path");
}
other => panic!("expected `InvalidExclude` variant; got {other:?}"),
}
}
#[test]
fn rejects_parent_dir_in_readme() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"readme": "../escape.md"
}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::InvalidReadme(_)));
}
fn assert_duplicate_key_error(err: ManifestError) {
let inner = match err {
ManifestError::InvalidJson(e) => e.to_string(),
other => panic!("expected `InvalidJson` variant; got {other:?}"),
};
assert!(
inner.contains("duplicate object key"),
"wrong inner message: {inner}"
);
}
#[test]
fn rejects_duplicate_top_level_keys() {
assert_duplicate_key_error(
parse(
r#"{
"name": "spellbook",
"name": "duplicate",
"version": "1.0.0",
"license": "MIT"
}"#,
)
.unwrap_err(),
);
}
#[test]
fn rejects_duplicate_nested_keys() {
assert_duplicate_key_error(
parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"tools": [
{"name": "x", "name": "y", "version": "1", "license": "MIT"}
]
}"#,
)
.unwrap_err(),
);
}
#[test]
fn accepts_hyphenated_dep_key() {
let m = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"dependencies": { "my-dep": {"path": "../local"} }
}"#,
)
.unwrap();
let key: DependencyName = "my-dep".parse().unwrap();
assert!(m.dependencies.contains_key(&key));
assert_eq!(key.manifest(), "my-dep");
assert_eq!(key.identifier(), "my_dep");
}
#[test]
fn rejects_exact_duplicate_dep_keys() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"dep": {"path": "../a"},
"dep": {"path": "../b"}
}
}"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::InvalidJson(_)),
"exact duplicate JSON keys should be rejected by strict JSON parsing, got: {err}"
);
}
#[test]
fn rejects_duplicate_hyphen_underscore_dep_keys() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"spell-book": {"path": "../a"},
"spell_book": {"path": "../b"}
}
}"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::DuplicateDependencyName(..)),
"expected `DuplicateDependencyName`, got: {err}"
);
}
#[test]
fn rejects_non_identifier_dep_key() {
let err = parse(
r#"{
"name": "spellbook",
"version": "1.0.0",
"license": "MIT",
"dependencies": { "1bad": {"path": "../local"} }
}"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::InvalidDependencyName(_)));
}
}