Files
Ferrous-Solitaire/solitaire_engine/src/theme/manifest.rs
T
funman300 936d035750 feat(engine): CardTheme asset + manifest loader (Card theme phase 2)
Implements Phase 2 of CARD_PLAN.md — the data types and `.theme.ron`
asset loader that build on Phase 1's SVG rasteriser.

solitaire_engine/src/theme/
  mod.rs        — CardKey { suit, rank } as the HashMap lookup key
                  (distinct from solitaire_core::Card which carries
                  per-deal id + face_up state); CardKey::all() yields
                  the 52 keys in suit-major / rank-ascending order;
                  manifest_name() and parse_manifest_name() round-trip
                  via the canonical "{suit}_{rank}" form.
                  ThemeMeta with structural validation (id non-empty,
                  no path separators, non-zero aspect components).
                  CardTheme #[derive(Asset, TypePath)] storing the
                  53 image handles + meta.
  manifest.rs   — ThemeManifest { meta, back, faces } with serde for
                  RON round-trip. validate() returns a strongly-typed
                  HashMap<CardKey, PathBuf>, surfacing precise errors
                  for unknown face keys, missing-of-52 entries, and
                  duplicate keys (RON silently keeps the last; brittle
                  for a release).
  loader.rs     — AssetLoader for .theme.ron. Validates manifest, then
                  composes sibling SVG paths via AssetPath::resolve so
                  the same loader works for both embedded:// and
                  themes:// asset sources (Phase 3 territory).
                  Schedules every face + back load through SvgLoader
                  with target_size derived from meta.card_aspect.

24 new tests covering: 52-key enumeration uniqueness, manifest-name
round trip, garbage-name rejection, complete/missing/unknown/duplicate
manifest validation, RON round-trip integrity, target-size aspect
math (2:3 → 512x768; non-standard; degenerate 1:10000 clamps to 1px).

Workspace deps added: ron 0.12.

cargo build / clippy --workspace --all-targets -- -D warnings / test
all green (937 passed total — +24 from Phase 2 vs the +7 from
Phase 1's b8fb3fb baseline).
2026-05-01 05:19:12 +00:00

181 lines
5.8 KiB
Rust

//! 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<CardKey, _>` 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<String, PathBuf>,
}
/// 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<String> },
#[error("manifest declares {duplicate} twice with different paths")]
DuplicateFace { duplicate: String },
}
impl ThemeManifest {
/// Parses the manifest's face map into a strongly-typed
/// `HashMap<CardKey, PathBuf>`, 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<HashMap<CardKey, PathBuf>, ManifestError> {
self.meta.validate()?;
let mut faces: HashMap<CardKey, PathBuf> = 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<String> = 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<String, PathBuf> {
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);
}
}