Skip to main content

blockworx/document/
schema_convert.rs

1//! App-side bridge between the in-memory [`Document`] model and the
2//! standalone on-disk [`crate::schema`] representation. Everything that couples
3//! the schema crate to `document` lives here: the infallible `from_*`
4//! projection, the fallible `into_*` reconstruction, the JSON/KDL wrapper
5//! functions, and the hand-written enum bridges (the schema enums carry serde;
6//! the `document` enums carry the domain).
7//!
8//! `SchemaError` carries the source text (for `miette` diagnostics), so it is
9//! large by design — the internal `Result`s accept that rather than boxing.
10#![allow(clippy::result_large_err)]
11
12use std::str::FromStr;
13
14use base64::Engine as _;
15
16use blockworx_doc::geometry::{
17    GridPoint, GridRect as DocGridRect, GridSize as DocGridSize, PinSlot,
18};
19
20use crate::document::{
21    Asset, AutoRoute, Block, BlockLabel, Comment, Decorations, Document, GridPos, GridRect,
22    GridSize, Image, ImageData, LabelSide, LineAnchor, LinearDistance, PinPort, PinSide, PinType,
23    TextBox, Waypoint,
24};
25use crate::edit::lower::pin_side;
26use crate::presentation::store::{IdMap, IdMapExt, IdSet};
27use crate::schema::SchemaError;
28use crate::schema::enums;
29use crate::schema::loc::{format_loc, parse_loc};
30use crate::schema::model as schema;
31use crate::shape::port::PORT_HEIGHT;
32use crate::store::{CommentId, ImageId, PinId, RectId, RouteId, TextId, WaypointId, WireLabelId};
33
34/// The legacy grid rect in the document crate's spelling — the two are the
35/// same value, and this file is the only place they still meet.
36fn as_doc(rect: GridRect) -> DocGridRect {
37    DocGridRect {
38        top_left: GridPoint {
39            x: rect.min.x,
40            y: rect.min.y,
41        },
42        size: DocGridSize {
43            w: rect.size.w,
44            h: rect.size.h,
45        },
46    }
47}
48
49fn from_doc(rect: DocGridRect) -> GridRect {
50    GridRect::new(
51        GridPos::new(rect.top_left.x, rect.top_left.y),
52        GridSize::new(rect.size.w, rect.size.h),
53    )
54}
55
56/// Port-body auto-placement, the on-disk round trip's half of it: a pin
57/// whose body sits where the auto-placement would put it omits its geometry
58/// on disk, and a file that omitted it has the body rebuilt on load. The
59/// placement itself is [`crate::edit::create::default_port_rect`] — one
60/// formula, so a change to it cannot leave the file format behind.
61impl Block {
62    fn default_port_rect(&self, name: &str, side: PinSide, offset: u32) -> GridRect {
63        // Every stored body, zero-width sentinels included: a zero rect
64        // intersects nothing, so an unplaced port never blocks a placement.
65        let siblings: Vec<DocGridRect> = self.pins.values().map(|p| as_doc(p.rect)).collect();
66        from_doc(crate::edit::create::default_port_rect(
67            as_doc(self.inner),
68            &siblings,
69            name,
70            PinSlot {
71                side: pin_side(side.into()),
72                offset,
73            },
74        ))
75    }
76
77    /// Fill any port whose `rect` is unset (zero width — the sentinel a schema
78    /// load leaves when a pin omits its geometry) via [`Self::default_port_rect`],
79    /// in stored order so each placement avoids the ports already positioned.
80    /// Ports that carry an explicit rect are left untouched.
81    fn fill_missing_port_rects(&mut self) {
82        let ids: Vec<PinId> = self.pins.keys().copied().collect();
83        for id in ids {
84            if self.pins[&id].rect.size.w != 0 {
85                continue;
86            }
87            let (name, side, offset) = {
88                let p = &self.pins[&id];
89                (p.name.clone(), p.side, p.offset)
90            };
91            let rect = self.default_port_rect(&name, side, offset);
92            if let Some(p) = self.pins.get_mut(&id) {
93                p.rect = rect;
94            }
95        }
96    }
97
98    /// The port rects this block *would* have if every port were auto-placed. A
99    /// port whose stored rect equals its auto rect can omit its geometry on disk
100    /// (it is reconstructed by [`Self::fill_missing_port_rects`] on load).
101    fn auto_port_rects(&self) -> Vec<(PinId, GridRect)> {
102        let mut probe = self.clone();
103        for p in probe.pins.values_mut() {
104            p.rect = GridRect::default();
105        }
106        probe.fill_missing_port_rects();
107        probe.pins.iter().map(|(&id, p)| (id, p.rect)).collect()
108    }
109}
110
111// ── Enum bridges ────────────────────────────────────────────────────────────
112// The schema enums carry serde; the domain enums carry no serde. These 1:1
113// bridges are the only place the two vocabularies meet.
114
115impl From<PinSide> for enums::PinSide {
116    fn from(v: PinSide) -> Self {
117        match v {
118            PinSide::East => enums::PinSide::East,
119            PinSide::West => enums::PinSide::West,
120        }
121    }
122}
123impl From<enums::PinSide> for PinSide {
124    fn from(v: enums::PinSide) -> Self {
125        match v {
126            enums::PinSide::East => PinSide::East,
127            enums::PinSide::West => PinSide::West,
128        }
129    }
130}
131
132impl From<PinType> for enums::PinType {
133    fn from(v: PinType) -> Self {
134        match v {
135            PinType::Input => enums::PinType::Input,
136            PinType::Output => enums::PinType::Output,
137            PinType::InOut => enums::PinType::InOut,
138        }
139    }
140}
141impl From<enums::PinType> for PinType {
142    fn from(v: enums::PinType) -> Self {
143        match v {
144            enums::PinType::Input => PinType::Input,
145            enums::PinType::Output => PinType::Output,
146            enums::PinType::InOut => PinType::InOut,
147        }
148    }
149}
150
151impl From<LabelSide> for enums::LabelSide {
152    fn from(v: LabelSide) -> Self {
153        match v {
154            LabelSide::Top => enums::LabelSide::Top,
155            LabelSide::Center => enums::LabelSide::Center,
156            LabelSide::Bottom => enums::LabelSide::Bottom,
157        }
158    }
159}
160impl From<enums::LabelSide> for LabelSide {
161    fn from(v: enums::LabelSide) -> Self {
162        match v {
163            enums::LabelSide::Top => LabelSide::Top,
164            enums::LabelSide::Center => LabelSide::Center,
165            enums::LabelSide::Bottom => LabelSide::Bottom,
166        }
167    }
168}
169
170// ── from_document: infallible projection onto the on-disk schema ─────────────
171
172impl From<&Document> for schema::Document {
173    fn from(doc: &Document) -> Self {
174        let mut assets = AssetIds::default();
175        let blocks = doc
176            .blocks
177            .iter()
178            .map(|(&id, b)| schema::Block::from_block(id, b, &mut assets))
179            .collect();
180        schema::Document {
181            // Always written at the current version: the in-memory document is
182            // whatever this build understands, whatever it was loaded from.
183            version: schema::CURRENT_VERSION,
184            name: doc.name.clone(),
185            top: doc.top_id.to_string(),
186            blocks,
187            assets: assets.into_assets(),
188        }
189    }
190}
191
192/// The raw bytes an asset is stored as, and the extension naming its format.
193/// This is exactly what a container writes to `assets/<id>`, so the hash below
194/// is a hash of the file, not of some other encoding of it.
195fn asset_bytes(image: &ImageData) -> (&[u8], &'static str) {
196    match image {
197        ImageData::Svg(src) => (src.as_bytes(), "svg"),
198        ImageData::Png(bytes) => (bytes, "png"),
199    }
200}
201
202/// The content-derived id for `image`: `<hash>.<ext>`, which is also the name it
203/// takes inside a container.
204///
205/// Truncated to 16 hex characters — 64 bits. Eight would collide at around 77k
206/// assets, and a collision here does not fail loudly, it silently swaps one
207/// image for another.
208pub fn asset_id(image: &ImageData) -> String {
209    let (bytes, ext) = asset_bytes(image);
210    let hash = blake3::hash(bytes).to_hex();
211    format!("{}.{ext}", &hash[..16])
212}
213
214/// The bytes a container writes to `assets/<id>` for `asset`. These are the
215/// bytes the id hashes, so the file's name really does describe its content.
216pub fn asset_to_bytes(asset: &schema::Asset) -> Result<Vec<u8>, SchemaError> {
217    match &asset.image {
218        schema::ImageData::Svg(src) => Ok(src.clone().into_bytes()),
219        schema::ImageData::Png(b64) => base64::engine::general_purpose::STANDARD
220            .decode(b64.as_bytes())
221            .map_err(|e| SchemaError::AssetPng {
222                asset: asset.id.clone(),
223                reason: e.to_string(),
224            }),
225    }
226}
227
228/// The inverse: an asset read back out of `assets/<id>`. The format comes from
229/// the id's extension, which inside a container is the only thing that carries
230/// it — there is no `svg`/`png` child node to read.
231pub fn asset_from_bytes(id: &str, bytes: Vec<u8>) -> Result<schema::Asset, SchemaError> {
232    let ext = id.rsplit_once('.').map(|(_, ext)| ext).unwrap_or_default();
233    let image = match ext {
234        "svg" => {
235            schema::ImageData::Svg(String::from_utf8(bytes).map_err(|e| SchemaError::AssetPng {
236                asset: id.to_string(),
237                reason: format!("not valid UTF-8: {e}"),
238            })?)
239        }
240        "png" => schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(&bytes)),
241        other => {
242            return Err(SchemaError::AssetPng {
243                asset: id.to_string(),
244                reason: format!("{other:?} is not an image format this build stores"),
245            });
246        }
247    };
248    Ok(schema::Asset {
249        id: id.to_string(),
250        image,
251    })
252}
253
254/// Names each distinct image once, so an image placed many times is written once
255/// and referenced by id. The id is derived from the content, so equal images get
256/// equal ids in every document, and an unrelated edit never renumbers anything.
257/// Interned content hashes and compares by pointer, so the lookup never touches
258/// the bytes.
259#[derive(Default)]
260pub struct AssetIds {
261    ids: std::collections::HashMap<Asset, String>,
262    assets: Vec<schema::Asset>,
263}
264
265impl AssetIds {
266    /// The id naming `asset`, minting (and recording) one on first sight.
267    fn id_for(&mut self, asset: &Asset) -> String {
268        if let Some(id) = self.ids.get(asset) {
269            return id.clone();
270        }
271        let id = asset_id(asset);
272        self.assets.push(schema::Asset {
273            id: id.clone(),
274            image: match &**asset {
275                ImageData::Svg(src) => schema::ImageData::Svg(src.clone()),
276                ImageData::Png(bytes) => {
277                    schema::ImageData::Png(base64::engine::general_purpose::STANDARD.encode(bytes))
278                }
279            },
280        });
281        self.ids.insert(asset.clone(), id.clone());
282        id
283    }
284
285    pub fn into_assets(self) -> Vec<schema::Asset> {
286        self.assets
287    }
288}
289
290/// The image content of a parsed document, keyed by the id its placements
291/// reference. Interning happens here, so equal content loaded from anywhere —
292/// this document, a paste, another file — ends up sharing one allocation.
293pub struct Assets(std::collections::HashMap<String, Asset>);
294
295impl Assets {
296    /// Decode and intern every asset, rejecting duplicate ids (KDL catches these
297    /// with a span; JSON has no span to point at).
298    pub fn decode(assets: Vec<schema::Asset>) -> Result<Self, SchemaError> {
299        let mut map = std::collections::HashMap::new();
300        for a in assets {
301            let image = match a.image {
302                schema::ImageData::Svg(svg) => ImageData::Svg(svg),
303                schema::ImageData::Png(png) => ImageData::Png(
304                    base64::engine::general_purpose::STANDARD
305                        .decode(png.as_bytes())
306                        .map_err(|e| SchemaError::AssetPng {
307                            asset: a.id.clone(),
308                            reason: e.to_string(),
309                        })?,
310                ),
311            };
312            if map.insert(a.id.clone(), Asset::from(image)).is_some() {
313                return Err(SchemaError::DuplicateAsset { asset: a.id });
314            }
315        }
316        Ok(Self(map))
317    }
318
319    fn get(&self, id: &str, block: &str) -> Result<Asset, SchemaError> {
320        self.0
321            .get(id)
322            .cloned()
323            .ok_or_else(|| SchemaError::UnknownAsset {
324                block: block.to_string(),
325                asset: id.to_string(),
326            })
327    }
328}
329
330/// Round a continuous quantity (label/route positions, sizes) to 1 decimal, so
331/// the on-disk form stays clean and greppable.
332fn round1(v: f32) -> f32 {
333    (v * 10.0).round() / 10.0
334}
335
336/// Emit a block label only when it differs from its per-kind `default`; `side` is
337/// dropped when it matches the default placement.
338fn from_label(label: &BlockLabel, default: &BlockLabel) -> Option<schema::Label> {
339    if label == default {
340        return None;
341    }
342    Some(schema::Label {
343        name: label.name.clone(),
344        side: (label.side != default.side).then(|| enums::LabelSide::from(label.side)),
345        offset: round1(label.offset),
346        hidden: label.hidden,
347    })
348}
349
350impl schema::Block {
351    pub fn from_block(id: RectId, b: &Block, assets: &mut AssetIds) -> Self {
352        // A port whose rect equals the auto-placement default omits its geometry
353        // (reconstructed on load); others carry it verbatim.
354        let auto: std::collections::HashMap<PinId, GridRect> =
355            b.auto_port_rects().into_iter().collect();
356        schema::Block {
357            id: id.to_string(),
358            x: b.inner.min.x,
359            y: b.inner.min.y,
360            w: b.inner.size.w,
361            h: b.inner.size.h,
362            role: b.role,
363            locked: b.locked,
364            title: from_label(&b.decorations.title, &BlockLabel::default()),
365            type_label: from_label(&b.decorations.type_label, &BlockLabel::upper_left()),
366            pins: b
367                .pins
368                .iter()
369                .map(|(&id, p)| schema::Pin::from_pin(id, p, auto.get(&id).copied()))
370                .collect(),
371            routes: b.routes.values().map(schema::Route::from_route).collect(),
372            texts: b.texts.values().map(schema::Text::from_text).collect(),
373            comments: b
374                .comments
375                .values()
376                .map(schema::Comment::from_comment)
377                .collect(),
378            images: b
379                .images
380                .values()
381                .map(|s| schema::Image::from_symbol(s, assets))
382                .collect(),
383            icon: b
384                .icon
385                .as_ref()
386                .map(|s| schema::Image::from_symbol(s, assets)),
387            children: b.children.iter().map(|&cid| cid.to_string()).collect(),
388        }
389    }
390}
391
392impl schema::Pin {
393    pub fn from_pin(id: PinId, p: &PinPort, auto: Option<GridRect>) -> Self {
394        // Omit geometry when it matches the auto-placed default (height is always
395        // `PORT_HEIGHT`, never stored).
396        let is_auto = auto == Some(p.rect);
397        schema::Pin {
398            id: id.to_string(),
399            name: p.name.clone(),
400            type_label: p.type_label.clone(),
401            tag: p.tag.clone(),
402            tag_hidden: p.tag_hidden,
403            loc: Some(format_loc(p.side.into(), p.offset)),
404            x: (!is_auto).then_some(p.rect.min.x),
405            y: (!is_auto).then_some(p.rect.min.y),
406            w: (!is_auto).then_some(p.rect.size.w),
407            dir: (p.kind != PinType::InOut).then(|| p.kind.into()),
408            // The stub accents are derived from route roles now, never
409            // stored — the keys stay in the schema so old files decode,
410            // but nothing writes them anymore.
411            pin_accent: None,
412            port_accent: p.port_accent,
413            port_pin_accent: None,
414            // A port faces `side.flip()` (natural) or `side` (flipped); the
415            // boolean records the flipped case.
416            fliplr: p.port_orientation == Some(p.side),
417        }
418    }
419}
420
421impl schema::Route {
422    pub fn from_route(r: &AutoRoute) -> Self {
423        schema::Route {
424            name: r.route_name().to_string(),
425            from: LineAnchor::to_string(&r.start()),
426            to: LineAnchor::to_string(&r.finish()),
427            role: r.role(),
428            waypoints: r
429                .iter_waypoints()
430                .map(|(_, w)| schema::Waypoint {
431                    x: w.pos.x,
432                    y: w.pos.y,
433                    locked: w.locked,
434                })
435                .collect(),
436            labels: r
437                .iter_labels()
438                .map(|(_, &d)| round1(f32::from(d)))
439                .collect(),
440        }
441    }
442}
443
444impl schema::Text {
445    pub fn from_text(t: &TextBox) -> Self {
446        schema::Text {
447            text: t.text.clone(),
448            x: t.anchor.x,
449            y: t.anchor.y,
450            role: t.role,
451        }
452    }
453}
454
455impl schema::Comment {
456    pub fn from_comment(c: &Comment) -> Self {
457        schema::Comment {
458            x: c.inner.min.x,
459            y: c.inner.min.y,
460            w: c.inner.size.w,
461            h: c.inner.size.h,
462            role: c.role,
463            title: from_label(&c.title, &BlockLabel::default()),
464        }
465    }
466}
467
468impl schema::Image {
469    pub fn from_symbol(s: &Image, assets: &mut AssetIds) -> Self {
470        schema::Image {
471            asset: assets.id_for(&s.image),
472            x: round1(s.inner.min.x),
473            y: round1(s.inner.min.y),
474            w: round1(s.inner.width()),
475            h: round1(s.inner.height()),
476        }
477    }
478}
479
480// ── into_document: fallible reconstruction from the on-disk schema ───────────
481
482fn parse_id<T: FromStr<Err = String>>(kind: &'static str, s: &str) -> Result<T, SchemaError> {
483    s.parse().map_err(|reason| SchemaError::Id {
484        kind,
485        value: s.to_string(),
486        reason,
487    })
488}
489
490fn parse_anchor(block: &str, s: &str) -> Result<LineAnchor, SchemaError> {
491    let anchor_err = |reason: String| SchemaError::Anchor {
492        block: block.to_string(),
493        value: s.to_string(),
494        reason,
495    };
496    if let Some((b, p)) = s.split_once(':') {
497        Ok(LineAnchor::Pin {
498            block: b.parse().map_err(anchor_err)?,
499            pin: p.parse().map_err(anchor_err)?,
500        })
501    } else {
502        Ok(LineAnchor::Port(s.parse().map_err(anchor_err)?))
503    }
504}
505
506impl schema::Label {
507    fn into_label(self, default: &BlockLabel) -> BlockLabel {
508        BlockLabel {
509            name: self.name,
510            hidden: self.hidden,
511            side: self.side.map_or(default.side, Into::into),
512            offset: self.offset,
513        }
514    }
515}
516
517fn label_or(dto: Option<schema::Label>, default: BlockLabel) -> BlockLabel {
518    match dto {
519        Some(l) => l.into_label(&default),
520        None => default,
521    }
522}
523
524impl schema::Document {
525    pub fn into_document(self) -> Result<Document, SchemaError> {
526        let top_id: RectId = self.top.parse().map_err(|reason| SchemaError::BadTop {
527            value: self.top.clone(),
528            reason,
529        })?;
530        let assets = Assets::decode(self.assets)?;
531        let mut blocks: IdMap<RectId, Block> = IdMap::default();
532        for sb in self.blocks {
533            let (id, block) = sb.into_block(&assets)?;
534            blocks.insert(id, block);
535        }
536        if !blocks.contains_key(&top_id) {
537            return Err(SchemaError::BadTop {
538                value: self.top,
539                reason: "no block with that id".into(),
540            });
541        }
542        validate_children(&blocks)?;
543        Ok(Document {
544            name: self.name,
545            top_id,
546            blocks: blocks.into(),
547        })
548    }
549}
550
551/// Strict on load (unlike the lenient runtime `child_blocks`): a child id that
552/// isn't in the document is a hand-edit error worth surfacing.
553fn validate_children(blocks: &IdMap<RectId, Block>) -> Result<(), SchemaError> {
554    for (bid, block) in blocks {
555        for cid in &block.children {
556            if !blocks.contains_key(cid) {
557                return Err(SchemaError::DanglingChild {
558                    block: bid.to_string(),
559                    child: cid.to_string(),
560                });
561            }
562        }
563    }
564    Ok(())
565}
566
567impl schema::Block {
568    pub fn into_block(self, assets: &Assets) -> Result<(RectId, Block), SchemaError> {
569        let id: RectId = parse_id("block", &self.id)?;
570        let block_ctx = self.id;
571
572        let decorations = Decorations {
573            title: label_or(self.title, BlockLabel::default()),
574            type_label: label_or(self.type_label, BlockLabel::upper_left()),
575        };
576
577        let mut pins: IdMap<PinId, PinPort> = IdMap::default();
578        for p in self.pins {
579            let (pid, pin) = p.into_pin(&block_ctx)?;
580            pins.insert(pid, pin);
581        }
582
583        // Routes/texts/comments/images carry no id on disk; mint fresh ones in
584        // stored order.
585        let mut routes: IdMap<RouteId, AutoRoute> = IdMap::default();
586        for r in self.routes {
587            routes.insert_value(r.into_route(&block_ctx)?);
588        }
589
590        let mut texts: IdMap<TextId, TextBox> = IdMap::default();
591        for t in self.texts {
592            texts.insert_value(t.into_text());
593        }
594
595        let mut comments: IdMap<CommentId, Comment> = IdMap::default();
596        for c in self.comments {
597            comments.insert_value(c.into_comment());
598        }
599
600        let mut images: IdMap<ImageId, Image> = IdMap::default();
601        for s in self.images {
602            images.insert_value(s.into_symbol(&block_ctx, assets)?);
603        }
604        let icon = match self.icon {
605            Some(s) => Some(s.into_symbol(&block_ctx, assets)?),
606            None => None,
607        };
608
609        let mut children: IdSet<RectId> = IdSet::default();
610        for cid in self.children {
611            children.insert(parse_id("block", &cid)?);
612        }
613
614        let block = Block {
615            inner: GridRect::new(GridPos::new(self.x, self.y), GridSize::new(self.w, self.h)),
616            decorations,
617            pins,
618            children,
619            routes,
620            texts,
621            comments,
622            images,
623            icon,
624            role: self.role,
625            locked: self.locked,
626        };
627        // Reconstruct any port geometry omitted on disk (matched the auto default).
628        let mut block = block;
629        block.fill_missing_port_rects();
630        Ok((id, block))
631    }
632}
633
634impl schema::Pin {
635    pub fn into_pin(self, block: &str) -> Result<(PinId, PinPort), SchemaError> {
636        let id: PinId = parse_id("pin", &self.id)?;
637        let loc = self.loc.ok_or_else(|| SchemaError::MissingPinSide {
638            block: block.to_string(),
639            pin: self.id.clone(),
640        })?;
641        let (side, offset) = parse_loc(&loc).map_err(|reason| SchemaError::BadLoc {
642            block: block.to_string(),
643            pin: self.id.clone(),
644            value: loc.clone(),
645            reason,
646        })?;
647        let side: PinSide = side.into();
648        let pin = PinPort {
649            name: self.name,
650            type_label: self.type_label,
651            tag: self.tag,
652            tag_hidden: self.tag_hidden,
653            side,
654            offset,
655            // Omitted geometry leaves a zero-width sentinel; `fill_missing_port_rects`
656            // (called by `into_block`) reconstructs it. Height is always PORT_HEIGHT.
657            rect: match (self.x, self.y, self.w) {
658                (Some(x), Some(y), Some(w)) => {
659                    GridRect::new(GridPos::new(x, y), GridSize::new(w, PORT_HEIGHT))
660                }
661                _ => GridRect::default(),
662            },
663            kind: self.dir.map(Into::into).unwrap_or_default(),
664            port_accent: self.port_accent,
665            // Flipped ⇒ facing == side; natural ⇒ None (facing derives side.flip()).
666            port_orientation: self.fliplr.then_some(side),
667        };
668        Ok((id, pin))
669    }
670}
671
672impl schema::Route {
673    pub fn into_route(self, block: &str) -> Result<AutoRoute, SchemaError> {
674        let from = parse_anchor(block, &self.from)?;
675        let to = parse_anchor(block, &self.to)?;
676
677        let mut waypoints: IdMap<WaypointId, Waypoint> = IdMap::default();
678        for w in self.waypoints {
679            waypoints.insert_value(Waypoint {
680                pos: GridPos::new(w.x, w.y),
681                locked: w.locked,
682            });
683        }
684        let mut labels: IdMap<WireLabelId, LinearDistance> = IdMap::default();
685        for at in self.labels {
686            labels.insert_value(LinearDistance::from(at));
687        }
688
689        let mut route = AutoRoute::new(from, to, waypoints, labels);
690        route.set_route_name(self.name);
691        route.set_role(self.role);
692        Ok(route)
693    }
694}
695
696impl schema::Text {
697    pub fn into_text(self) -> TextBox {
698        TextBox {
699            text: self.text,
700            anchor: GridPos::new(self.x, self.y),
701            role: self.role,
702        }
703    }
704}
705
706impl schema::Comment {
707    pub fn into_comment(self) -> Comment {
708        Comment {
709            inner: GridRect::new(GridPos::new(self.x, self.y), GridSize::new(self.w, self.h)),
710            title: label_or(self.title, BlockLabel::default()),
711            role: self.role,
712        }
713    }
714}
715
716impl schema::Image {
717    pub fn into_symbol(self, block: &str, assets: &Assets) -> Result<Image, SchemaError> {
718        Ok(Image {
719            image: assets.get(&self.asset, block)?,
720            inner: egui::Rect::from_min_size(
721                egui::pos2(self.x, self.y),
722                egui::vec2(self.w, self.h),
723            ),
724        })
725    }
726}
727
728// ── JSON / KDL wrappers ──────────────────────────────────────────────────────
729
730/// Serialize the document as pretty JSON.
731pub fn to_json(doc: &Document) -> String {
732    schema::Document::from(doc).to_json()
733}
734
735/// Serialize the document as KDL.
736pub fn to_kdl(doc: &Document) -> String {
737    schema::Document::from(doc).to_kdl()
738}
739
740/// Parse a KDL document. Routes come back with empty geometry; the caller must
741/// rebuild it before display (see `crate::presentation::Presentation::refresh_routes`).
742pub fn from_kdl(src: &str, src_name: &str) -> miette::Result<Document> {
743    Ok(schema::Document::parse_kdl(src, src_name)?.into_document()?)
744}
745
746/// Parse a document and rebuild its wire geometry, choosing the format by
747/// `src_name`'s extension (`.kdl` for KDL, otherwise JSON).
748///
749/// This — not [`from_kdl`] — is what a caller loading a document to
750/// display wants: those two stop at the parse, leaving every route with empty
751/// geometry.
752pub fn load(src: &str, src_name: &str) -> miette::Result<Document> {
753    let is_kdl = std::path::Path::new(src_name)
754        .extension()
755        .is_some_and(|e| e.eq_ignore_ascii_case("kdl"));
756    if is_kdl {
757        finish_load(schema::Document::parse_kdl(src, src_name)?)
758    } else {
759        finish_load(schema::Document::parse_json(src, src_name)?)
760    }
761}
762
763/// Reconstruct a document from a parsed model. Geometry is left empty —
764/// the caller that owns the document's [`Presentation`](crate::presentation::Presentation)
765/// runs [`refresh_routes`](crate::presentation::Presentation::refresh_routes), so wire
766/// geometry lands beside the document rather than being built into a store
767/// this layer cannot see. (Split out of [`load`] for the container, which
768/// injects its file-kept assets before the model can be reconstructed.)
769pub fn finish_load(model: schema::Document) -> miette::Result<Document> {
770    Ok(model.into_document()?)
771}
772
773/// Serialize a document in the format `dst_name`'s extension names, so a `.kdl`
774/// round-trips through KDL and a `.json` through JSON. The counterpart of
775/// [`load`].
776pub fn save(doc: &Document, dst_name: &str) -> String {
777    let is_kdl = std::path::Path::new(dst_name)
778        .extension()
779        .is_some_and(|e| e.eq_ignore_ascii_case("kdl"));
780    if is_kdl { to_kdl(doc) } else { to_json(doc) }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::{from_kdl, to_json, to_kdl};
786
787    /// Parse JSON *without* rebuilding wire geometry. The round-trip tests below
788    /// compare documents by value, so they need the parse alone —
789    /// `super::load` finalizes, which is a different document.
790    fn from_json(src: &str, src_name: &str) -> miette::Result<Document> {
791        Ok(super::schema::Document::parse_json(src, src_name)?.into_document()?)
792    }
793    use crate::document::{
794        AutoRoute, Block, BlockLabel, Comment, Decorations, Document, GridPos, GridRect, GridSize,
795        Image, ImageData, LabelSide, LineAnchor, LinearDistance, PinPort, PinSide, PinType,
796        TextBox, Waypoint,
797    };
798    use crate::presentation::store::{IdMap, IdMapExt, IdSet};
799    use crate::schema::SchemaError;
800    use crate::schema::model;
801    use crate::store::{
802        CommentId, ImageId, PinId, RectId, RouteId, TextId, WaypointId, WireLabelId,
803    };
804
805    fn pin(name: &str, side: PinSide, offset: u32, kind: PinType, rect: GridRect) -> PinPort {
806        PinPort {
807            name: name.to_string(),
808            type_label: String::new(),
809            tag: String::new(),
810            tag_hidden: false,
811            side,
812            offset,
813            rect,
814            kind,
815            port_accent: None,
816            port_orientation: None,
817        }
818    }
819
820    fn rect(x: i32, y: i32, w: u32, h: u32) -> GridRect {
821        GridRect::new(GridPos::new(x, y), GridSize::new(w, h))
822    }
823
824    fn block(name: &str) -> Block {
825        Block {
826            decorations: Decorations {
827                title: BlockLabel {
828                    name: name.to_string(),
829                    ..BlockLabel::default()
830                },
831                ..Decorations::default()
832            },
833            ..Block::default()
834        }
835    }
836
837    fn route(
838        from: LineAnchor,
839        to: LineAnchor,
840        name: &str,
841        wps: &[(i32, i32)],
842        labels: &[f32],
843    ) -> AutoRoute {
844        let mut waypoints: IdMap<WaypointId, Waypoint> = IdMap::default();
845        for &(x, y) in wps {
846            waypoints.insert_value(Waypoint {
847                pos: GridPos::new(x, y),
848                locked: false,
849            });
850        }
851        let mut ld: IdMap<WireLabelId, LinearDistance> = IdMap::default();
852        for &l in labels {
853            ld.insert_value(LinearDistance::from(l));
854        }
855        let mut r = AutoRoute::new(from, to, waypoints, ld);
856        r.set_route_name(name.to_string());
857        r
858    }
859
860    /// A representative document exercising every field the schema carries: pin
861    /// rects, type labels, accents, facing, routes with waypoints + labels, text,
862    /// comment, SVG image, role, locked, nested children.
863    fn demo() -> Document {
864        let top = RectId::nth_default(1);
865        let c1 = RectId::nth_default(2);
866        let c2 = RectId::nth_default(3);
867        let buffer = RectId::nth_default(4);
868
869        let mut t = block("block_1");
870        t.decorations.type_label.name = "Counter".to_string();
871        t.pins.insert(
872            PinId::nth_default(1),
873            pin("clk", PinSide::West, 1, PinType::Input, rect(-11, 28, 4, 2)),
874        );
875        t.pins.insert(
876            PinId::nth_default(2),
877            pin("rst", PinSide::West, 2, PinType::InOut, rect(-11, 32, 4, 2)),
878        );
879        t.children = IdSet::from_iter([c1, c2]);
880        t.role = Some(3);
881        t.locked = true;
882        t.texts.insert(
883            TextId::nth_default(1),
884            TextBox {
885                text: "line one\nline two".to_string(),
886                anchor: GridPos::new(5, 8),
887                role: Some(2),
888            },
889        );
890        t.comments.insert(
891            CommentId::nth_default(1),
892            Comment {
893                inner: rect(2, 2, 10, 8),
894                title: BlockLabel {
895                    name: "Region".to_string(),
896                    side: LabelSide::Top,
897                    ..BlockLabel::default()
898                },
899                role: None,
900            },
901        );
902        t.images.insert(
903            ImageId::nth_default(1),
904            Image {
905                image: ImageData::Svg(
906                    "<svg viewBox=\"0 0 10 10\">\n  <path d=\"M0 0 L10 10\"/>\n</svg>".to_string(),
907                )
908                .into(),
909                inner: egui::Rect::from_min_size(egui::pos2(20.0, 15.0), egui::vec2(8.0, 8.0)),
910            },
911        );
912        t.icon = Some(Image {
913            image: ImageData::Svg(
914                "<svg viewBox=\"0 0 4 4\"><rect width=\"4\" height=\"4\"/></svg>".to_string(),
915            )
916            .into(),
917            inner: egui::Rect::from_min_size(egui::pos2(4.0, 4.0), egui::vec2(3.0, 3.0)),
918        });
919        let mut r1 = route(
920            LineAnchor::Pin {
921                block: c1,
922                pin: PinId::nth_default(1),
923            },
924            LineAnchor::Pin {
925                block: c2,
926                pin: PinId::nth_default(1),
927            },
928            "route_1",
929            &[(12, 21), (-16, 19)],
930            &[76.6],
931        );
932        r1.set_role(Some(5));
933        t.routes.insert(RouteId::nth_default(1), r1);
934        t.routes.insert(
935            RouteId::nth_default(2),
936            route(
937                LineAnchor::Pin {
938                    block: c2,
939                    pin: PinId::nth_default(1),
940                },
941                LineAnchor::Port(PinId::nth_default(1)),
942                "",
943                &[],
944                &[],
945            ),
946        );
947
948        let mut b1 = block("block_1");
949        b1.pins.insert(
950            PinId::nth_default(1),
951            pin(
952                "i.1.write_logic",
953                PinSide::West,
954                1,
955                PinType::InOut,
956                rect(0, 0, 8, 2),
957            ),
958        );
959        // A pin exercising every optional field.
960        let mut fancy = pin(
961            "typed",
962            PinSide::East,
963            3,
964            PinType::Output,
965            rect(20, 6, 5, 2),
966        );
967        fancy.type_label = "Counter".to_string();
968        fancy.tag = "U3".to_string();
969        fancy.tag_hidden = true;
970        fancy.port_accent = Some(2);
971        // side == East, so Some(East) is the flipped state (fliplr=true) that the
972        // boolean encoding round-trips (Some(side.flip()) would collapse to None).
973        fancy.port_orientation = Some(PinSide::East);
974        b1.pins.insert(PinId::nth_default(2), fancy);
975
976        let mut b2 = block("block_2b");
977        b2.pins.insert(
978            PinId::nth_default(1),
979            pin(
980                "o.0.read_logic",
981                PinSide::East,
982                1,
983                PinType::InOut,
984                rect(30, 0, 8, 2),
985            ),
986        );
987        b2.children = IdSet::from_iter([buffer]);
988
989        let mut buf = block("Internal Buffer");
990        buf.pins.insert(
991            PinId::nth_default(1),
992            pin("Port", PinSide::West, 1, PinType::InOut, rect(22, 5, 4, 2)),
993        );
994
995        let mut blocks: IdMap<RectId, Block> = IdMap::default();
996        blocks.insert(top, t);
997        blocks.insert(c1, b1);
998        blocks.insert(c2, b2);
999        blocks.insert(buffer, buf);
1000        Document {
1001            name: None,
1002            top_id: top,
1003            blocks: blocks.into(),
1004        }
1005    }
1006
1007    #[test]
1008    fn json_round_trip_is_idempotent() {
1009        let doc = demo();
1010        let s1 = to_json(&doc);
1011        let back = from_json(&s1, "demo.json").unwrap_or_else(|e| panic!("re-parse:\n{e:?}\n{s1}"));
1012        let s2 = to_json(&back);
1013        assert_eq!(s1, s2, "JSON must be idempotent\n{s1}");
1014    }
1015
1016    #[test]
1017    fn kdl_round_trip_is_idempotent() {
1018        let doc = demo();
1019        let s1 = to_kdl(&doc);
1020        let back = from_kdl(&s1, "demo.kdl").unwrap_or_else(|e| panic!("re-parse:\n{e:?}\n{s1}"));
1021        let s2 = to_kdl(&back);
1022        assert_eq!(s1, s2, "KDL must be idempotent\n{s1}");
1023    }
1024
1025    #[test]
1026    fn json_and_kdl_agree() {
1027        // Both formats are serializations of the same schema, so a doc routed through
1028        // KDL and back must produce the same JSON as the original.
1029        let doc = demo();
1030        let via_kdl = from_kdl(&to_kdl(&doc), "demo.kdl").unwrap();
1031        assert_eq!(to_json(&doc), to_json(&via_kdl));
1032    }
1033
1034    #[test]
1035    fn schema_projection_round_trips_by_value() {
1036        // The schema itself (not the string) round-trips exactly through both formats.
1037        let doc = demo();
1038        let s = model::Document::from(&doc);
1039        let via_json = from_json(&to_json(&doc), "d.json").unwrap();
1040        let via_kdl = from_kdl(&to_kdl(&doc), "d.kdl").unwrap();
1041        assert_eq!(s, model::Document::from(&via_json), "json");
1042        assert_eq!(s, model::Document::from(&via_kdl), "kdl");
1043    }
1044
1045    #[test]
1046    fn image_fractional_size_round_trips() {
1047        // Images are free-sized (not grid-quantized), so a fractional rect must
1048        // survive both formats verbatim.
1049        let mut doc = Document::default();
1050        let top = doc.top_id;
1051        let inner = egui::Rect::from_min_size(egui::pos2(3.5, 2.5), egui::vec2(37.5, 12.5));
1052        doc.block_mut(top).unwrap().images.insert(
1053            ImageId::nth_default(1),
1054            Image {
1055                image: ImageData::Svg("<svg/>".into()).into(),
1056                inner,
1057            },
1058        );
1059        for back in [
1060            from_json(&to_json(&doc), "d.json").unwrap(),
1061            from_kdl(&to_kdl(&doc), "d.kdl").unwrap(),
1062        ] {
1063            let img = back.block(top).unwrap().images.values().next().unwrap();
1064            assert_eq!(img.inner, inner, "fractional image rect preserved");
1065        }
1066    }
1067
1068    /// A document placing one image twice as a background and once as a block's
1069    /// icon — the shape that used to write the same bytes three times.
1070    fn thrice_placed(marker: &str) -> (Document, RectId) {
1071        let content = ImageData::Svg(format!("<svg viewBox=\"0 0 2 2\"><!--{marker}--></svg>"));
1072        let mut doc = Document::default();
1073        let top = doc.top_id;
1074        let child = doc.add_child(top, block("child"));
1075        for (n, at) in [(1, 0.0), (2, 10.0)] {
1076            doc.block_mut(top).unwrap().images.insert(
1077                ImageId::nth_default(n),
1078                Image::new(
1079                    content.clone(),
1080                    egui::Rect::from_min_size(egui::pos2(at, at), egui::vec2(4.0, 4.0)),
1081                ),
1082            );
1083        }
1084        doc.block_mut(child).unwrap().icon = Some(Image::new(
1085            content.clone(),
1086            egui::Rect::from_min_size(egui::pos2(1.0, 1.0), egui::vec2(2.0, 2.0)),
1087        ));
1088        (doc, child)
1089    }
1090
1091    #[test]
1092    fn a_repeated_image_is_written_once_and_shared_on_from_json() {
1093        let (doc, child) = thrice_placed("written-once");
1094        let kdl = to_kdl(&doc);
1095        assert_eq!(kdl.matches("<svg viewBox").count(), 1, "one copy:\n{kdl}");
1096        assert_eq!(kdl.matches("asset \"").count(), 1, "one asset:\n{kdl}");
1097
1098        let original = doc.block(child).unwrap().icon.clone().unwrap().image;
1099        let id = super::asset_id(&original);
1100        assert_eq!(
1101            kdl.matches(&format!("\"{id}\"")).count(),
1102            4,
1103            "one definition, three placements:\n{kdl}"
1104        );
1105        for (name, back) in [
1106            ("kdl", from_kdl(&kdl, "d.kdl").unwrap()),
1107            ("json", from_json(&to_json(&doc), "d.json").unwrap()),
1108        ] {
1109            let top = back.block(back.top_id).unwrap();
1110            let mut placed: Vec<_> = top.images.values().map(|s| s.image.clone()).collect();
1111            placed.push(back.block(child).unwrap().icon.clone().unwrap().image);
1112            assert_eq!(placed.len(), 3, "{name}");
1113            // Equality here *is* pointer identity, so this says the three
1114            // placements share one allocation — and share it with the document
1115            // that was already in memory, since interning is global.
1116            assert!(
1117                placed.iter().all(|a| *a == original),
1118                "{name}: every placement resolves to the one interned copy"
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn an_unknown_asset_reference_is_rejected() {
1125        let (doc, child) = thrice_placed("unknown-ref");
1126        let real = super::asset_id(&doc.block(child).unwrap().icon.clone().unwrap().image);
1127        // A well-formed id that nothing defines — the reference is what is being
1128        // rejected here, not the spelling.
1129        let kdl = to_kdl(&doc).replace(
1130            &format!("image \"{real}\""),
1131            "image \"0000000000000000.svg\"",
1132        );
1133        let err = from_kdl(&kdl, "d.kdl")
1134            .unwrap_err()
1135            .downcast::<SchemaError>()
1136            .unwrap();
1137        assert!(
1138            matches!(err, SchemaError::UnknownAsset { .. }),
1139            "a placement naming an undefined asset must not load: {err}"
1140        );
1141    }
1142
1143    #[test]
1144    fn ids_preserved_and_child_order_kept() {
1145        let back = from_kdl(&to_kdl(&demo()), "d.kdl").unwrap();
1146        assert_eq!(back.top_id, RectId::nth_default(1));
1147        assert!(back.block(RectId::nth_default(4)).is_some());
1148        let top = back.block(back.top_id).unwrap();
1149        assert_eq!(
1150            top.children.iter().copied().collect::<Vec<_>>(),
1151            vec![RectId::nth_default(2), RectId::nth_default(3)]
1152        );
1153    }
1154
1155    #[test]
1156    fn pin_rect_is_captured_not_recomputed() {
1157        // The dragged/authored port rect survives a round-trip verbatim.
1158        let back = from_json(&to_json(&demo()), "d.json").unwrap();
1159        let clk = back
1160            .block(RectId::nth_default(1))
1161            .unwrap()
1162            .pins
1163            .get(&PinId::nth_default(1))
1164            .unwrap();
1165        assert_eq!(clk.rect, rect(-11, 28, 4, 2));
1166    }
1167
1168    #[test]
1169    fn fliplr_round_trips() {
1170        let kdl = to_kdl(&demo());
1171        assert!(kdl.contains("fliplr=true"), "{kdl}");
1172        assert!(
1173            !kdl.contains("facing") && !kdl.contains("port-orientation"),
1174            "{kdl}"
1175        );
1176        let back = from_kdl(&kdl, "d.kdl").unwrap();
1177        let fancy = back
1178            .block(RectId::nth_default(2))
1179            .unwrap()
1180            .pins
1181            .get(&PinId::nth_default(2))
1182            .unwrap();
1183        // fliplr=true reloads as port_orientation == side (East here).
1184        assert_eq!(fancy.port_orientation, Some(PinSide::East));
1185        // A natural-facing pin has no fliplr and reloads as None.
1186        let clk = back
1187            .block(RectId::nth_default(1))
1188            .unwrap()
1189            .pins
1190            .get(&PinId::nth_default(1))
1191            .unwrap();
1192        assert_eq!(clk.port_orientation, None);
1193    }
1194
1195    #[test]
1196    fn route_anchors_are_positional_and_id_less() {
1197        let kdl = to_kdl(&demo());
1198        // Anchors are the two positional args; no `from=`/`to=` props and no id.
1199        assert!(
1200            kdl.contains(r#"route "b2:p1" "b3:p1" name="route_1""#),
1201            "{kdl}"
1202        );
1203        assert!(!kdl.contains("from=") && !kdl.contains("to="), "{kdl}");
1204    }
1205
1206    #[test]
1207    fn label_position_round_trips_exactly() {
1208        // The ×10 integer encoding makes the label position exact through a round-trip.
1209        let doc = demo();
1210        let orig = doc
1211            .block(RectId::nth_default(1))
1212            .unwrap()
1213            .routes
1214            .get(&RouteId::nth_default(1))
1215            .unwrap()
1216            .iter_labels()
1217            .next()
1218            .map(|(_, &d)| d)
1219            .unwrap();
1220        let back = from_kdl(&to_kdl(&doc), "d.kdl").unwrap();
1221        let got = back
1222            .block(RectId::nth_default(1))
1223            .unwrap()
1224            .routes
1225            .values()
1226            .find(|r| r.route_name() == "route_1")
1227            .unwrap()
1228            .iter_labels()
1229            .next()
1230            .map(|(_, &d)| d)
1231            .unwrap();
1232        assert_eq!(orig, got, "label LinearDistance preserved exactly");
1233    }
1234
1235    #[test]
1236    fn enums_are_kebab_case_in_json() {
1237        let json = to_json(&demo());
1238        assert!(json.contains(r#""dir": "output""#), "{json}");
1239        assert!(
1240            !json.contains("\"East\"") && !json.contains("\"InOut\""),
1241            "{json}"
1242        );
1243    }
1244
1245    #[test]
1246    fn loc_combines_side_and_offset() {
1247        let kdl = to_kdl(&demo());
1248        let json = to_json(&demo());
1249        // "clk" is West, offset 1 → loc "w1"; no separate side/offset fields.
1250        assert!(kdl.contains(r#"loc="w1""#), "{kdl}");
1251        assert!(json.contains(r#""loc": "w1""#), "{json}");
1252        // Pins no longer carry separate side/offset props (labels still use `side=`).
1253        assert!(!kdl.contains(r#""clk" side="#), "{kdl}");
1254        let back = from_kdl(&kdl, "d.kdl").unwrap();
1255        let clk = back
1256            .block(RectId::nth_default(1))
1257            .unwrap()
1258            .pins
1259            .get(&PinId::nth_default(1))
1260            .unwrap();
1261        assert_eq!((clk.side, clk.offset), (PinSide::West, 1));
1262    }
1263
1264    #[test]
1265    fn pin_geometry_omitted_when_auto() {
1266        let p = pin("clk", PinSide::West, 1, PinType::InOut, rect(5, 5, 4, 2));
1267        // rect == auto-placed default → geometry omitted.
1268        let auto = model::Pin::from_pin(PinId::nth_default(1), &p, Some(rect(5, 5, 4, 2)));
1269        assert_eq!((auto.x, auto.y, auto.w), (None, None, None));
1270        // rect != default (dragged) → geometry kept.
1271        let kept = model::Pin::from_pin(PinId::nth_default(1), &p, Some(rect(9, 9, 4, 2)));
1272        assert_eq!((kept.x, kept.y, kept.w), (Some(5), Some(5), Some(4)));
1273    }
1274
1275    #[test]
1276    fn hand_authored_pin_without_geometry_gets_auto_placed() {
1277        // A pin declared with only `loc` (no x/y/w) parses and is auto-placed on load.
1278        let src =
1279            "top \"b1\"\nblock \"b1\" x=0 y=0 w=20 h=8 {\n    pin \"p1\" \"clk\" loc=\"w1\"\n}";
1280        let back = from_kdl(src, "d.kdl").unwrap();
1281        let p = back
1282            .block(RectId::nth_default(1))
1283            .unwrap()
1284            .pins
1285            .get(&PinId::nth_default(1))
1286            .unwrap();
1287        // Height is PORT_HEIGHT and width/position were filled in (non-zero width).
1288        assert!(p.rect.size.w > 0, "port width was auto-filled");
1289        assert_eq!(p.rect.size.h, crate::shape::port::PORT_HEIGHT);
1290    }
1291
1292    #[test]
1293    fn children_are_one_node() {
1294        let kdl = to_kdl(&demo());
1295        // The top block's children on a single node, in order.
1296        assert!(kdl.contains(r#"children "b2" "b3""#), "{kdl}");
1297        assert!(
1298            !kdl.contains("child \"b2\""),
1299            "no per-line child nodes:\n{kdl}"
1300        );
1301        let back = from_kdl(&kdl, "d.kdl").unwrap();
1302        assert_eq!(
1303            back.block(RectId::nth_default(1))
1304                .unwrap()
1305                .children
1306                .iter()
1307                .copied()
1308                .collect::<Vec<_>>(),
1309            vec![RectId::nth_default(2), RectId::nth_default(3)]
1310        );
1311    }
1312
1313    #[test]
1314    fn bad_and_missing_loc_errors() {
1315        // In KDL both surface with a span (via the walker).
1316        let missing = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    pin \"p1\" \"clk\" x=0 y=0 w=4 h=2\n}";
1317        assert!(matches!(kdl_err(missing), SchemaError::Kdl { .. }));
1318        let bad = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    pin \"p1\" \"clk\" loc=\"z9\" x=0 y=0 w=4 h=2\n}";
1319        let err = kdl_err(bad);
1320        assert!(matches!(err, SchemaError::Kdl { .. }));
1321        let (off, len) = kdl_span(&err);
1322        assert_eq!(&bad[off..off + len], "\"z9\"", "span points at the bad loc");
1323    }
1324
1325    #[test]
1326    fn missing_loc_in_json_is_missing_pin_side() {
1327        // The JSON path (no walker) surfaces the friendly conversion error.
1328        let json = r#"{"top":"b1","blocks":[{"id":"b1","x":0,"y":0,"w":40,"h":30,
1329            "pins":[{"id":"p1","name":"clk","x":0,"y":0,"w":4,"h":2}]}]}"#;
1330        let err = from_json(json, "e.json")
1331            .unwrap_err()
1332            .downcast::<SchemaError>()
1333            .unwrap();
1334        assert!(matches!(err, SchemaError::MissingPinSide { .. }));
1335    }
1336
1337    #[test]
1338    fn defaults_omitted() {
1339        let kdl = to_kdl(&demo());
1340        assert!(!kdl.contains("in-out"), "default kind omitted:\n{kdl}");
1341    }
1342
1343    // ── Error cases ─────────────────────────────────────────────────────────────
1344
1345    fn json_err(src: &str) -> SchemaError {
1346        from_json(src, "e.json")
1347            .unwrap_err()
1348            .downcast::<SchemaError>()
1349            .expect("SchemaError")
1350    }
1351    fn kdl_err(src: &str) -> SchemaError {
1352        from_kdl(src, "e.kdl")
1353            .unwrap_err()
1354            .downcast::<SchemaError>()
1355            .expect("SchemaError")
1356    }
1357
1358    /// The span of a `Kdl` error, for asserting it points at the right token.
1359    fn kdl_span(err: &SchemaError) -> (usize, usize) {
1360        match err {
1361            SchemaError::Kdl { span, .. } => (span.offset(), span.len()),
1362            other => panic!("expected a Kdl error, got {other:?}"),
1363        }
1364    }
1365
1366    #[test]
1367    fn malformed_is_an_error() {
1368        assert!(matches!(kdl_err("top \"b1\" {"), SchemaError::Kdl { .. }));
1369        assert!(matches!(json_err("not json"), SchemaError::Json { .. }));
1370    }
1371
1372    #[test]
1373    fn bad_id_error_points_at_the_id() {
1374        let src = "top \"b1\"\nblock \"xyz\" x=0 y=0 w=4 h=4";
1375        let err = kdl_err(src);
1376        assert!(matches!(err, SchemaError::Kdl { .. }));
1377        // The span covers the `"xyz"` token.
1378        let (off, len) = kdl_span(&err);
1379        assert_eq!(&src[off..off + len], "\"xyz\"");
1380    }
1381
1382    #[test]
1383    fn bad_anchor_error() {
1384        // Anchors are positional; `"zzz"` is neither `p<N>` nor `b<N>:p<M>`.
1385        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    route \"zzz\" \"p1\"\n}";
1386        assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1387    }
1388
1389    #[test]
1390    fn dangling_child_error() {
1391        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    children \"b9\"\n}";
1392        assert!(matches!(kdl_err(src), SchemaError::DanglingChild { .. }));
1393    }
1394
1395    /// A `top` naming no block used to load "fine" and then take the editor down
1396    /// on the first frame: every draw resolves the current block through `top_id`.
1397    #[test]
1398    fn top_naming_a_missing_block_is_an_error() {
1399        let src = "top \"b9\"\nblock \"b1\" x=0 y=0 w=40 h=30";
1400        assert!(matches!(kdl_err(src), SchemaError::BadTop { .. }));
1401    }
1402
1403    #[test]
1404    fn image_without_data_error() {
1405        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    image x=1 y=1 size=10.0\n}";
1406        assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1407    }
1408
1409    #[test]
1410    fn unknown_node_error() {
1411        let src = "top \"b1\"\nblock \"b1\" x=0 y=0 w=40 h=30 {\n    widget x=1\n}";
1412        assert!(matches!(kdl_err(src), SchemaError::Kdl { .. }));
1413    }
1414
1415    #[test]
1416    fn svg_with_quotes_round_trips() {
1417        let kdl = to_kdl(&demo());
1418        assert!(kdl.contains("svg r#\""), "{kdl}");
1419        let back = from_kdl(&kdl, "d.kdl").unwrap();
1420        let sym = back
1421            .block(RectId::nth_default(1))
1422            .unwrap()
1423            .images
1424            .get(&ImageId::nth_default(1))
1425            .unwrap();
1426        match &*sym.image {
1427            ImageData::Svg(s) => assert!(s.contains("M0 0 L10 10") && s.contains('\n')),
1428            ImageData::Png(_) => panic!("expected svg"),
1429        }
1430    }
1431
1432    fn png(marker: &str) -> ImageData {
1433        ImageData::Png(format!("fake-png-{marker}").into_bytes())
1434    }
1435
1436    /// A document holding `images` as background images on its top block.
1437    fn doc_with_images(images: Vec<ImageData>) -> Document {
1438        let mut doc = Document::default();
1439        let top = doc.top_id;
1440        let block = doc.blocks.get_mut(&top).expect("the top block");
1441        for image in images {
1442            block
1443                .images
1444                .insert_value(Image::new(image, egui::Rect::ZERO));
1445        }
1446        doc
1447    }
1448
1449    fn asset_ids(doc: &Document) -> Vec<String> {
1450        model::Document::from(doc)
1451            .assets
1452            .into_iter()
1453            .map(|a| a.id)
1454            .collect()
1455    }
1456
1457    #[test]
1458    fn an_asset_id_is_its_content_hash_and_extension() {
1459        let ids = asset_ids(&doc_with_images(vec![
1460            png("one"),
1461            ImageData::Svg("<svg/>".to_string()),
1462        ]));
1463        assert_eq!(ids.len(), 2);
1464        let split = |id: &str| {
1465            let (stem, ext) = id.rsplit_once('.').expect("an extension");
1466            (stem.to_string(), ext.to_string())
1467        };
1468        for (id, expected_ext) in ids.iter().zip(["png", "svg"]) {
1469            let (stem, ext) = split(id);
1470            assert_eq!(ext, expected_ext, "{id}");
1471            assert_eq!(stem.len(), 16, "{id}");
1472            assert!(stem.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
1473        }
1474        // Equal content, equal id — in this document and in any other.
1475        assert_eq!(
1476            asset_ids(&doc_with_images(vec![png("one")]))[0],
1477            ids[0],
1478            "the same image got different ids in different documents"
1479        );
1480    }
1481
1482    /// The property the content-derived ids exist for: with positional ids,
1483    /// dropping the first image renumbered every later reference, so an
1484    /// unrelated edit rewrote the whole file. Now nothing moves.
1485    #[test]
1486    fn removing_one_asset_does_not_rename_the_others() {
1487        let before = doc_with_images(vec![png("first"), png("second"), png("third")]);
1488        let ids_before = asset_ids(&before);
1489        assert_eq!(ids_before.len(), 3, "three distinct images");
1490
1491        let mut after = before.clone();
1492        let top = after.top_id;
1493        let first = *after.blocks[&top].images.keys().next().expect("an image");
1494        after
1495            .blocks
1496            .get_mut(&top)
1497            .expect("the top block")
1498            .images
1499            .shift_remove(&first);
1500
1501        assert_eq!(
1502            asset_ids(&after),
1503            ids_before[1..],
1504            "dropping the first image renamed the survivors"
1505        );
1506    }
1507
1508    /// A version-1 document's `i<N>` ids load, and are rewritten as content
1509    /// hashes on the next save — reader permissive, writer canonical.
1510    #[test]
1511    fn legacy_ids_are_recanonicalized_on_save() {
1512        let legacy = "top \"b1\"\n\
1513                      block \"b1\" x=0 y=0 w=40 h=30 {\n\
1514                      \x20   image \"i1\" x=0 y=0 w=4 h=4\n\
1515                      }\n\
1516                      asset \"i1\" {\n    svg r#\"<svg/>\"#\n}";
1517        let doc = from_kdl(legacy, "legacy.kdl").expect("a version-1 document loads");
1518
1519        let saved = to_kdl(&doc);
1520        assert!(
1521            !saved.contains("\"i1\""),
1522            "the legacy id survived:\n{saved}"
1523        );
1524        assert!(
1525            saved.contains(&format!("version {}", model::CURRENT_VERSION)),
1526            "{saved}"
1527        );
1528        let id = &asset_ids(&doc)[0];
1529        assert!(saved.contains(&format!("asset \"{id}\"")), "{saved}");
1530        assert!(saved.contains(&format!("image \"{id}\"")), "{saved}");
1531    }
1532}