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 serde::Deserialize;
10
11use crate::version::Version;
12
13/// A parsed plugin manifest.
14///
15/// # Examples
16///
17/// ```
18/// use fulltime_plugin_api::Manifest;
19///
20/// let toml = r#"
21///     id = "bundesliga"
22///     version = "0.1.0"
23///     schema_version = "1.0"
24///     interface_version = "1.0"
25///     network_hosts = ["api.openligadb.de"]
26/// "#;
27///
28/// let manifest = Manifest::parse(toml).unwrap();
29/// assert_eq!(manifest.id, "bundesliga");
30/// assert_eq!(manifest.network_hosts, ["api.openligadb.de"]);
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Manifest {
34    /// Plugin identifier, unique among plugins the host loads.
35    pub id: String,
36    /// Plugin's own release version (not a contract version).
37    pub version: String,
38    /// Canonical schema version this plugin's output targets.
39    pub schema_version: Version,
40    /// Data-provider interface version this plugin was built against.
41    pub interface_version: Version,
42    /// Network hosts this plugin requires access to.
43    pub network_hosts: Vec<String>,
44}
45
46/// A manifest field that failed presence or format validation.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ManifestField {
49    /// The `id` field.
50    Id,
51    /// The `version` field.
52    Version,
53    /// The `schema_version` field.
54    SchemaVersion,
55    /// The `interface_version` field.
56    InterfaceVersion,
57    /// The `network_hosts` field.
58    NetworkHosts,
59}
60
61impl core::fmt::Display for ManifestField {
62    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63        let name = match self {
64            Self::Id => "id",
65            Self::Version => "version",
66            Self::SchemaVersion => "schema_version",
67            Self::InterfaceVersion => "interface_version",
68            Self::NetworkHosts => "network_hosts",
69        };
70        f.write_str(name)
71    }
72}
73
74/// Error returned when a manifest fails to parse.
75#[derive(Debug, thiserror::Error)]
76pub enum ManifestError {
77    /// The manifest is not well-formed TOML.
78    #[error("manifest is not valid TOML: {0}")]
79    Malformed(#[from] toml::de::Error),
80
81    /// A required field is missing, or a present field has an invalid format.
82    #[error("invalid manifest field {field}: {reason}")]
83    InvalidField {
84        /// The field that failed validation.
85        field: ManifestField,
86        /// Human-readable reason, safe to surface to a plugin author.
87        reason: String,
88    },
89}
90
91/// Raw, unvalidated manifest shape as it appears on disk.
92#[derive(Debug, Deserialize)]
93struct RawManifest {
94    id: Option<String>,
95    version: Option<String>,
96    schema_version: Option<String>,
97    interface_version: Option<String>,
98    network_hosts: Option<Vec<String>>,
99}
100
101impl Manifest {
102    /// Parses and validates a manifest from its TOML source.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`ManifestError::Malformed`] if `source` is not valid TOML, or
107    /// [`ManifestError::InvalidField`] if a required field is missing or a version field
108    /// is not a valid `"major.minor"` string. No network host in `network_hosts` is
109    /// contacted or otherwise validated beyond being a non-empty string.
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use fulltime_plugin_api::{Manifest, ManifestError};
115    ///
116    /// let err = Manifest::parse("id = \"x\"").unwrap_err();
117    /// assert!(matches!(err, ManifestError::InvalidField { .. }));
118    /// ```
119    pub fn parse(source: &str) -> Result<Self, ManifestError> {
120        let raw: RawManifest = toml::from_str(source)?;
121
122        let id = required(raw.id, ManifestField::Id)?;
123        let version = required(raw.version, ManifestField::Version)?;
124        let schema_version = parse_version(raw.schema_version, ManifestField::SchemaVersion)?;
125        let interface_version =
126            parse_version(raw.interface_version, ManifestField::InterfaceVersion)?;
127        let network_hosts = required(raw.network_hosts, ManifestField::NetworkHosts)?;
128
129        if network_hosts.iter().any(|host| host.trim().is_empty()) {
130            return Err(ManifestError::InvalidField {
131                field: ManifestField::NetworkHosts,
132                reason: "network_hosts entries must not be empty".to_owned(),
133            });
134        }
135
136        Ok(Self {
137            id,
138            version,
139            schema_version,
140            interface_version,
141            network_hosts,
142        })
143    }
144}
145
146fn required<T>(value: Option<T>, field: ManifestField) -> Result<T, ManifestError> {
147    value.ok_or_else(|| ManifestError::InvalidField {
148        field,
149        reason: "field is required".to_owned(),
150    })
151}
152
153fn parse_version(value: Option<String>, field: ManifestField) -> Result<Version, ManifestError> {
154    let raw = required(value, field)?;
155    raw.parse().map_err(|_| ManifestError::InvalidField {
156        field,
157        reason: format!("{raw:?} is not a valid \"major.minor\" version"),
158    })
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn valid_toml() -> &'static str {
166        r#"
167            id = "bundesliga"
168            version = "0.1.0"
169            schema_version = "1.0"
170            interface_version = "1.0"
171            network_hosts = ["api.openligadb.de"]
172        "#
173    }
174
175    #[test]
176    fn parses_a_well_formed_manifest() {
177        let manifest = Manifest::parse(valid_toml()).unwrap();
178        assert_eq!(manifest.id, "bundesliga");
179        assert_eq!(manifest.schema_version, Version::new(1, 0));
180        assert_eq!(manifest.network_hosts, vec!["api.openligadb.de".to_owned()]);
181    }
182
183    #[test]
184    fn rejects_missing_required_field() {
185        let err = Manifest::parse("id = \"x\"").unwrap_err();
186        assert!(matches!(
187            err,
188            ManifestError::InvalidField {
189                field: ManifestField::Version,
190                ..
191            }
192        ));
193    }
194
195    #[test]
196    fn rejects_malformed_version_string() {
197        let toml = r#"
198            id = "bundesliga"
199            version = "0.1.0"
200            schema_version = "not-a-version"
201            interface_version = "1.0"
202            network_hosts = ["api.openligadb.de"]
203        "#;
204        let err = Manifest::parse(toml).unwrap_err();
205        assert!(matches!(
206            err,
207            ManifestError::InvalidField {
208                field: ManifestField::SchemaVersion,
209                ..
210            }
211        ));
212    }
213
214    #[test]
215    fn rejects_empty_network_host_entry() {
216        let toml = r#"
217            id = "bundesliga"
218            version = "0.1.0"
219            schema_version = "1.0"
220            interface_version = "1.0"
221            network_hosts = [""]
222        "#;
223        let err = Manifest::parse(toml).unwrap_err();
224        assert!(matches!(
225            err,
226            ManifestError::InvalidField {
227                field: ManifestField::NetworkHosts,
228                ..
229            }
230        ));
231    }
232
233    #[test]
234    fn rejects_malformed_toml() {
235        let err = Manifest::parse("not = [valid").unwrap_err();
236        assert!(matches!(err, ManifestError::Malformed(_)));
237    }
238
239    #[test]
240    fn does_not_contact_declared_network_hosts() {
241        // Parsing a manifest declaring an unreachable/nonexistent host must still succeed;
242        // this crate performs format validation only.
243        let toml = r#"
244            id = "x"
245            version = "0.1.0"
246            schema_version = "1.0"
247            interface_version = "1.0"
248            network_hosts = ["definitely-not-a-real-host.invalid"]
249        "#;
250        assert!(Manifest::parse(toml).is_ok());
251    }
252
253    #[test]
254    fn interface_version_2_0_is_accepted_by_the_current_interface_version() {
255        let toml = r#"
256            id = "bundesliga"
257            version = "0.1.0"
258            schema_version = "1.0"
259            interface_version = "2.0"
260            network_hosts = ["api.openligadb.de"]
261        "#;
262        let manifest = Manifest::parse(toml).unwrap();
263        assert_eq!(manifest.interface_version, Version::new(2, 0));
264        assert!(crate::INTERFACE_VERSION.accepts(manifest.interface_version));
265    }
266
267    #[test]
268    fn interface_version_1_0_is_rejected_after_the_host_fetch_major_bump() {
269        // A plugin built before `host.fetch` existed declares interface_version 1.0; the
270        // host's INTERFACE_VERSION is now 2.0 (major bump), so it must not accept it — see
271        // openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md
272        // ("Plugin built before the host-fetch import existed").
273        let manifest = Manifest::parse(valid_toml()).unwrap();
274        assert_eq!(manifest.interface_version, Version::new(1, 0));
275        assert!(!crate::INTERFACE_VERSION.accepts(manifest.interface_version));
276    }
277}