//! On-disk theme manifest schema (`.theme.ron`). //! //! A manifest is a single RON file that lists, for one card theme, the //! display metadata plus the 52 face SVG paths and one back SVG path. //! Paths are interpreted relative to the manifest file's directory so //! the same manifest works whether the theme is bundled via //! `embedded://`, dropped under `themes://`, or unpacked into a temp //! dir during import validation. use std::collections::HashMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use thiserror::Error; use super::{CardKey, ThemeMeta, ThemeMetaError}; /// Raw deserialised manifest. Keys in `faces` use the canonical /// [`CardKey::manifest_name`] string form (e.g. `"hearts_ace"`); the /// loader converts to `HashMap` after validating that all /// 52 entries are present. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ThemeManifest { pub meta: ThemeMeta, pub back: PathBuf, pub faces: HashMap, } /// Errors raised by [`ThemeManifest::validate`]. #[derive(Debug, Error)] pub enum ManifestError { #[error("theme metadata invalid: {0}")] Meta(#[from] ThemeMetaError), #[error("manifest face key {key:?} is not a valid card name")] UnknownFaceKey { key: String }, #[error("manifest is missing face entries: {missing:?}")] MissingFaces { missing: Vec }, #[error("manifest declares {duplicate} twice with different paths")] DuplicateFace { duplicate: String }, } impl ThemeManifest { /// Parses the manifest's face map into a strongly-typed /// `HashMap`, surfacing precise errors for /// (a) keys that don't name a real card, (b) any of the 52 cards /// that the manifest forgot to list, and (c) duplicate keys (RON /// silently keeps the last value, which is brittle behaviour for /// a release). Also runs [`ThemeMeta::validate`] up front so /// metadata-level errors surface before path validation. pub fn validate(&self) -> Result, ManifestError> { self.meta.validate()?; let mut faces: HashMap = HashMap::with_capacity(52); for (key_str, path) in &self.faces { let key = CardKey::parse_manifest_name(key_str).ok_or_else(|| { ManifestError::UnknownFaceKey { key: key_str.clone(), } })?; if faces.insert(key, path.clone()).is_some() { return Err(ManifestError::DuplicateFace { duplicate: key_str.clone(), }); } } let missing: Vec = CardKey::all() .filter(|k| !faces.contains_key(k)) .map(CardKey::manifest_name) .collect(); if !missing.is_empty() { return Err(ManifestError::MissingFaces { missing }); } Ok(faces) } } #[cfg(test)] mod tests { use super::*; fn meta() -> ThemeMeta { ThemeMeta { id: "default".into(), name: "Default".into(), author: "Solitaire Quest".into(), version: "1.0.0".into(), card_aspect: (2, 3), } } fn full_face_map() -> HashMap { CardKey::all() .map(|k| (k.manifest_name(), PathBuf::from(format!("{}.svg", k.manifest_name())))) .collect() } #[test] fn complete_manifest_validates() { let m = ThemeManifest { meta: meta(), back: PathBuf::from("back.svg"), faces: full_face_map(), }; let parsed = m.validate().expect("valid manifest"); assert_eq!(parsed.len(), 52); for k in CardKey::all() { assert!(parsed.contains_key(&k), "{} missing", k.manifest_name()); } } #[test] fn missing_face_is_rejected_with_a_named_list() { let mut faces = full_face_map(); faces.remove("hearts_ace"); faces.remove("spades_king"); let m = ThemeManifest { meta: meta(), back: PathBuf::from("back.svg"), faces, }; match m.validate() { Err(ManifestError::MissingFaces { missing }) => { assert!(missing.iter().any(|s| s == "hearts_ace")); assert!(missing.iter().any(|s| s == "spades_king")); } other => panic!("expected MissingFaces, got {other:?}"), } } #[test] fn unknown_face_key_is_rejected() { let mut faces = full_face_map(); faces.insert("not_a_card".into(), PathBuf::from("nope.svg")); let m = ThemeManifest { meta: meta(), back: PathBuf::from("back.svg"), faces, }; assert!(matches!( m.validate(), Err(ManifestError::UnknownFaceKey { key }) if key == "not_a_card" )); } #[test] fn invalid_meta_propagates() { let mut bad_meta = meta(); bad_meta.id = "../escape".into(); let m = ThemeManifest { meta: bad_meta, back: PathBuf::from("back.svg"), faces: full_face_map(), }; assert!(matches!(m.validate(), Err(ManifestError::Meta(_)))); } #[test] fn ron_round_trip_preserves_manifest() { let m = ThemeManifest { meta: meta(), back: PathBuf::from("back.svg"), faces: full_face_map(), }; let serialised = ron::ser::to_string_pretty( &m, ron::ser::PrettyConfig::default(), ) .expect("serde_ron"); let parsed: ThemeManifest = ron::from_str(&serialised).expect("ron parse"); assert_eq!(parsed.meta, m.meta); assert_eq!(parsed.back, m.back); assert_eq!(parsed.faces, m.faces); } }