1use std::collections::BTreeMap;
10
11use serde::Deserialize;
12
13use crate::version::Version;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Manifest {
44 pub id: String,
46 pub name: String,
52 pub localized_names: BTreeMap<String, String>,
56 pub version: String,
58 pub schema_version: Version,
60 pub interface_version: Version,
62 pub network_hosts: Vec<String>,
64}
65
66impl Manifest {
67 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ManifestField {
80 Id,
82 Name,
84 Version,
86 SchemaVersion,
88 InterfaceVersion,
90 NetworkHosts,
92 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#[derive(Debug, thiserror::Error)]
113pub enum ManifestError {
114 #[error("manifest is not valid TOML: {0}")]
116 Malformed(#[from] toml::de::Error),
117
118 #[error("invalid manifest field {field}: {reason}")]
120 InvalidField {
121 field: ManifestField,
123 reason: String,
125 },
126}
127
128#[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 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 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 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}