use std::collections::BTreeMap;
use serde::Deserialize;
use crate::version::Version;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub id: String,
pub name: String,
pub localized_names: BTreeMap<String, String>,
pub version: String,
pub schema_version: Version,
pub interface_version: Version,
pub network_hosts: Vec<String>,
}
impl Manifest {
#[must_use]
pub fn localized_name(&self, locale: &str) -> &str {
self.localized_names
.get(locale)
.map_or(self.name.as_str(), String::as_str)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManifestField {
Id,
Name,
Version,
SchemaVersion,
InterfaceVersion,
NetworkHosts,
LocalizedNames,
}
impl core::fmt::Display for ManifestField {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match self {
Self::Id => "id",
Self::Name => "name",
Self::Version => "version",
Self::SchemaVersion => "schema_version",
Self::InterfaceVersion => "interface_version",
Self::NetworkHosts => "network_hosts",
Self::LocalizedNames => "names",
};
f.write_str(name)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("manifest is not valid TOML: {0}")]
Malformed(#[from] toml::de::Error),
#[error("invalid manifest field {field}: {reason}")]
InvalidField {
field: ManifestField,
reason: String,
},
}
#[derive(Debug, Deserialize)]
struct RawManifest {
id: Option<String>,
name: Option<String>,
#[serde(default)]
names: BTreeMap<String, String>,
version: Option<String>,
schema_version: Option<String>,
interface_version: Option<String>,
network_hosts: Option<Vec<String>>,
}
impl Manifest {
pub fn parse(source: &str) -> Result<Self, ManifestError> {
let raw: RawManifest = toml::from_str(source)?;
let id = required(raw.id, ManifestField::Id)?;
let name = required(raw.name, ManifestField::Name)?;
let version = required(raw.version, ManifestField::Version)?;
let schema_version = parse_version(raw.schema_version, ManifestField::SchemaVersion)?;
let interface_version =
parse_version(raw.interface_version, ManifestField::InterfaceVersion)?;
let network_hosts = required(raw.network_hosts, ManifestField::NetworkHosts)?;
if name.trim().is_empty() {
return Err(ManifestError::InvalidField {
field: ManifestField::Name,
reason: "name must not be empty".to_owned(),
});
}
if raw
.names
.values()
.any(|localized_name| localized_name.trim().is_empty())
{
return Err(ManifestError::InvalidField {
field: ManifestField::LocalizedNames,
reason: "[names] entries must not be empty".to_owned(),
});
}
if network_hosts.iter().any(|host| host.trim().is_empty()) {
return Err(ManifestError::InvalidField {
field: ManifestField::NetworkHosts,
reason: "network_hosts entries must not be empty".to_owned(),
});
}
Ok(Self {
id,
name,
localized_names: raw.names,
version,
schema_version,
interface_version,
network_hosts,
})
}
}
fn required<T>(value: Option<T>, field: ManifestField) -> Result<T, ManifestError> {
value.ok_or_else(|| ManifestError::InvalidField {
field,
reason: "field is required".to_owned(),
})
}
fn parse_version(value: Option<String>, field: ManifestField) -> Result<Version, ManifestError> {
let raw = required(value, field)?;
raw.parse().map_err(|_| ManifestError::InvalidField {
field,
reason: format!("{raw:?} is not a valid \"major.minor\" version"),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_toml() -> &'static str {
r#"
id = "bundesliga"
name = "Bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = ["api.openligadb.de"]
[names]
de = "Bundesliga"
fr = "Bundesliga"
"#
}
#[test]
fn parses_a_well_formed_manifest() {
let manifest = Manifest::parse(valid_toml()).unwrap();
assert_eq!(manifest.id, "bundesliga");
assert_eq!(manifest.name, "Bundesliga");
assert_eq!(manifest.schema_version, Version::new(1, 0));
assert_eq!(manifest.network_hosts, vec!["api.openligadb.de".to_owned()]);
assert_eq!(
manifest.localized_names.get("de"),
Some(&"Bundesliga".to_owned())
);
}
#[test]
fn localized_name_returns_locale_specific_value() {
let manifest = Manifest::parse(valid_toml()).unwrap();
assert_eq!(manifest.localized_name("fr"), "Bundesliga");
}
#[test]
fn localized_name_falls_back_to_name_when_locale_is_missing() {
let manifest = Manifest::parse(valid_toml()).unwrap();
assert_eq!(manifest.localized_name("es"), manifest.name);
}
#[test]
fn parses_a_manifest_with_no_names_table() {
let toml = r#"
id = "bundesliga"
name = "Bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = ["api.openligadb.de"]
"#;
let manifest = Manifest::parse(toml).unwrap();
assert!(manifest.localized_names.is_empty());
assert_eq!(manifest.localized_name("de"), "Bundesliga");
}
#[test]
fn rejects_empty_localized_name_value() {
let toml = r#"
id = "bundesliga"
name = "Bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = ["api.openligadb.de"]
[names]
de = " "
"#;
let err = Manifest::parse(toml).unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::LocalizedNames,
..
}
));
}
#[test]
fn rejects_missing_required_field() {
let err = Manifest::parse("id = \"x\"").unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::Name,
..
}
));
}
#[test]
fn rejects_empty_name() {
let toml = r#"
id = "bundesliga"
name = " "
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = ["api.openligadb.de"]
"#;
let err = Manifest::parse(toml).unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::Name,
..
}
));
}
#[test]
fn rejects_malformed_version_string() {
let toml = r#"
id = "bundesliga"
name = "Bundesliga"
version = "0.1.0"
schema_version = "not-a-version"
interface_version = "1.0"
network_hosts = ["api.openligadb.de"]
"#;
let err = Manifest::parse(toml).unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::SchemaVersion,
..
}
));
}
#[test]
fn rejects_empty_network_host_entry() {
let toml = r#"
id = "bundesliga"
name = "Bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = [""]
"#;
let err = Manifest::parse(toml).unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::NetworkHosts,
..
}
));
}
#[test]
fn rejects_malformed_toml() {
let err = Manifest::parse("not = [valid").unwrap_err();
assert!(matches!(err, ManifestError::Malformed(_)));
}
#[test]
fn does_not_contact_declared_network_hosts() {
let toml = r#"
id = "x"
name = "X"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = ["definitely-not-a-real-host.invalid"]
"#;
assert!(Manifest::parse(toml).is_ok());
}
#[test]
fn interface_version_2_0_is_accepted_by_the_current_interface_version() {
let toml = r#"
id = "bundesliga"
name = "Bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "2.0"
network_hosts = ["api.openligadb.de"]
"#;
let manifest = Manifest::parse(toml).unwrap();
assert_eq!(manifest.interface_version, Version::new(2, 0));
assert!(crate::INTERFACE_VERSION.accepts(manifest.interface_version));
}
#[test]
fn interface_version_1_0_is_rejected_after_the_host_fetch_major_bump() {
let manifest = Manifest::parse(valid_toml()).unwrap();
assert_eq!(manifest.interface_version, Version::new(1, 0));
assert!(!crate::INTERFACE_VERSION.accepts(manifest.interface_version));
}
}