use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
use url::Url;
use crate::GitCommit;
use crate::GitCommitError;
use crate::RelativePath;
use crate::RelativePathError;
use crate::VersionRequirement;
use crate::VersionRequirementError;
#[derive(Debug, Error)]
pub enum DependencySourceError {
#[error(
"dependency source is invalid: {reason}; must specify either `path` for a local-path \
source, or `git` with exactly one of `version`, `tag`, `branch`, or `commit` for a Git \
source"
)]
InvalidSource {
reason: &'static str,
},
#[error(transparent)]
VersionRequirement(#[from] VersionRequirementError),
#[error("Git dependency sub-path is invalid")]
GitSubpath(#[source] RelativePathError),
#[error(transparent)]
GitCommit(#[from] GitCommitError),
#[error("invalid Git URL: {0}")]
InvalidUrl(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "DependencySourceFields", into = "DependencySourceFields")]
pub enum DependencySource {
Git {
url: Url,
selector: GitSelector,
path: Option<RelativePath>,
extra: serde_json::Map<String, serde_json::Value>,
},
LocalPath {
path: PathBuf,
extra: serde_json::Map<String, serde_json::Value>,
},
}
impl TryFrom<DependencySourceFields> for DependencySource {
type Error = DependencySourceError;
fn try_from(fields: DependencySourceFields) -> Result<Self, Self::Error> {
let DependencySourceFields {
git,
path,
version,
tag,
branch,
commit,
extra,
} = fields;
let selector_count = [&version, &tag, &branch, &commit]
.iter()
.filter(|s| s.is_some())
.count();
match (git, path) {
(Some(g), git_subpath) => {
if selector_count == 0 {
return Err(DependencySourceError::InvalidSource {
reason: "Git dependency is missing a selector",
});
}
if selector_count > 1 {
return Err(DependencySourceError::InvalidSource {
reason: "Git dependency specifies more than one selector",
});
}
let url =
Url::parse(&g).map_err(|e| DependencySourceError::InvalidUrl(e.to_string()))?;
let selector = if let Some(v) = version {
GitSelector::Version(VersionRequirement::try_from(v)?)
} else if let Some(t) = tag {
GitSelector::Tag(t)
} else if let Some(b) = branch {
GitSelector::Branch(b)
} else if let Some(c) = commit {
GitSelector::Commit(GitCommit::try_from(c)?)
} else {
unreachable!()
};
Ok(Self::Git {
url,
selector,
path: git_subpath
.map(RelativePath::try_from)
.transpose()
.map_err(DependencySourceError::GitSubpath)?,
extra,
})
}
(None, Some(p)) => {
if selector_count > 0 {
return Err(DependencySourceError::InvalidSource {
reason: "local-path dependency cannot specify a selector",
});
}
Ok(Self::LocalPath { path: p, extra })
}
(None, None) => Err(DependencySourceError::InvalidSource {
reason: "neither `git` nor `path` was specified",
}),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GitSelector {
Version(VersionRequirement),
Tag(String),
Branch(String),
Commit(GitCommit),
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct DependencySourceFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
git: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
commit: Option<String>,
#[serde(flatten)]
extra: serde_json::Map<String, serde_json::Value>,
}
impl From<DependencySource> for DependencySourceFields {
fn from(source: DependencySource) -> Self {
match source {
DependencySource::Git {
url,
selector,
path,
extra,
} => {
let mut fields = DependencySourceFields {
git: Some(url.to_string()),
path: path.map(PathBuf::from),
extra,
..Default::default()
};
match selector {
GitSelector::Version(v) => fields.version = Some(v.to_string()),
GitSelector::Tag(t) => fields.tag = Some(t),
GitSelector::Branch(b) => fields.branch = Some(b),
GitSelector::Commit(c) => fields.commit = Some(c.to_string()),
}
fields
}
DependencySource::LocalPath { path, extra } => DependencySourceFields {
path: Some(path),
extra,
..Default::default()
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Result<DependencySource, serde_json::Error> {
serde_json::from_str(s)
}
#[test]
fn parses_git_with_version() {
let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0"}"#).unwrap();
match dep {
DependencySource::Git {
selector: GitSelector::Version(_),
..
} => {}
_ => panic!("expected `Version` selector"),
}
}
#[test]
fn parses_git_with_tag() {
let dep = parse(r#"{"git": "https://github.com/x/y", "tag": "v1.2.3"}"#).unwrap();
assert!(matches!(
dep,
DependencySource::Git {
selector: GitSelector::Tag(_),
..
}
));
}
#[test]
fn parses_git_with_branch() {
let dep = parse(r#"{"git": "https://github.com/x/y", "branch": "main"}"#).unwrap();
assert!(matches!(
dep,
DependencySource::Git {
selector: GitSelector::Branch(_),
..
}
));
}
#[test]
fn parses_git_with_commit() {
let dep = parse(
r#"{
"git": "https://github.com/x/y",
"commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}"#,
)
.unwrap();
match dep {
DependencySource::Git {
selector: GitSelector::Commit(commit),
..
} => assert_eq!(commit.as_str(), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"),
_ => panic!("expected `Commit` selector"),
}
}
#[test]
fn parses_local_path() {
let dep = parse(r#"{"path": "../local"}"#).unwrap();
assert!(matches!(dep, DependencySource::LocalPath { .. }));
}
#[test]
fn parses_git_with_subpath() {
let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "path": "wdl"}"#)
.unwrap();
match dep {
DependencySource::Git {
selector: GitSelector::Version(_),
path: Some(p),
..
} => assert_eq!(p.as_path(), std::path::Path::new("wdl")),
_ => panic!("expected Git source with sub-path"),
}
}
#[test]
fn rejects_invalid_git_subpaths() {
for bad in [
r#"{"git": "https://x/y", "version": "^1", "path": "/abs"}"#,
r#"{"git": "https://x/y", "version": "^1", "path": "../escape"}"#,
] {
assert!(parse(bad).is_err(), "accepted `{bad}`");
}
}
#[test]
fn rejects_short_commit_selector() {
let err = parse(r#"{"git": "https://x/y", "commit": "abc123"}"#).unwrap_err();
assert!(
err.to_string()
.contains("must be exactly 40 lowercase hex characters"),
"wrong error: {err}"
);
}
#[test]
fn captures_unknown_fields() {
let dep =
parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "deprecated": true}"#)
.unwrap();
match dep {
DependencySource::Git { extra, .. } => {
assert_eq!(
extra.get("deprecated"),
Some(&serde_json::Value::Bool(true))
);
}
_ => panic!("expected Git source"),
}
}
#[test]
fn rejects_invalid_structures() {
for bad in [
r#"{"git": "https://x/y", "version": "^1", "tag": "v1"}"#,
r#"{"git": "https://x/y"}"#,
r#"{"path": "p", "version": "^1"}"#,
r#"{}"#,
] {
let err = parse(bad).unwrap_err();
assert!(
err.to_string().contains("dependency source is invalid"),
"wrong message for `{bad}`: {err}"
);
}
}
}