Skip to main content

fulltime_plugin_api/
manifest.rs

1//! Plugin manifest format: the static, TOML-encoded file every plugin ships declaring its
2//! identity, targeted contract versions, and required network hosts.
3//!
4//! This module validates manifest structure and field presence/format only. It performs
5//! no host-side enforcement (network reachability, capability granting, enable/disable
6//! state) - that belongs to the plugin host runtime. See
7//! `openspec/changes/define-league-data-contract/specs/plugin-manifest-format/spec.md`.
8
9use std::collections::BTreeMap;
10
11use serde::Deserialize;
12
13use crate::version::Version;
14
15/// A parsed plugin manifest.
16///
17/// # Examples
18///
19/// ```
20/// use fulltime_plugin_api::Manifest;
21///
22/// let toml = r#"
23///     id = "bundesliga"
24///     name = "Bundesliga"
25///     version = "0.1.0"
26///     schema_version = "1.0"
27///     interface_version = "1.0"
28///     network_hosts = ["api.openligadb.de"]
29///
30///     [names]
31///     de = "Bundesliga"
32///     fr = "Bundesliga"
33/// "#;
34///
35/// let manifest = Manifest::parse(toml).unwrap();
36/// assert_eq!(manifest.id, "bundesliga");
37/// assert_eq!(manifest.name, "Bundesliga");
38/// assert_eq!(manifest.network_hosts, ["api.openligadb.de"]);
39/// assert_eq!(manifest.localized_name("de"), "Bundesliga");
40/// assert_eq!(manifest.localized_name("es"), "Bundesliga"); // falls back to `name`
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Manifest {
44    /// Plugin identifier, unique among plugins the host loads.
45    pub id: String,
46    /// Human-readable display name (e.g. `"Bundesliga"`), distinct from
47    /// `id`. A plugin manifest is the only place this is declared - hosts
48    /// must not derive a display name from `id` (e.g. by title-casing it).
49    /// Used as the fallback when no entry in `localized_names` matches the
50    /// host's current locale.
51    pub name: String,
52    /// Locale-keyed display names (e.g. `"de"` -> `"Bundesliga"`), from the
53    /// manifest's `[names]` table. Prefer [`Manifest::localized_name`] over
54    /// reading this directly, since it applies the fallback to `name`.
55    pub localized_names: BTreeMap<String, String>,
56    /// Plugin's own release version (not a contract version).
57    pub version: String,
58    /// Canonical schema version this plugin's output targets.
59    pub schema_version: Version,
60    /// Data-provider interface version this plugin was built against.
61    pub interface_version: Version,
62    /// Network hosts this plugin requires access to.
63    pub network_hosts: Vec<String>,
64}
65
66impl Manifest {
67    /// Returns the display name for `locale`, falling back to [`name`](Self::name)
68    /// if the manifest declares no entry for that locale in `[names]`.
69    #[must_use]
70    pub fn localized_name(&self, locale: &str) -> &str {
71        self.localized_names
72            .get(locale)
73            .map_or(self.name.as_str(), String::as_str)
74    }
75}
76
77/// A manifest field that failed presence or format validation.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ManifestField {
80    /// The `id` field.
81    Id,
82    /// The `name` field.
83    Name,
84    /// The `version` field.
85    Version,
86    /// The `schema_version` field.
87    SchemaVersion,
88    /// The `interface_version` field.
89    InterfaceVersion,
90    /// The `network_hosts` field.
91    NetworkHosts,
92    /// The `[names]` table.
93    LocalizedNames,
94}
95
96impl core::fmt::Display for ManifestField {
97    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
98        let name = match self {
99            Self::Id => "id",
100            Self::Name => "name",
101            Self::Version => "version",
102            Self::SchemaVersion => "schema_version",
103            Self::InterfaceVersion => "interface_version",
104            Self::NetworkHosts => "network_hosts",
105            Self::LocalizedNames => "names",
106        };
107        f.write_str(name)
108    }
109}
110
111/// Error returned when a manifest fails to parse.
112#[derive(Debug, thiserror::Error)]
113pub enum ManifestError {
114    /// The manifest is not well-formed TOML.
115    #[error("manifest is not valid TOML: {0}")]
116    Malformed(#[from] toml::de::Error),
117
118    /// A required field is missing, or a present field has an invalid format.
119    #[error("invalid manifest field {field}: {reason}")]
120    InvalidField {
121        /// The field that failed validation.
122        field: ManifestField,
123        /// Human-readable reason, safe to surface to a plugin author.
124        reason: String,
125    },
126}
127
128/// Raw, unvalidated manifest shape as it appears on disk.
129#[derive(Debug, Deserialize)]
130struct RawManifest {
131    id: Option<String>,
132    name: Option<String>,
133    #[serde(default)]
134    names: BTreeMap<String, String>,
135    version: Option<String>,
136    schema_version: Option<String>,
137    interface_version: Option<String>,
138    network_hosts: Option<Vec<String>>,
139}
140
141impl Manifest {
142    /// Parses and validates a manifest from its TOML source.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`ManifestError::Malformed`] if `source` is not valid TOML, or
147    /// [`ManifestError::InvalidField`] if a required field is missing or a version field
148    /// is not a valid `"major.minor"` string. No network host in `network_hosts` is
149    /// contacted or otherwise validated beyond being a non-empty string.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use fulltime_plugin_api::{Manifest, ManifestError};
155    ///
156    /// let err = Manifest::parse("id = \"x\"").unwrap_err();
157    /// assert!(matches!(err, ManifestError::InvalidField { .. }));
158    /// ```
159    pub fn parse(source: &str) -> Result<Self, ManifestError> {
160        let raw: RawManifest = toml::from_str(source)?;
161
162        let id = required(raw.id, ManifestField::Id)?;
163        let name = required(raw.name, ManifestField::Name)?;
164        let version = required(raw.version, ManifestField::Version)?;
165        let schema_version = parse_version(raw.schema_version, ManifestField::SchemaVersion)?;
166        let interface_version =
167            parse_version(raw.interface_version, ManifestField::InterfaceVersion)?;
168        let network_hosts = required(raw.network_hosts, ManifestField::NetworkHosts)?;
169
170        if name.trim().is_empty() {
171            return Err(ManifestError::InvalidField {
172                field: ManifestField::Name,
173                reason: "name must not be empty".to_owned(),
174            });
175        }
176
177        if raw
178            .names
179            .values()
180            .any(|localized_name| localized_name.trim().is_empty())
181        {
182            return Err(ManifestError::InvalidField {
183                field: ManifestField::LocalizedNames,
184                reason: "[names] entries must not be empty".to_owned(),
185            });
186        }
187
188        if network_hosts.iter().any(|host| host.trim().is_empty()) {
189            return Err(ManifestError::InvalidField {
190                field: ManifestField::NetworkHosts,
191                reason: "network_hosts entries must not be empty".to_owned(),
192            });
193        }
194
195        Ok(Self {
196            id,
197            name,
198            localized_names: raw.names,
199            version,
200            schema_version,
201            interface_version,
202            network_hosts,
203        })
204    }
205}
206
207fn required<T>(value: Option<T>, field: ManifestField) -> Result<T, ManifestError> {
208    value.ok_or_else(|| ManifestError::InvalidField {
209        field,
210        reason: "field is required".to_owned(),
211    })
212}
213
214fn parse_version(value: Option<String>, field: ManifestField) -> Result<Version, ManifestError> {
215    let raw = required(value, field)?;
216    raw.parse().map_err(|_| ManifestError::InvalidField {
217        field,
218        reason: format!("{raw:?} is not a valid \"major.minor\" version"),
219    })
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn valid_toml() -> &'static str {
227        r#"
228            id = "bundesliga"
229            name = "Bundesliga"
230            version = "0.1.0"
231            schema_version = "1.0"
232            interface_version = "1.0"
233            network_hosts = ["api.openligadb.de"]
234
235            [names]
236            de = "Bundesliga"
237            fr = "Bundesliga"
238        "#
239    }
240
241    #[test]
242    fn parses_a_well_formed_manifest() {
243        let manifest = Manifest::parse(valid_toml()).unwrap();
244        assert_eq!(manifest.id, "bundesliga");
245        assert_eq!(manifest.name, "Bundesliga");
246        assert_eq!(manifest.schema_version, Version::new(1, 0));
247        assert_eq!(manifest.network_hosts, vec!["api.openligadb.de".to_owned()]);
248        assert_eq!(
249            manifest.localized_names.get("de"),
250            Some(&"Bundesliga".to_owned())
251        );
252    }
253
254    #[test]
255    fn localized_name_returns_locale_specific_value() {
256        let manifest = Manifest::parse(valid_toml()).unwrap();
257        assert_eq!(manifest.localized_name("fr"), "Bundesliga");
258    }
259
260    #[test]
261    fn localized_name_falls_back_to_name_when_locale_is_missing() {
262        let manifest = Manifest::parse(valid_toml()).unwrap();
263        assert_eq!(manifest.localized_name("es"), manifest.name);
264    }
265
266    #[test]
267    fn parses_a_manifest_with_no_names_table() {
268        let toml = r#"
269            id = "bundesliga"
270            name = "Bundesliga"
271            version = "0.1.0"
272            schema_version = "1.0"
273            interface_version = "1.0"
274            network_hosts = ["api.openligadb.de"]
275        "#;
276        let manifest = Manifest::parse(toml).unwrap();
277        assert!(manifest.localized_names.is_empty());
278        assert_eq!(manifest.localized_name("de"), "Bundesliga");
279    }
280
281    #[test]
282    fn rejects_empty_localized_name_value() {
283        let toml = r#"
284            id = "bundesliga"
285            name = "Bundesliga"
286            version = "0.1.0"
287            schema_version = "1.0"
288            interface_version = "1.0"
289            network_hosts = ["api.openligadb.de"]
290
291            [names]
292            de = "   "
293        "#;
294        let err = Manifest::parse(toml).unwrap_err();
295        assert!(matches!(
296            err,
297            ManifestError::InvalidField {
298                field: ManifestField::LocalizedNames,
299                ..
300            }
301        ));
302    }
303
304    #[test]
305    fn rejects_missing_required_field() {
306        let err = Manifest::parse("id = \"x\"").unwrap_err();
307        assert!(matches!(
308            err,
309            ManifestError::InvalidField {
310                field: ManifestField::Name,
311                ..
312            }
313        ));
314    }
315
316    #[test]
317    fn rejects_empty_name() {
318        let toml = r#"
319            id = "bundesliga"
320            name = "   "
321            version = "0.1.0"
322            schema_version = "1.0"
323            interface_version = "1.0"
324            network_hosts = ["api.openligadb.de"]
325        "#;
326        let err = Manifest::parse(toml).unwrap_err();
327        assert!(matches!(
328            err,
329            ManifestError::InvalidField {
330                field: ManifestField::Name,
331                ..
332            }
333        ));
334    }
335
336    #[test]
337    fn rejects_malformed_version_string() {
338        let toml = r#"
339            id = "bundesliga"
340            name = "Bundesliga"
341            version = "0.1.0"
342            schema_version = "not-a-version"
343            interface_version = "1.0"
344            network_hosts = ["api.openligadb.de"]
345        "#;
346        let err = Manifest::parse(toml).unwrap_err();
347        assert!(matches!(
348            err,
349            ManifestError::InvalidField {
350                field: ManifestField::SchemaVersion,
351                ..
352            }
353        ));
354    }
355
356    #[test]
357    fn rejects_empty_network_host_entry() {
358        let toml = r#"
359            id = "bundesliga"
360            name = "Bundesliga"
361            version = "0.1.0"
362            schema_version = "1.0"
363            interface_version = "1.0"
364            network_hosts = [""]
365        "#;
366        let err = Manifest::parse(toml).unwrap_err();
367        assert!(matches!(
368            err,
369            ManifestError::InvalidField {
370                field: ManifestField::NetworkHosts,
371                ..
372            }
373        ));
374    }
375
376    #[test]
377    fn rejects_malformed_toml() {
378        let err = Manifest::parse("not = [valid").unwrap_err();
379        assert!(matches!(err, ManifestError::Malformed(_)));
380    }
381
382    #[test]
383    fn does_not_contact_declared_network_hosts() {
384        // Parsing a manifest declaring an unreachable/nonexistent host must still succeed;
385        // this crate performs format validation only.
386        let toml = r#"
387            id = "x"
388            name = "X"
389            version = "0.1.0"
390            schema_version = "1.0"
391            interface_version = "1.0"
392            network_hosts = ["definitely-not-a-real-host.invalid"]
393        "#;
394        assert!(Manifest::parse(toml).is_ok());
395    }
396
397    #[test]
398    fn interface_version_2_0_is_accepted_by_the_current_interface_version() {
399        let toml = r#"
400            id = "bundesliga"
401            name = "Bundesliga"
402            version = "0.1.0"
403            schema_version = "1.0"
404            interface_version = "2.0"
405            network_hosts = ["api.openligadb.de"]
406        "#;
407        let manifest = Manifest::parse(toml).unwrap();
408        assert_eq!(manifest.interface_version, Version::new(2, 0));
409        assert!(crate::INTERFACE_VERSION.accepts(manifest.interface_version));
410    }
411
412    #[test]
413    fn interface_version_1_0_is_rejected_after_the_host_fetch_major_bump() {
414        // A plugin built before `host.fetch` existed declares interface_version 1.0; the
415        // host's INTERFACE_VERSION is now 2.0 (major bump), so it must not accept it - see
416        // openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md
417        // ("Plugin built before the host-fetch import existed").
418        let manifest = Manifest::parse(valid_toml()).unwrap();
419        assert_eq!(manifest.interface_version, Version::new(1, 0));
420        assert!(!crate::INTERFACE_VERSION.accepts(manifest.interface_version));
421    }
422}