Skip to main content

oxiui_theme/
serial.rs

1//! Theme serialization and deserialization via [`oxicode`].
2//!
3//! Provides a compact, Pure-Rust binary representation of a theme snapshot
4//! (design tokens + typography scale) that can be saved to disk or transmitted
5//! over the network and reconstructed without loss.
6//!
7//! # Format
8//!
9//! The serialized form is a length-prefixed [`ThemeSnapshot`] encoded with
10//! [`oxicode`]'s standard configuration (variable-length integers, little-endian
11//! floats, no external schema).  The format is opaque binary; for human-editable
12//! theme files use [`serde_json`](https://crates.io/crates/serde_json) on top of
13//! these types instead.
14//!
15//! # Example
16//!
17//! ```rust
18//! use oxiui_theme::serial::{deserialize_theme, serialize_theme, ThemeSnapshot};
19//!
20//! let snapshot = ThemeSnapshot::default();
21//! let bytes = serialize_theme(&snapshot).expect("serialize");
22//! let restored = deserialize_theme(&bytes).expect("deserialize");
23//! assert_eq!(snapshot, restored);
24//! ```
25
26use oxicode::{Decode, Encode};
27use oxiui_core::{FontSpec, Palette, UiError};
28
29use crate::{DesignTokens, TypographyScale};
30
31// ── Snapshot type ────────────────────────────────────────────────────────────
32
33/// A serializable snapshot of the design-token, typography, palette, and font
34/// layers of a theme.
35///
36/// This is the primary type persisted by [`serialize_theme`] /
37/// [`deserialize_theme`].  All fields round-trip faithfully through the
38/// [`oxicode`] binary format.
39#[derive(Clone, Debug, Default, PartialEq, Encode, Decode)]
40pub struct ThemeSnapshot {
41    /// Design tokens (spacing / radius / elevation / opacity).
42    pub tokens: DesignTokens,
43    /// Typographic scale (six named text-style roles).
44    pub typography: TypographyScale,
45    /// Semantic colour palette for this theme.
46    pub palette: Palette,
47    /// Body font specification.
48    pub body_font: FontSpec,
49}
50
51// ── Public API ───────────────────────────────────────────────────────────────
52
53/// Serialise a [`ThemeSnapshot`] to a compact binary blob via [`oxicode`].
54///
55/// The returned bytes can be saved to disk and later restored with
56/// [`deserialize_theme`].
57///
58/// # Errors
59///
60/// Returns [`UiError::Other`] if the [`oxicode`] encoder encounters an
61/// unexpected error (e.g. out-of-memory when allocating the output buffer).
62pub fn serialize_theme(snapshot: &ThemeSnapshot) -> Result<Vec<u8>, UiError> {
63    oxicode::encode_to_vec(snapshot)
64        .map_err(|e| UiError::Other(format!("theme serialization failed: {e}")))
65}
66
67/// Deserialise a [`ThemeSnapshot`] from bytes produced by [`serialize_theme`].
68///
69/// # Errors
70///
71/// Returns [`UiError::Other`] if the bytes are truncated, corrupted, or were
72/// not produced by [`serialize_theme`] with a compatible [`oxicode`] version.
73pub fn deserialize_theme(bytes: &[u8]) -> Result<ThemeSnapshot, UiError> {
74    let (snapshot, _consumed) = oxicode::decode_from_slice::<ThemeSnapshot>(bytes)
75        .map_err(|e| UiError::Other(format!("theme deserialization failed: {e}")))?;
76    Ok(snapshot)
77}
78
79// ── Tests ────────────────────────────────────────────────────────────────────
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::tokens::{RadiusStep, SpacingStep};
85
86    #[test]
87    fn serialize_deserialize_default_theme_roundtrip() {
88        let original = ThemeSnapshot::default();
89        let bytes = serialize_theme(&original).expect("serialize should succeed");
90        assert!(!bytes.is_empty(), "serialized bytes must be non-empty");
91
92        let restored = deserialize_theme(&bytes).expect("deserialize should succeed");
93        assert_eq!(
94            original, restored,
95            "round-tripped snapshot must equal original"
96        );
97
98        // Re-serialise and verify determinism.
99        let bytes2 = serialize_theme(&restored).expect("re-serialize should succeed");
100        assert_eq!(
101            bytes, bytes2,
102            "re-serialized bytes must be identical (deterministic encoding)"
103        );
104    }
105
106    #[test]
107    fn deserialize_invalid_bytes_returns_error() {
108        let bad = b"this is not a valid oxicode-encoded theme snapshot";
109        let result = deserialize_theme(bad);
110        assert!(result.is_err(), "invalid bytes must return an error");
111    }
112
113    #[test]
114    fn serialize_is_not_empty() {
115        let snapshot = ThemeSnapshot::default();
116        let bytes = serialize_theme(&snapshot).expect("serialize");
117        assert!(
118            bytes.len() > 10,
119            "serialized theme should be non-trivial in size (got {} bytes)",
120            bytes.len()
121        );
122    }
123
124    #[test]
125    fn roundtrip_custom_tokens() {
126        let custom = ThemeSnapshot {
127            tokens: DesignTokens {
128                spacing: [2.0, 4.0, 8.0, 16.0, 24.0, 32.0, 64.0],
129                radius: [0.0, 3.0, 6.0, 12.0, 24.0, 999.0],
130                elevation: [0.0, 2.0, 4.0, 8.0, 16.0, 32.0],
131                opacity: [0.2, 0.5, 0.7, 0.9, 1.0],
132            },
133            typography: TypographyScale::default(),
134            ..ThemeSnapshot::default()
135        };
136
137        let bytes = serialize_theme(&custom).expect("serialize custom tokens");
138        let restored = deserialize_theme(&bytes).expect("deserialize custom tokens");
139        assert_eq!(custom, restored);
140    }
141
142    #[test]
143    fn named_token_lookup_survives_roundtrip() {
144        let original = ThemeSnapshot::default();
145        let bytes = serialize_theme(&original).expect("serialize");
146        let restored = deserialize_theme(&bytes).expect("deserialize");
147
148        // Verify named step access still works on the restored value.
149        assert_eq!(
150            original.tokens.spacing(SpacingStep::Md),
151            restored.tokens.spacing(SpacingStep::Md),
152            "spacing Md must survive round-trip"
153        );
154        assert_eq!(
155            original.tokens.radius(RadiusStep::Full),
156            restored.tokens.radius(RadiusStep::Full),
157            "radius Full must survive round-trip"
158        );
159    }
160
161    #[test]
162    fn typography_survives_roundtrip() {
163        let original = ThemeSnapshot::default();
164        let bytes = serialize_theme(&original).expect("serialize");
165        let restored = deserialize_theme(&bytes).expect("deserialize");
166
167        assert_eq!(
168            original.typography.body.size, restored.typography.body.size,
169            "body size must survive round-trip"
170        );
171        assert_eq!(
172            original.typography.display.weight, restored.typography.display.weight,
173            "display weight must survive round-trip"
174        );
175    }
176
177    #[test]
178    fn palette_round_trip() {
179        use oxiui_core::{Color, Palette};
180        let snapshot = ThemeSnapshot {
181            palette: Palette {
182                background: Color(10, 20, 30, 255),
183                surface: Color(11, 21, 31, 255),
184                primary: Color(12, 22, 32, 255),
185                on_primary: Color(13, 23, 33, 255),
186                text: Color(14, 24, 34, 255),
187                muted: Color(15, 25, 35, 255),
188            },
189            ..ThemeSnapshot::default()
190        };
191        let bytes = serialize_theme(&snapshot).expect("serialize");
192        let decoded = deserialize_theme(&bytes).expect("deserialize");
193        assert_eq!(snapshot, decoded);
194    }
195
196    #[test]
197    fn font_style_oblique_round_trip() {
198        use oxiui_core::{FontSpec, FontStyle};
199        let snapshot = ThemeSnapshot {
200            body_font: FontSpec {
201                family: "MyFont".to_string(),
202                size: 18.0,
203                weight: 700,
204                style: FontStyle::Oblique { degrees: 12.5 },
205                letter_spacing: 0.5,
206                line_height: Some(1.6),
207                features: vec![],
208            },
209            ..ThemeSnapshot::default()
210        };
211        let bytes = serialize_theme(&snapshot).expect("serialize");
212        let decoded = deserialize_theme(&bytes).expect("deserialize");
213        assert_eq!(snapshot, decoded);
214    }
215
216    #[test]
217    fn full_snapshot_round_trip() {
218        use oxiui_core::{Color, FontSpec, FontStyle, Palette};
219        let snapshot = ThemeSnapshot {
220            palette: Palette {
221                background: Color(255, 255, 255, 255),
222                surface: Color(245, 245, 245, 255),
223                primary: Color(99, 102, 241, 255),
224                on_primary: Color(255, 255, 255, 255),
225                text: Color(15, 23, 42, 255),
226                muted: Color(100, 116, 139, 255),
227            },
228            body_font: FontSpec {
229                family: "Inter".to_string(),
230                size: 16.0,
231                weight: 400,
232                style: FontStyle::Normal,
233                letter_spacing: 0.0,
234                line_height: Some(1.5),
235                features: vec![],
236            },
237            ..ThemeSnapshot::default()
238        };
239        let bytes = serialize_theme(&snapshot).expect("serialize");
240        let decoded = deserialize_theme(&bytes).expect("deserialize");
241        assert_eq!(snapshot, decoded);
242    }
243}