use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::PkgError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Package {
pub name: String,
pub version: String,
pub entry: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Dep {
pub git: String,
pub tag: Option<String>,
pub rev: Option<String>,
pub branch: Option<String>,
pub entry: Option<PathBuf>,
}
impl Dep {
fn validate_ref_exclusivity(&self, dep_name: &str) -> Result<(), PkgError> {
let count = [
self.tag.is_some(),
self.rev.is_some(),
self.branch.is_some(),
]
.into_iter()
.filter(|&b| b)
.count();
if count > 1 {
return Err(PkgError::Validation {
message: format!(
"dep '{dep_name}': only one of `tag`, `rev`, `branch` may be specified, \
but multiple are set"
),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub package: Package,
#[serde(default)]
pub deps: HashMap<String, Dep>,
}
impl Manifest {
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, PkgError> {
let content = fs::read_to_string(path)?;
let manifest: Self = toml::from_str(&content)?;
manifest.validate()?;
Ok(manifest)
}
fn validate(&self) -> Result<(), PkgError> {
for (name, dep) in &self.deps {
dep.validate_ref_exclusivity(name)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn temp_manifest(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn consumer_happy_path() {
let toml = r#"
[package]
name = "my-app"
version = "0.1.0"
[deps]
foo = { git = "https://github.com/x/foo", tag = "v1.2.0" }
bar = { git = "https://github.com/y/bar", rev = "abc123" }
baz = { git = "https://github.com/z/baz", branch = "main" }
[deps.qux]
git = "https://github.com/q/qux"
tag = "v2.0.0"
entry = "lib"
"#;
let f = temp_manifest(toml);
let m = Manifest::from_path(f.path()).unwrap();
assert_eq!(m.package.name, "my-app");
assert_eq!(m.package.version, "0.1.0");
assert!(m.package.entry.is_none());
assert_eq!(m.deps.len(), 4);
let foo = &m.deps["foo"];
assert_eq!(foo.git, "https://github.com/x/foo");
assert_eq!(foo.tag.as_deref(), Some("v1.2.0"));
assert!(foo.rev.is_none());
assert!(foo.branch.is_none());
assert!(foo.entry.is_none());
let bar = &m.deps["bar"];
assert_eq!(bar.rev.as_deref(), Some("abc123"));
assert!(bar.tag.is_none());
let baz = &m.deps["baz"];
assert_eq!(baz.branch.as_deref(), Some("main"));
assert!(baz.tag.is_none());
let qux = &m.deps["qux"];
assert_eq!(qux.tag.as_deref(), Some("v2.0.0"));
assert_eq!(qux.entry, Some(PathBuf::from("lib")));
}
#[test]
fn author_happy_path() {
let toml = r#"
[package]
name = "foo"
version = "1.2.0"
entry = "src"
"#;
let f = temp_manifest(toml);
let m = Manifest::from_path(f.path()).unwrap();
assert_eq!(m.package.name, "foo");
assert_eq!(m.package.version, "1.2.0");
assert_eq!(m.package.entry, Some(PathBuf::from("src")));
assert!(m.deps.is_empty());
}
#[test]
fn tag_and_rev_mutually_exclusive() {
let toml = r#"
[package]
name = "my-app"
version = "0.1.0"
[deps.bad]
git = "https://github.com/x/bad"
tag = "v1.0.0"
rev = "abc123"
"#;
let f = temp_manifest(toml);
let err = Manifest::from_path(f.path()).unwrap_err();
assert!(
matches!(err, PkgError::Validation { .. }),
"expected Validation error, got: {err}"
);
assert!(err.to_string().contains("bad"));
}
#[test]
fn invalid_toml_returns_parse_error() {
let toml = "this is not valid = [ toml";
let f = temp_manifest(toml);
let err = Manifest::from_path(f.path()).unwrap_err();
assert!(
matches!(err, PkgError::ManifestParse { .. }),
"expected ManifestParse error, got: {err}"
);
}
#[test]
fn missing_package_section_returns_parse_error() {
let toml = r#"
[deps]
foo = { git = "https://github.com/x/foo", tag = "v1.0.0" }
"#;
let f = temp_manifest(toml);
let err = Manifest::from_path(f.path()).unwrap_err();
assert!(
matches!(err, PkgError::ManifestParse { .. }),
"expected ManifestParse error for missing [package], got: {err}"
);
}
#[test]
fn round_trip_serialize_deserialize() {
let original = Manifest {
package: Package {
name: "roundtrip".into(),
version: "0.1.0".into(),
entry: None,
},
deps: {
let mut m = HashMap::new();
m.insert(
"lib".into(),
Dep {
git: "https://github.com/x/lib".into(),
tag: Some("v1.0.0".into()),
rev: None,
branch: None,
entry: None,
},
);
m
},
};
let serialized = toml::to_string(&original).unwrap();
let deserialized: Manifest = toml::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn all_three_ref_fields_is_validation_error() {
let toml = r#"
[package]
name = "my-app"
version = "0.1.0"
[deps.oops]
git = "https://github.com/x/oops"
tag = "v1.0.0"
rev = "abc123"
branch = "main"
"#;
let f = temp_manifest(toml);
let err = Manifest::from_path(f.path()).unwrap_err();
assert!(matches!(err, PkgError::Validation { .. }));
}
#[test]
fn unknown_field_in_package_is_rejected() {
let toml = r#"
[package]
name = "my-app"
version = "0.1.0"
unknown = "should-fail"
"#;
let f = temp_manifest(toml);
let err = Manifest::from_path(f.path()).unwrap_err();
assert!(
matches!(err, PkgError::ManifestParse { .. }),
"expected ManifestParse error for unknown field, got: {err}"
);
}
}