fulltime_plugin_api/
manifest.rs1use serde::Deserialize;
10
11use crate::version::Version;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Manifest {
34 pub id: String,
36 pub version: String,
38 pub schema_version: Version,
40 pub interface_version: Version,
42 pub network_hosts: Vec<String>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ManifestField {
49 Id,
51 Version,
53 SchemaVersion,
55 InterfaceVersion,
57 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#[derive(Debug, thiserror::Error)]
76pub enum ManifestError {
77 #[error("manifest is not valid TOML: {0}")]
79 Malformed(#[from] toml::de::Error),
80
81 #[error("invalid manifest field {field}: {reason}")]
83 InvalidField {
84 field: ManifestField,
86 reason: String,
88 },
89}
90
91#[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 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 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 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}