diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f55f7..82e5542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ ## [Unreleased] +### Added + +- Add a required `name` field to `Manifest` for a plugin's human-readable display name, + distinct from `id` (hosts must not derive a display name from `id`) +- Add an optional `[names]` table for locale-keyed display names, and + `Manifest::localized_name` to look one up with fallback to `name` + ## [0.1.1] - 2026-07-20 ### Added diff --git a/src/manifest.rs b/src/manifest.rs index e8dfaf5..f0e75a4 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -3,9 +3,11 @@ //! //! This module validates manifest structure and field presence/format only. It performs //! no host-side enforcement (network reachability, capability granting, enable/disable -//! state) — that belongs to the plugin host runtime. See +//! state) - that belongs to the plugin host runtime. See //! `openspec/changes/define-league-data-contract/specs/plugin-manifest-format/spec.md`. +use std::collections::BTreeMap; + use serde::Deserialize; use crate::version::Version; @@ -19,20 +21,38 @@ use crate::version::Version; /// /// 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 = "Bundesliga" +/// fr = "Bundesliga" /// "#; /// /// let manifest = Manifest::parse(toml).unwrap(); /// assert_eq!(manifest.id, "bundesliga"); +/// assert_eq!(manifest.name, "Bundesliga"); /// assert_eq!(manifest.network_hosts, ["api.openligadb.de"]); +/// assert_eq!(manifest.localized_name("de"), "Bundesliga"); +/// assert_eq!(manifest.localized_name("es"), "Bundesliga"); // falls back to `name` /// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct Manifest { /// Plugin identifier, unique among plugins the host loads. pub id: String, + /// Human-readable display name (e.g. `"Bundesliga"`), distinct from + /// `id`. A plugin manifest is the only place this is declared - hosts + /// must not derive a display name from `id` (e.g. by title-casing it). + /// Used as the fallback when no entry in `localized_names` matches the + /// host's current locale. + pub name: String, + /// Locale-keyed display names (e.g. `"de"` -> `"Bundesliga"`), from the + /// manifest's `[names]` table. Prefer [`Manifest::localized_name`] over + /// reading this directly, since it applies the fallback to `name`. + pub localized_names: BTreeMap, /// Plugin's own release version (not a contract version). pub version: String, /// Canonical schema version this plugin's output targets. @@ -43,11 +63,24 @@ pub struct Manifest { pub network_hosts: Vec, } +impl Manifest { + /// Returns the display name for `locale`, falling back to [`name`](Self::name) + /// if the manifest declares no entry for that locale in `[names]`. + #[must_use] + pub fn localized_name(&self, locale: &str) -> &str { + self.localized_names + .get(locale) + .map_or(self.name.as_str(), String::as_str) + } +} + /// A manifest field that failed presence or format validation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ManifestField { /// The `id` field. Id, + /// The `name` field. + Name, /// The `version` field. Version, /// The `schema_version` field. @@ -56,16 +89,20 @@ pub enum ManifestField { InterfaceVersion, /// The `network_hosts` field. NetworkHosts, + /// The `[names]` table. + 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) } @@ -92,6 +129,9 @@ pub enum ManifestError { #[derive(Debug, Deserialize)] struct RawManifest { id: Option, + name: Option, + #[serde(default)] + names: BTreeMap, version: Option, schema_version: Option, interface_version: Option, @@ -120,12 +160,31 @@ impl Manifest { 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, @@ -135,6 +194,8 @@ impl Manifest { Ok(Self { id, + name, + localized_names: raw.names, version, schema_version, interface_version, @@ -165,10 +226,15 @@ mod tests { 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" "# } @@ -176,8 +242,63 @@ mod tests { 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] @@ -186,7 +307,27 @@ mod tests { assert!(matches!( err, ManifestError::InvalidField { - field: ManifestField::Version, + 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, .. } )); @@ -196,6 +337,7 @@ mod tests { 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" @@ -215,6 +357,7 @@ mod tests { 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" @@ -242,6 +385,7 @@ mod tests { // this crate performs format validation only. let toml = r#" id = "x" + name = "X" version = "0.1.0" schema_version = "1.0" interface_version = "1.0" @@ -254,6 +398,7 @@ mod tests { 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" @@ -267,7 +412,7 @@ mod tests { #[test] fn interface_version_1_0_is_rejected_after_the_host_fetch_major_bump() { // A plugin built before `host.fetch` existed declares interface_version 1.0; the - // host's INTERFACE_VERSION is now 2.0 (major bump), so it must not accept it — see + // host's INTERFACE_VERSION is now 2.0 (major bump), so it must not accept it - see // openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md // ("Plugin built before the host-fetch import existed"). let manifest = Manifest::parse(valid_toml()).unwrap(); diff --git a/tests/fixtures/manifest.toml b/tests/fixtures/manifest.toml index b78876a..43d3bf4 100644 --- a/tests/fixtures/manifest.toml +++ b/tests/fixtures/manifest.toml @@ -1,5 +1,11 @@ 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" +es = "Bundesliga"