Skip to main content

blockworx/schema/
model.rs

1//! The on-disk document model. JSON is the format (D13): serde derives on
2//! these types *are* the codec, so the model is the single source of truth
3//! for it.
4//!
5//! Ids are strings on the wire (parsed to the typed newtypes during
6//! conversion). Blocks, pins and image assets carry ids;
7//! routes/texts/areas/image placements are id-less (ids minted on load).
8
9use serde::{Deserialize, Serialize};
10
11use super::enums::{LabelSide, PinType};
12use super::error::SchemaError;
13
14// serde's `skip_serializing_if` calls these with a reference by contract.
15#[expect(clippy::trivially_copy_pass_by_ref)]
16fn is_false(b: &bool) -> bool {
17    !*b
18}
19fn is_empty_str(s: &str) -> bool {
20    s.is_empty()
21}
22#[expect(clippy::trivially_copy_pass_by_ref)]
23fn is_zero_i32(v: &i32) -> bool {
24    *v == 0
25}
26#[expect(clippy::trivially_copy_pass_by_ref)]
27fn is_zero_f32(v: &f32) -> bool {
28    *v == 0.0
29}
30
31/// The document format version this build writes. Bump it whenever a change
32/// would make an older reader misinterpret a file rather than fail on it.
33///
34/// 1. The original format; asset ids were `i<N>`, assigned by position.
35/// 2. Asset ids are content-derived (`<hash>.<ext>`). Old ids still *read* —
36///    the reader is permissive — but a build that only knows version 1 would
37///    reject the new ids, so it must refuse the file rather than half-read it.
38pub const CURRENT_VERSION: u32 = 2;
39
40/// Documents written before `version` existed. Nothing about the format changed
41/// when the node became explicit, so they read as version 1 rather than as a
42/// distinct legacy dialect.
43pub fn pre_versioning() -> u32 {
44    1
45}
46
47/// The whole document. The file's top level *is* the document.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct Document {
50    #[serde(default = "pre_versioning")]
51    pub version: u32,
52    /// Display name. Optional: the editor falls back to the name of the file
53    /// holding the document.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub name: Option<String>,
56    pub top: String,
57    pub blocks: Vec<Block>,
58    /// The root scope's own contents. The document root is a scope like any
59    /// other: a wire between two top-level blocks, or an annotation drawn
60    /// beside them, is owned by the root and lives here rather than on any
61    /// block.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub routes: Vec<Route>,
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub texts: Vec<Text>,
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub areas: Vec<Area>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub images: Vec<Image>,
70    /// Image content, stored once per distinct image and referenced by
71    /// [`Image::asset`]. Emitted after the blocks, since a reader wants the
72    /// diagram before the payloads.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub assets: Vec<Asset>,
75}
76
77/// Why a document declaring `version` cannot be read, if it cannot. A version
78/// from the future is refused rather than guessed at — the field exists
79/// precisely because a newer writer may have used syntax this build would
80/// otherwise misread as something else.
81pub fn unsupported_version(version: u32) -> Option<String> {
82    (version > CURRENT_VERSION).then(|| {
83        format!(
84            "document format version {version} is newer than this build reads \
85             (up to {CURRENT_VERSION})"
86        )
87    })
88}
89
90/// What a scope holds besides the blocks in it. The document root and every
91/// block carry the same four lists, so a reader of either takes this view of
92/// it and the two scopes cannot be read two different ways.
93#[derive(Clone, Copy)]
94pub struct ScopeContents<'a> {
95    pub routes: &'a [Route],
96    pub texts: &'a [Text],
97    pub areas: &'a [Area],
98    pub images: &'a [Image],
99}
100
101impl Document {
102    pub fn root_contents(&self) -> ScopeContents<'_> {
103        ScopeContents {
104            routes: &self.routes,
105            texts: &self.texts,
106            areas: &self.areas,
107            images: &self.images,
108        }
109    }
110
111    /// The asset ids the placements name, in first-appearance order and without
112    /// repeats — what `lower` uses to only fold in assets something places.
113    pub fn referenced_assets(&self) -> Vec<&str> {
114        let mut seen = Vec::new();
115        let placements = self
116            .blocks
117            .iter()
118            .flat_map(|b| b.images.iter().chain(b.icon.as_ref()))
119            .chain(&self.images);
120        for id in placements.map(|i| i.asset.as_str()) {
121            if !seen.contains(&id) {
122                seen.push(id);
123            }
124        }
125        seen
126    }
127
128    /// Serialize this model as the document format: pretty-printed JSON,
129    /// one field per line, so a diff of two folds reads as the edits
130    /// between them (F5).
131    // `serde_json` fails only on a map key that is not a string or a
132    // non-finite float, and the model holds neither.
133    #[expect(clippy::expect_used, clippy::missing_panics_doc)]
134    pub fn to_json(&self) -> String {
135        serde_json::to_string_pretty(self).expect("the document model serializes infallibly")
136    }
137
138    /// Parse a JSON document into the model.
139    ///
140    /// # Errors
141    /// [`SchemaError::Json`], carrying the source so the failure points at
142    /// the offending line.
143    pub fn parse_json(src: &str, src_name: &str) -> Result<Document, SchemaError> {
144        let parsed: Document =
145            serde_json::from_str(src).map_err(|e| SchemaError::json(src, src_name, &e))?;
146        match unsupported_version(parsed.version) {
147            Some(message) => Err(SchemaError::Json {
148                message,
149                span: (0, src.len().min(1)).into(),
150                src: miette::NamedSource::new(src_name, src.to_owned()),
151            }),
152            None => Ok(parsed),
153        }
154    }
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct Block {
159    pub id: String,
160    pub x: i32,
161    pub y: i32,
162    pub w: u32,
163    pub h: u32,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub role: Option<u8>,
166    #[serde(default, skip_serializing_if = "is_false")]
167    pub locked: bool,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub title: Option<Label>,
170    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
171    pub type_label: Option<Label>,
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub pins: Vec<Pin>,
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub routes: Vec<Route>,
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub texts: Vec<Text>,
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub areas: Vec<Area>,
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub images: Vec<Image>,
182    /// The block's optional foreground icon.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub icon: Option<Image>,
185    /// Ordered child block ids (render z-order).
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub children: Vec<String>,
188}
189
190impl Block {
191    pub fn contents(&self) -> ScopeContents<'_> {
192        ScopeContents {
193            routes: &self.routes,
194            texts: &self.texts,
195            areas: &self.areas,
196            images: &self.images,
197        }
198    }
199}
200
201/// A block label (`title` or `type`). `side` is omitted when it equals that
202/// label's default placement, so the per-kind default is applied on load.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct Label {
205    #[serde(default, skip_serializing_if = "is_empty_str")]
206    pub name: String,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub side: Option<LabelSide>,
209    #[serde(default, skip_serializing_if = "is_zero_f32")]
210    pub offset: f32,
211    #[serde(default, skip_serializing_if = "is_false")]
212    pub hidden: bool,
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct Pin {
217    pub id: String,
218    #[serde(default, skip_serializing_if = "is_empty_str")]
219    pub name: String,
220    #[serde(rename = "type", default, skip_serializing_if = "is_empty_str")]
221    pub type_label: String,
222    #[serde(default, skip_serializing_if = "is_empty_str")]
223    pub tag: String,
224    #[serde(default, skip_serializing_if = "is_false")]
225    pub tag_hidden: bool,
226    /// Boundary slot as `"{w|e}{offset}"` (e.g. `"w1"`). Optional so an omitted
227    /// value yields the friendly `MissingPinSide` error, not a generic failure.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub loc: Option<String>,
230    /// The port's on-canvas position/width. Omitted when it equals the auto-placed
231    /// default (reconstructed on load); height is always `PORT_HEIGHT`, never stored.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub x: Option<i32>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub y: Option<i32>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub w: Option<u32>,
238    /// I/O direction; `None` means the `InOut` default.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub dir: Option<PinType>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub pin_accent: Option<u8>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub port_accent: Option<u8>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub port_pin_accent: Option<u8>,
247    /// Whether the port stub is flipped from its natural (inward) facing.
248    #[serde(default, skip_serializing_if = "is_false")]
249    pub fliplr: bool,
250}
251
252/// A route. Carries no id (routes aren't referenced; ids are minted on load) and
253/// no wire geometry (edges/positions/crossings are recomputed).
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct Route {
256    #[serde(default, skip_serializing_if = "is_empty_str")]
257    pub name: String,
258    /// Anchors: `"p2"` (a port on this block) or `"b3:p2"` (a child block's pin).
259    pub from: String,
260    pub to: String,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub role: Option<u8>,
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub waypoints: Vec<Waypoint>,
265    /// Wire-label positions along the route (grid units, 1-decimal precision).
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub labels: Vec<f32>,
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271pub struct Waypoint {
272    pub x: i32,
273    pub y: i32,
274    #[serde(default, skip_serializing_if = "is_false")]
275    pub locked: bool,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct Text {
280    pub text: String,
281    #[serde(default, skip_serializing_if = "is_zero_i32")]
282    pub x: i32,
283    #[serde(default, skip_serializing_if = "is_zero_i32")]
284    pub y: i32,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub role: Option<u8>,
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct Area {
291    pub x: i32,
292    pub y: i32,
293    pub w: u32,
294    pub h: u32,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub role: Option<u8>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub title: Option<Label>,
299}
300
301/// Where an image is placed: a background image annotation, or (as a block's
302/// `icon`) its foreground icon. The image itself is an [`Asset`] named by
303/// [`asset`](Self::asset), so placing one image many times stores it once.
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
305pub struct Image {
306    /// Id of the [`Asset`] this places (`"9f3a2c81d4e7b026.png"`).
307    pub asset: String,
308    // Free (non-grid) coordinates, so floats — images/icons size and position
309    // freely, unlike the grid-quantized blocks and areas.
310    pub x: f32,
311    pub y: f32,
312    pub w: f32,
313    pub h: f32,
314}
315
316/// One image's content under the id its placements reference.
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318pub struct Asset {
319    pub id: String,
320    #[serde(flatten)]
321    pub image: ImageData,
322}
323
324/// An image's data — exactly one of an SVG document or base64-encoded PNG. The
325/// enum makes "exactly one" a type invariant rather than two optional fields
326/// that could both be set or both be absent.
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328#[serde(rename_all = "lowercase")]
329pub enum ImageData {
330    Svg(String),
331    Png(String),
332}