Skip to main content

mdcast_api/wire/
generated.rs

1//! Server-side generated images — QR codes and chess boards — that a
2//! template render asks for by *content* instead of uploading as a blob.
3//!
4//! A [`GeneratedAsset`] names the virtual path the template references
5//! (`#image("qr.svg")`) and a [`GeneratedSpec`] saying what to draw there. The
6//! server renders it with the same code the markdown pre-step uses (`qr`,
7//! `fen` and `pgn` fences and tags), so a caller needs no QR or chess library
8//! of its own.
9
10use serde::{Deserialize, Serialize};
11
12/// One generated file: `path` is where the template finds it, the flattened
13/// `spec` is what it contains.
14///
15/// ```json
16/// {"path": "qr.svg", "kind": "qr", "data": "SPD*1.0*ACC:CZ65…", "ec": "M"}
17/// ```
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct GeneratedAsset {
20    /// Virtual path the template reads it under. Must not collide with a
21    /// manifest key — the server rejects that rather than pick a winner.
22    pub path: String,
23    #[serde(flatten)]
24    pub spec: GeneratedSpec,
25}
26
27/// What to generate. Non-exhaustive: new kinds arrive as new variants, and
28/// [`super::CapabilitiesResponse::generators`] says which ones a server has.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31#[non_exhaustive]
32pub enum GeneratedSpec {
33    /// A QR code encoding `data` verbatim, as SVG.
34    Qr {
35        data: String,
36        #[serde(default)]
37        ec: QrEcLevel,
38    },
39    /// A chess board showing the position `fen` (full FEN or just its
40    /// placement field), as SVG.
41    ///
42    /// ```json
43    /// {"path": "board.svg", "kind": "fen", "fen": "8/8/8/4k3/8/8/8/4K3", "flip": true}
44    /// ```
45    Fen {
46        fen: String,
47        #[serde(flatten)]
48        board: BoardOptions,
49    },
50    /// A chess board showing a PGN game's mainline after half-move `move`
51    /// (`0` = the starting position); absent means the final position.
52    Pgn {
53        pgn: String,
54        #[serde(default, rename = "move")]
55        ply: Option<usize>,
56        #[serde(flatten)]
57        board: BoardOptions,
58    },
59}
60
61/// How a `fen`/`pgn` board is drawn — the same knobs the markdown fences and
62/// tags take as attributes. Every field is optional on the wire.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(default)]
65pub struct BoardOptions {
66    /// Draw the board from Black's side.
67    pub flip: bool,
68    pub theme: BoardTheme,
69    /// File/rank labels along the edges.
70    pub coordinates: bool,
71}
72
73impl Default for BoardOptions {
74    fn default() -> Self {
75        Self {
76            flip: false,
77            theme: BoardTheme::default(),
78            coordinates: true,
79        }
80    }
81}
82
83/// Board square colours.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum BoardTheme {
87    #[default]
88    Brown,
89    Blue,
90    Green,
91}
92
93/// QR error-correction level. `M` is the default, and what SPAYD ("QR
94/// platba") recommends.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
96pub enum QrEcLevel {
97    L,
98    #[default]
99    M,
100    Q,
101    H,
102}
103
104/// The generator kinds a server can render — the values of
105/// [`super::CapabilitiesResponse::generators`]. Each known variant is also the
106/// markdown fence/tag name it answers to.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "kebab-case")]
109#[non_exhaustive]
110pub enum Generator {
111    Qr,
112    Fen,
113    Pgn,
114    /// A kind this build doesn't know — what a newer server's generator
115    /// decodes to, so an older client still reads the rest of
116    /// `CapabilitiesResponse` instead of failing the whole decode. A server
117    /// never reports it, and it has no fence or tag.
118    #[serde(other)]
119    Unknown,
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn qr_entry_is_flat_json_tagged_by_kind() {
128        let asset = GeneratedAsset {
129            path: "qr.svg".to_string(),
130            spec: GeneratedSpec::Qr {
131                data: "SPD*1.0".to_string(),
132                ec: QrEcLevel::H,
133            },
134        };
135        let json = serde_json::to_value(&asset).unwrap();
136        assert_eq!(
137            json,
138            serde_json::json!({"path": "qr.svg", "kind": "qr", "data": "SPD*1.0", "ec": "H"})
139        );
140        assert_eq!(
141            serde_json::from_value::<GeneratedAsset>(json).unwrap(),
142            asset
143        );
144    }
145
146    #[test]
147    fn ec_defaults_to_m() {
148        let asset: GeneratedAsset =
149            serde_json::from_str(r#"{"path": "a.svg", "kind": "qr", "data": "x"}"#).unwrap();
150        assert_eq!(
151            asset.spec,
152            GeneratedSpec::Qr {
153                data: "x".to_string(),
154                ec: QrEcLevel::M
155            }
156        );
157    }
158
159    #[test]
160    fn chess_entries_are_flat_json_with_wire_name_move() {
161        let asset = GeneratedAsset {
162            path: "game.svg".to_string(),
163            spec: GeneratedSpec::Pgn {
164                pgn: "1. e4 e5".to_string(),
165                ply: Some(1),
166                board: BoardOptions {
167                    flip: true,
168                    theme: BoardTheme::Green,
169                    coordinates: false,
170                },
171            },
172        };
173        let json = serde_json::to_value(&asset).unwrap();
174        assert_eq!(
175            json,
176            serde_json::json!({
177                "path": "game.svg", "kind": "pgn", "pgn": "1. e4 e5", "move": 1,
178                "flip": true, "theme": "green", "coordinates": false
179            })
180        );
181        assert_eq!(
182            serde_json::from_value::<GeneratedAsset>(json).unwrap(),
183            asset
184        );
185    }
186
187    #[test]
188    fn chess_board_options_default() {
189        let fen: GeneratedAsset =
190            serde_json::from_str(r#"{"path": "a.svg", "kind": "fen", "fen": "8/8/8/8/8/8/8/4K3"}"#)
191                .unwrap();
192        assert_eq!(
193            fen.spec,
194            GeneratedSpec::Fen {
195                fen: "8/8/8/8/8/8/8/4K3".to_string(),
196                board: BoardOptions {
197                    flip: false,
198                    theme: BoardTheme::Brown,
199                    coordinates: true,
200                },
201            }
202        );
203        let pgn: GeneratedAsset =
204            serde_json::from_str(r#"{"path": "a.svg", "kind": "pgn", "pgn": "1. e4"}"#).unwrap();
205        assert_eq!(
206            pgn.spec,
207            GeneratedSpec::Pgn {
208                pgn: "1. e4".to_string(),
209                ply: None,
210                board: BoardOptions::default(),
211            }
212        );
213    }
214
215    #[test]
216    fn unknown_board_theme_is_rejected() {
217        let err = serde_json::from_str::<GeneratedAsset>(
218            r#"{"path": "a.svg", "kind": "fen", "fen": "8/8/8/8/8/8/8/8", "theme": "pink"}"#,
219        );
220        assert!(err.is_err());
221    }
222
223    #[test]
224    fn generators_serialize_kebab_case() {
225        assert_eq!(
226            serde_json::to_value([Generator::Qr, Generator::Fen, Generator::Pgn]).unwrap(),
227            serde_json::json!(["qr", "fen", "pgn"])
228        );
229    }
230
231    #[test]
232    fn unknown_generator_decodes_as_unknown() {
233        assert_eq!(
234            serde_json::from_str::<Vec<Generator>>(r#"["qr", "barcode"]"#).unwrap(),
235            [Generator::Qr, Generator::Unknown]
236        );
237    }
238
239    #[test]
240    fn unknown_kind_is_rejected() {
241        let err = serde_json::from_str::<GeneratedAsset>(
242            r#"{"path": "a.svg", "kind": "barcode", "data": "x"}"#,
243        );
244        assert!(err.is_err());
245    }
246}