1use crate::doc_ng::commit::CommitEnvelope;
14
15#[derive(Debug, thiserror::Error)]
16pub enum DecodeError {
17 #[error("malformed commit payload: {0}")]
18 Malformed(#[from] ciborium::de::Error<std::io::Error>),
19 #[error("{0} trailing byte(s) after the commit payload")]
24 Trailing(usize),
25}
26
27#[expect(clippy::expect_used, clippy::missing_panics_doc)]
30#[must_use]
31pub fn to_bytes(envelope: &CommitEnvelope) -> Vec<u8> {
32 let mut bytes = Vec::new();
33 ciborium::into_writer(envelope, &mut bytes).expect("a commit serializes infallibly");
34 bytes
35}
36
37pub fn from_bytes(bytes: &[u8]) -> Result<CommitEnvelope, DecodeError> {
41 let mut unread = bytes;
42 let envelope = ciborium::from_reader(&mut unread)?;
43 if unread.is_empty() {
44 Ok(envelope)
45 } else {
46 Err(DecodeError::Trailing(unread.len()))
47 }
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53 use crate::doc_ng::fixtures::{
54 block_id, comment_id, image_id, pin_id, route_id, route_label_id, text_id,
55 };
56 use crate::doc_ng::{
57 block_model::{
58 BlockInit, BlockUpdate, CommentInit, CommentUpdate, Icon, ImageInit, ImageUpdate,
59 LabelInit, LabelUpdate, PinInit, PinUpdate, RouteInit, RouteLabelInit,
60 RouteLabelUpdate, RouteUpdate, TextInit, TextUpdate,
61 },
62 commit::Commit,
63 document::TitleBlockUpdate,
64 geometry::{
65 FracVal, GRID_LIMIT, GridPoint, GridRect, GridSize, ScreenPoint, ScreenRect,
66 ScreenSize, Waypoint,
67 },
68 hash::{AssetHash, AssetKind, Hasher},
69 opcode::{Crud, OpCodes},
70 values::{LabelSide, PinDir, Role},
71 };
72
73 const GOLDEN_V1: &[u8] = include_bytes!("goldens/commit_v1.cbor");
76
77 const GOLDEN_V1_OPS: usize = 65;
79
80 fn rect(x: i32, y: i32) -> GridRect {
81 GridRect {
82 top_left: GridPoint { x, y },
83 size: GridSize { w: 4, h: 6 },
84 }
85 }
86
87 fn screen_rect(x: f32) -> ScreenRect {
88 ScreenRect {
89 top_left: ScreenPoint {
90 x: FracVal::from(x),
91 y: FracVal::from(2.5),
92 },
93 size: ScreenSize {
94 w: FracVal::from(8.25),
95 h: FracVal::from(9.5),
96 },
97 }
98 }
99
100 fn asset(bytes: &[u8]) -> AssetHash {
101 let mut hasher = Hasher::<AssetKind>::new();
102 hasher.update(bytes);
103 hasher.finalize()
104 }
105
106 fn label_init(name: &str) -> LabelInit {
107 LabelInit {
108 name: name.into(),
109 side: LabelSide::Bottom,
110 offset: FracVal::from(1.5),
111 hidden: true,
112 }
113 }
114
115 fn every_label_update() -> Vec<LabelUpdate> {
118 vec![
119 LabelUpdate::Name("renamed".into()),
120 LabelUpdate::Side(LabelSide::Center),
121 LabelUpdate::Offset(FracVal::from(0.75)),
122 LabelUpdate::Hidden(false),
123 ]
124 }
125
126 fn every_op() -> Vec<OpCodes> {
133 let mut ops = vec![OpCodes::Document(TitleBlockUpdate::Name("drawing".into()))];
134
135 ops.push(OpCodes::Block(
136 block_id(1),
137 Crud::Create(BlockInit {
138 parent: block_id(9),
139 rect: rect(2, 3),
140 locked: true,
141 title: label_init("title"),
142 type_label: label_init("type"),
143 icon: Icon {
144 asset: asset(b"icon"),
145 rect: screen_rect(1.0),
146 },
147 }),
148 ));
149 for update in [
150 BlockUpdate::Parent(block_id(8)),
151 BlockUpdate::Rect(rect(11, 12)),
152 BlockUpdate::Locked(false),
153 BlockUpdate::Icon(Icon {
154 asset: asset(b"other"),
155 rect: screen_rect(3.0),
156 }),
157 ] {
158 ops.push(OpCodes::Block(block_id(1), Crud::Update(update)));
159 }
160 for label in every_label_update() {
161 ops.push(OpCodes::Block(
162 block_id(1),
163 Crud::Update(BlockUpdate::Title(label)),
164 ));
165 }
166 for label in every_label_update() {
167 ops.push(OpCodes::Block(
168 block_id(1),
169 Crud::Update(BlockUpdate::TypeLabel(label)),
170 ));
171 }
172 ops.push(OpCodes::Block(block_id(1), Crud::Delete));
173 ops.push(OpCodes::Block(block_id(1), Crud::Restore));
174
175 ops.push(OpCodes::Pin(
176 pin_id(2),
177 Crud::Create(PinInit {
178 owner: block_id(1),
179 name: "clk".into(),
180 type_name: "clock".into(),
181 tag: "t0".into(),
182 tag_hidden: true,
183 rect: rect(4, 5),
184 dir: PinDir::Output,
185 pin_accent: Role::Accent3,
186 port_accent: Role::Accent4,
187 port_pin_accent: Role::Accent5,
188 flip_lr: true,
189 }),
190 ));
191 for update in [
192 PinUpdate::Owner(block_id(7)),
193 PinUpdate::Name("rst".into()),
194 PinUpdate::TypeName("reset".into()),
195 PinUpdate::Tag("t1".into()),
196 PinUpdate::TagHidden(false),
197 PinUpdate::Rect(rect(13, 14)),
198 PinUpdate::Dir(PinDir::InOut),
199 PinUpdate::PinAccent(Role::Accent1),
200 PinUpdate::PortAccent(Role::Accent2),
201 PinUpdate::PortPinAccent(Role::Accent6),
202 PinUpdate::FlipLR(false),
203 ] {
204 ops.push(OpCodes::Pin(pin_id(2), Crud::Update(update)));
205 }
206 ops.push(OpCodes::Pin(pin_id(2), Crud::Delete));
207 ops.push(OpCodes::Pin(pin_id(2), Crud::Restore));
208
209 ops.push(OpCodes::Route(
210 route_id(3),
211 Crud::Create(RouteInit {
212 owner: block_id(1),
213 name: "net7".into(),
214 from: pin_id(2),
215 to: pin_id(4),
216 role: Role::Accent2,
217 waypoints: vec![Waypoint {
218 pos: GridPoint { x: 6, y: 7 },
219 locked: true,
220 }],
221 }),
222 ));
223 for update in [
224 RouteUpdate::Owner(block_id(6)),
225 RouteUpdate::Name("net8".into()),
226 RouteUpdate::Role(Role::Accent1),
227 RouteUpdate::Waypoints(vec![Waypoint {
228 pos: GridPoint { x: 15, y: 16 },
229 locked: false,
230 }]),
231 ] {
232 ops.push(OpCodes::Route(route_id(3), Crud::Update(update)));
233 }
234 ops.push(OpCodes::Route(route_id(3), Crud::Delete));
235 ops.push(OpCodes::Route(route_id(3), Crud::Restore));
236
237 ops.push(OpCodes::RouteLabel(
238 route_label_id(4),
239 Crud::Create(RouteLabelInit {
240 owner: route_id(3),
241 pos: FracVal::from(0.25),
242 }),
243 ));
244 for update in [
245 RouteLabelUpdate::Owner(route_id(5)),
246 RouteLabelUpdate::Pos(FracVal::from(0.5)),
247 ] {
248 ops.push(OpCodes::RouteLabel(route_label_id(4), Crud::Update(update)));
249 }
250 ops.push(OpCodes::RouteLabel(route_label_id(4), Crud::Delete));
251 ops.push(OpCodes::RouteLabel(route_label_id(4), Crud::Restore));
252
253 ops.push(OpCodes::Text(
254 text_id(5),
255 Crud::Create(TextInit {
256 owner: block_id(1),
257 text: "note".into(),
258 pos: GridPoint { x: 8, y: 9 },
259 role: Role::Accent1,
260 }),
261 ));
262 for update in [
263 TextUpdate::Owner(block_id(5)),
264 TextUpdate::Text("edited".into()),
265 TextUpdate::Pos(GridPoint { x: 17, y: 18 }),
266 TextUpdate::Role(Role::Accent4),
267 ] {
268 ops.push(OpCodes::Text(text_id(5), Crud::Update(update)));
269 }
270 ops.push(OpCodes::Text(text_id(5), Crud::Delete));
271 ops.push(OpCodes::Text(text_id(5), Crud::Restore));
272
273 ops.push(OpCodes::Comment(
274 comment_id(6),
275 Crud::Create(CommentInit {
276 owner: block_id(1),
277 rect: rect(10, 11),
278 role: Role::Accent6,
279 title: label_init("comment"),
280 }),
281 ));
282 for update in [
283 CommentUpdate::Owner(block_id(4)),
284 CommentUpdate::Rect(rect(19, 20)),
285 CommentUpdate::Role(Role::Accent5),
286 ] {
287 ops.push(OpCodes::Comment(comment_id(6), Crud::Update(update)));
288 }
289 for label in every_label_update() {
290 ops.push(OpCodes::Comment(
291 comment_id(6),
292 Crud::Update(CommentUpdate::Title(label)),
293 ));
294 }
295 ops.push(OpCodes::Comment(comment_id(6), Crud::Delete));
296 ops.push(OpCodes::Comment(comment_id(6), Crud::Restore));
297
298 ops.push(OpCodes::Image(
299 image_id(7),
300 Crud::Create(ImageInit {
301 owner: block_id(1),
302 asset: asset(b"png"),
303 rect: screen_rect(5.0),
304 }),
305 ));
306 for update in [
307 ImageUpdate::Owner(block_id(3)),
308 ImageUpdate::Asset(asset(b"jpg")),
309 ImageUpdate::Rect(screen_rect(7.0)),
310 ] {
311 ops.push(OpCodes::Image(image_id(7), Crud::Update(update)));
312 }
313 ops.push(OpCodes::Image(image_id(7), Crud::Delete));
314 ops.push(OpCodes::Image(image_id(7), Crud::Restore));
315
316 ops
317 }
318
319 fn tag(op: &OpCodes) -> String {
323 fn label(update: &LabelUpdate) -> &'static str {
324 match update {
325 LabelUpdate::Name(_) => "Name",
326 LabelUpdate::Side(_) => "Side",
327 LabelUpdate::Offset(_) => "Offset",
328 LabelUpdate::Hidden(_) => "Hidden",
329 }
330 }
331 fn lifecycle<I, U>(crud: &Crud<I, U>, update: impl FnOnce(&U) -> String) -> String {
332 match crud {
333 Crud::Create(_) => "Create".into(),
334 Crud::Restore => "Restore".into(),
335 Crud::Delete => "Delete".into(),
336 Crud::Update(inner) => format!("Update.{}", update(inner)),
337 }
338 }
339 match op {
340 OpCodes::Document(update) => match update {
341 TitleBlockUpdate::Name(_) => "Document.Name".into(),
342 },
343 OpCodes::Block(_, crud) => format!(
344 "Block.{}",
345 lifecycle(crud, |update| match update {
346 BlockUpdate::Parent(_) => "Parent".into(),
347 BlockUpdate::Rect(_) => "Rect".into(),
348 BlockUpdate::Locked(_) => "Locked".into(),
349 BlockUpdate::Icon(_) => "Icon".into(),
350 BlockUpdate::Title(inner) => format!("Title.{}", label(inner)),
351 BlockUpdate::TypeLabel(inner) => format!("TypeLabel.{}", label(inner)),
352 })
353 ),
354 OpCodes::Pin(_, crud) => format!(
355 "Pin.{}",
356 lifecycle(crud, |update| match update {
357 PinUpdate::Owner(_) => "Owner",
358 PinUpdate::Name(_) => "Name",
359 PinUpdate::TypeName(_) => "TypeName",
360 PinUpdate::Tag(_) => "Tag",
361 PinUpdate::TagHidden(_) => "TagHidden",
362 PinUpdate::Rect(_) => "Rect",
363 PinUpdate::Dir(_) => "Dir",
364 PinUpdate::PinAccent(_) => "PinAccent",
365 PinUpdate::PortAccent(_) => "PortAccent",
366 PinUpdate::PortPinAccent(_) => "PortPinAccent",
367 PinUpdate::FlipLR(_) => "FlipLR",
368 }
369 .into())
370 ),
371 OpCodes::Route(_, crud) => format!(
372 "Route.{}",
373 lifecycle(crud, |update| match update {
374 RouteUpdate::Owner(_) => "Owner",
375 RouteUpdate::Name(_) => "Name",
376 RouteUpdate::Role(_) => "Role",
377 RouteUpdate::Waypoints(_) => "Waypoints",
378 }
379 .into())
380 ),
381 OpCodes::RouteLabel(_, crud) => format!(
382 "RouteLabel.{}",
383 lifecycle(crud, |update| match update {
384 RouteLabelUpdate::Owner(_) => "Owner",
385 RouteLabelUpdate::Pos(_) => "Pos",
386 }
387 .into())
388 ),
389 OpCodes::Text(_, crud) => format!(
390 "Text.{}",
391 lifecycle(crud, |update| match update {
392 TextUpdate::Owner(_) => "Owner",
393 TextUpdate::Text(_) => "Text",
394 TextUpdate::Pos(_) => "Pos",
395 TextUpdate::Role(_) => "Role",
396 }
397 .into())
398 ),
399 OpCodes::Comment(_, crud) => format!(
400 "Comment.{}",
401 lifecycle(crud, |update| match update {
402 CommentUpdate::Owner(_) => "Owner".into(),
403 CommentUpdate::Rect(_) => "Rect".into(),
404 CommentUpdate::Role(_) => "Role".into(),
405 CommentUpdate::Title(inner) => format!("Title.{}", label(inner)),
406 })
407 ),
408 OpCodes::Image(_, crud) => format!(
409 "Image.{}",
410 lifecycle(crud, |update| match update {
411 ImageUpdate::Owner(_) => "Owner",
412 ImageUpdate::Asset(_) => "Asset",
413 ImageUpdate::Rect(_) => "Rect",
414 }
415 .into())
416 ),
417 }
418 }
419
420 fn golden_envelope() -> CommitEnvelope {
421 CommitEnvelope::CommitV1(Commit::new("golden".into(), every_op()))
422 }
423
424 fn ops_of(envelope: &CommitEnvelope) -> &[OpCodes] {
425 let CommitEnvelope::CommitV1(commit) = envelope;
426 commit.ops()
427 }
428
429 #[test]
434 fn the_golden_decodes_to_its_pinned_values() {
435 let decoded = from_bytes(GOLDEN_V1).expect("pinned bytes must decode in every build");
436 let expected = every_op();
437
438 assert_eq!(ops_of(&decoded).len(), GOLDEN_V1_OPS);
439 assert_eq!(ops_of(&decoded), &expected[..GOLDEN_V1_OPS]);
440 }
441
442 #[test]
446 fn every_variant_is_covered_by_a_golden() {
447 let ops = every_op();
448 assert_eq!(
449 ops.len(),
450 GOLDEN_V1_OPS,
451 "a variant was added to the vocabulary but no golden covers it",
452 );
453
454 let mut tags: Vec<String> = ops.iter().map(tag).collect();
455 tags.sort();
456 tags.dedup();
457 assert_eq!(tags.len(), ops.len(), "the fixture repeats a variant");
458 }
459
460 #[test]
461 fn an_envelope_round_trips() {
462 let envelope = golden_envelope();
463 let decoded = from_bytes(&to_bytes(&envelope)).expect("its own output decodes");
464 assert_eq!(decoded, envelope);
465 }
466
467 #[test]
470 fn an_unknown_update_variant_is_refused() {
471 let payload = ciborium::Value::Map(vec![(
472 ciborium::Value::Text("CommitV1".into()),
473 ciborium::Value::Map(vec![
474 (
475 ciborium::Value::Text("label".into()),
476 ciborium::Value::Text("from a newer build".into()),
477 ),
478 (
479 ciborium::Value::Text("ops".into()),
480 ciborium::Value::Array(vec![ciborium::Value::Map(vec![(
481 ciborium::Value::Text("Document".into()),
482 ciborium::Value::Map(vec![(
483 ciborium::Value::Text("Subtitle".into()),
484 ciborium::Value::Text("a register this build lacks".into()),
485 )]),
486 )])]),
487 ),
488 ]),
489 )]);
490
491 let mut bytes = Vec::new();
492 ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
493 assert!(from_bytes(&bytes).is_err());
494 }
495
496 #[test]
499 fn a_future_envelope_version_is_refused() {
500 let payload = ciborium::Value::Map(vec![(
501 ciborium::Value::Text("CommitV2".into()),
502 ciborium::Value::Map(vec![(
503 ciborium::Value::Text("label".into()),
504 ciborium::Value::Text("from a newer build".into()),
505 )]),
506 )]);
507
508 let mut bytes = Vec::new();
509 ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
510 assert!(from_bytes(&bytes).is_err());
511 }
512
513 #[test]
514 fn a_truncated_payload_is_refused() {
515 let bytes = to_bytes(&golden_envelope());
516 for cut in [1, bytes.len() / 2, bytes.len() - 1] {
517 assert!(
518 from_bytes(&bytes[..cut]).is_err(),
519 "a payload cut at {cut} must not decode",
520 );
521 }
522 }
523
524 #[test]
527 fn trailing_bytes_are_refused() {
528 let mut bytes = to_bytes(&golden_envelope());
529 bytes.push(0xff);
530 assert!(from_bytes(&bytes).is_err());
531 }
532
533 #[test]
536 fn geometry_outside_the_document_extent_is_refused() {
537 for coordinate in [i32::MAX, i32::MIN, GRID_LIMIT + 1, -GRID_LIMIT - 1] {
538 let payload = ciborium::Value::Map(vec![
539 (
540 ciborium::Value::Text("x".into()),
541 ciborium::Value::Integer(coordinate.into()),
542 ),
543 (
544 ciborium::Value::Text("y".into()),
545 ciborium::Value::Integer(0.into()),
546 ),
547 ]);
548 let mut bytes = Vec::new();
549 ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
550 assert!(
551 ciborium::from_reader::<GridPoint, _>(&bytes[..]).is_err(),
552 "{coordinate} must be refused",
553 );
554 }
555
556 let payload = ciborium::Value::Map(vec![
557 (
558 ciborium::Value::Text("w".into()),
559 ciborium::Value::Integer(u32::MAX.into()),
560 ),
561 (
562 ciborium::Value::Text("h".into()),
563 ciborium::Value::Integer(1.into()),
564 ),
565 ]);
566 let mut bytes = Vec::new();
567 ciborium::into_writer(&payload, &mut bytes).expect("the probe serializes");
568 assert!(ciborium::from_reader::<GridSize, _>(&bytes[..]).is_err());
569
570 let mut bytes = Vec::new();
571 ciborium::into_writer(&i64::MAX, &mut bytes).expect("the probe serializes");
572 assert!(ciborium::from_reader::<FracVal, _>(&bytes[..]).is_err());
573 }
574
575 #[test]
577 fn geometry_inside_the_document_extent_decodes() {
578 for point in [
579 GridPoint { x: 0, y: 0 },
580 GridPoint {
581 x: GRID_LIMIT,
582 y: -GRID_LIMIT,
583 },
584 ] {
585 let mut bytes = Vec::new();
586 ciborium::into_writer(&point, &mut bytes).expect("the probe serializes");
587 assert_eq!(
588 ciborium::from_reader::<GridPoint, _>(&bytes[..]).expect("in-extent decodes"),
589 point,
590 );
591 }
592 }
593
594 #[test]
599 #[ignore = "writes a golden fixture; run deliberately"]
600 fn regenerate_goldens() {
601 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
602 .join("src/doc_ng/goldens/commit_v1.cbor");
603 std::fs::create_dir_all(path.parent().expect("the goldens directory"))
604 .expect("the goldens directory is writable");
605 std::fs::write(&path, to_bytes(&golden_envelope())).expect("the golden is writable");
606 }
607}