From c235c34caf2d844ab07dfe76efee88f20623a444 Mon Sep 17 00:00:00 2001 From: Paul Schifferer Date: Tue, 21 Jul 2026 14:06:51 -0700 Subject: [PATCH 1/2] feat(manifest): add required name field for plugin display names Hosts previously had no manifest-declared human-readable name to show in UI, forcing derivation from `id` (e.g. title-casing "bundesliga"). Plugins now declare `name` directly. Not marked breaking: the plugin API contract is still pre-1.0 and in flux. --- CHANGELOG.md | 5 ++++ src/manifest.rs | 47 +++++++++++++++++++++++++++++++++++- tests/fixtures/manifest.toml | 1 + 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f55f7..4f399db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ ## [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` + ## [0.1.1] - 2026-07-20 ### Added diff --git a/src/manifest.rs b/src/manifest.rs index e8dfaf5..5997ea6 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -19,6 +19,7 @@ use crate::version::Version; /// /// let toml = r#" /// id = "bundesliga" +/// name = "Bundesliga" /// version = "0.1.0" /// schema_version = "1.0" /// interface_version = "1.0" @@ -27,12 +28,17 @@ use crate::version::Version; /// /// let manifest = Manifest::parse(toml).unwrap(); /// assert_eq!(manifest.id, "bundesliga"); +/// assert_eq!(manifest.name, "Bundesliga"); /// assert_eq!(manifest.network_hosts, ["api.openligadb.de"]); /// ``` #[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). + pub name: String, /// Plugin's own release version (not a contract version). pub version: String, /// Canonical schema version this plugin's output targets. @@ -48,6 +54,8 @@ pub struct Manifest { pub enum ManifestField { /// The `id` field. Id, + /// The `name` field. + Name, /// The `version` field. Version, /// The `schema_version` field. @@ -62,6 +70,7 @@ 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", @@ -92,6 +101,7 @@ pub enum ManifestError { #[derive(Debug, Deserialize)] struct RawManifest { id: Option, + name: Option, version: Option, schema_version: Option, interface_version: Option, @@ -120,12 +130,20 @@ 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 network_hosts.iter().any(|host| host.trim().is_empty()) { return Err(ManifestError::InvalidField { field: ManifestField::NetworkHosts, @@ -135,6 +153,7 @@ impl Manifest { Ok(Self { id, + name, version, schema_version, interface_version, @@ -165,6 +184,7 @@ mod tests { fn valid_toml() -> &'static str { r#" id = "bundesliga" + name = "Bundesliga" version = "0.1.0" schema_version = "1.0" interface_version = "1.0" @@ -176,6 +196,7 @@ 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()]); } @@ -186,7 +207,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 +237,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 +257,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 +285,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 +298,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" diff --git a/tests/fixtures/manifest.toml b/tests/fixtures/manifest.toml index b78876a..b4594cf 100644 --- a/tests/fixtures/manifest.toml +++ b/tests/fixtures/manifest.toml @@ -1,4 +1,5 @@ id = "bundesliga" +name = "Bundesliga" version = "0.1.0" schema_version = "1.0" interface_version = "1.0" From a5026390b08e74e4e6b702cd65154991cca81d7f Mon Sep 17 00:00:00 2001 From: Paul Schifferer Date: Tue, 21 Jul 2026 14:11:45 -0700 Subject: [PATCH 2/2] feat(manifest): add localized display names via a [names] table Hosts need to show a plugin's name in the user's locale. Adds an optional [names] table (locale -> display name) and Manifest::localized_name(locale), which falls back to the required name field when no entry matches. Not marked breaking: still pre-1.0. --- CHANGELOG.md | 4 +- src/manifest.rs | 106 ++++++++++++++++++++++++++++++++++- tests/fixtures/manifest.toml | 5 ++ 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f399db..82e5542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ ### 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` + 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 diff --git a/src/manifest.rs b/src/manifest.rs index 5997ea6..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; @@ -24,21 +26,33 @@ use crate::version::Version; /// 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 + /// `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. @@ -49,6 +63,17 @@ 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 { @@ -64,6 +89,8 @@ pub enum ManifestField { InterfaceVersion, /// The `network_hosts` field. NetworkHosts, + /// The `[names]` table. + LocalizedNames, } impl core::fmt::Display for ManifestField { @@ -75,6 +102,7 @@ impl core::fmt::Display for ManifestField { Self::SchemaVersion => "schema_version", Self::InterfaceVersion => "interface_version", Self::NetworkHosts => "network_hosts", + Self::LocalizedNames => "names", }; f.write_str(name) } @@ -102,6 +130,8 @@ pub enum ManifestError { struct RawManifest { id: Option, name: Option, + #[serde(default)] + names: BTreeMap, version: Option, schema_version: Option, interface_version: Option, @@ -144,6 +174,17 @@ impl Manifest { }); } + 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, @@ -154,6 +195,7 @@ impl Manifest { Ok(Self { id, name, + localized_names: raw.names, version, schema_version, interface_version, @@ -189,6 +231,10 @@ mod tests { schema_version = "1.0" interface_version = "1.0" network_hosts = ["api.openligadb.de"] + + [names] + de = "Bundesliga" + fr = "Bundesliga" "# } @@ -199,6 +245,60 @@ mod tests { 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] @@ -312,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 b4594cf..43d3bf4 100644 --- a/tests/fixtures/manifest.toml +++ b/tests/fixtures/manifest.toml @@ -4,3 +4,8 @@ version = "0.1.0" schema_version = "1.0" interface_version = "1.0" network_hosts = ["api.openligadb.de"] + +[names] +de = "Bundesliga" +fr = "Bundesliga" +es = "Bundesliga"