//! Theme-store catalog wire types. //! //! Shared between `solitaire_server` (which builds the catalog by //! scanning its theme-store directory) and `solitaire_data` (which //! fetches it and downloads theme archives). These are **additive** //! API types: they do not participate in [`crate::SyncPayload`] and //! changing them cannot break the sync wire format. //! //! Store entries describe downloadable card-art theme archives — the //! same `.zip` format consumed by the engine's theme importer (a //! `theme.ron` manifest plus 53 SVGs). use serde::{Deserialize, Serialize}; /// One downloadable theme in the server's catalog. /// /// URLs are server-relative (e.g. `/api/themes/neon/download`) so a /// client can join them onto whichever base URL it is configured with. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ThemeCatalogEntry { /// Unique theme id — `meta.id` from the archive's `theme.ron`. /// Also the id the theme installs under on the client. pub id: String, /// Display name shown in the store UI. pub name: String, /// Author attribution (free-form text). pub author: String, /// Version string (conventionally semver). pub version: String, /// Card aspect ratio as `(numerator, denominator)`. pub card_aspect: (u32, u32), /// Size of the `.zip` archive in bytes. Clients use this for /// display and as a pre-download sanity bound. pub size_bytes: u64, /// Lowercase-hex SHA-256 of the `.zip` archive. Clients MUST /// verify the downloaded bytes against this digest before handing /// the archive to the theme importer. pub sha256: String, /// Server-relative URL of the theme archive. pub download_url: String, /// Server-relative URL of a PNG preview, when the store ships one. #[serde(default, skip_serializing_if = "Option::is_none")] pub preview_url: Option, } /// Response body of the catalog listing endpoint (`GET /api/themes`). /// /// Wrapped in a struct (rather than a bare `Vec`) so future additive /// fields — pagination, store revision, purchase metadata — don't /// break existing clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ThemeCatalogResponse { /// Every theme currently offered by the server, sorted by name. pub themes: Vec, } #[cfg(test)] mod tests { use super::*; fn entry() -> ThemeCatalogEntry { ThemeCatalogEntry { id: "neon".into(), name: "Neon".into(), author: "Rusty Solitaire".into(), version: "1.0.0".into(), card_aspect: (2, 3), size_bytes: 123_456, sha256: "ab".repeat(32), download_url: "/api/themes/neon/download".into(), preview_url: Some("/api/themes/neon/preview".into()), } } #[test] fn catalog_round_trips_through_json() { let response = ThemeCatalogResponse { themes: vec![entry()], }; let json = serde_json::to_string(&response).expect("serialize"); let back: ThemeCatalogResponse = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back, response); } #[test] fn missing_preview_url_deserializes_to_none() { // Older servers (or themes without preview art) omit the key // entirely; the field must default rather than error. let json = r#"{ "id": "neon", "name": "Neon", "author": "Rusty Solitaire", "version": "1.0.0", "card_aspect": [2, 3], "size_bytes": 1, "sha256": "00", "download_url": "/api/themes/neon/download" }"#; let entry: ThemeCatalogEntry = serde_json::from_str(json).expect("deserialize"); assert_eq!(entry.preview_url, None); } }