0016. Loading & Saving Board & Review Session Files (serde, serde_json & thiserror)
Serializing BoardModel and ReviewNote pins into a schema-versioned ReviewSessionEnvelope JSON payload, validating untrusted inputs with exhaustive destructuring, and surfacing SessionIoError banners in egui::Panel::right without panicking.
Versioned Envelope Persistence and Exhaustive Destructuring
A hardware design review tool is only useful if engineers can save their review pins (ReviewNote status, severity, author, and millimetre coordinates) to a file, share it in a pull request, and reload it without losing state.Why Wrap BoardModel in ReviewSessionEnvelope? Serializing a raw BoardModel directly leaves no metadata to detect format changes later. Wrapping it in ReviewSessionEnvelope { schema_version: 1, board_id, board } lets from_session_json reject incompatible schema versions with a clear, typed error before touching application state.
Because pcb-doc (L1) owns all domain types, adding persistence requires zero changes to our egui rendering code:
- Enable
emath's"serde"feature: Runningcargo add --package pcb-doc emath --features serdeupdatescrates/pcb-doc/Cargo.tomlsoemath = { version = "0.37", features = ["serde"] }enablesSerializeandDeserializeonPos2,Vec2, andRect. - Define a typed
SessionIoErrorwiththiserror: Instead of returning opaque strings or panicking on corrupt files,SessionIoErrormodels three explicit failure modes:EmptyBoardId,UnsupportedSchemaVersion { found, expected }, andMalformedJson(String). - Destructure
ReviewSessionEnvelopeexhaustively: InBoardModel::from_session_json, we unpacklet ReviewSessionEnvelope { schema_version, board_id, board } = ...without..so adding any new envelope field in the future triggers a compiler error until validation is wired.
Add the persistence and error-handling dependencies to pcb-doc with cargo add:
cargo add --package pcb-doc emath --features serde
cargo add --package pcb-doc serde --features derive
cargo add --package pcb-doc serde_json thiserror
Here is the resulting crates/pcb-doc/Cargo.toml:
[package]
name = "pcb-doc"
version = "0.1.0"
edition = "2024"
[lints]
workspace = true
[dependencies]
emath = { version = "0.37", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
flowchart LR
Raw["Raw JSON (&str)"] --> Parse["serde_json::from_str::<ReviewSessionEnvelope>"]
Parse -- "Err(e)" --> Malformed["Err(SessionIoError::MalformedJson)"]
Parse -- "schema_version != 1" --> BadVer["Err(SessionIoError::UnsupportedSchemaVersion)"]
Parse -- "board_id.trim().is_empty()" --> EmptyId["Err(SessionIoError::EmptyBoardId)"]
Parse -- "Valid v1 Envelope" --> OkBoard["Ok((board_id, BoardModel))"]
style OkBoard fill:#14231c,stroke:#3dd68c,color:#e6edf3
style Malformed fill:#2a1418,stroke:#ff5555,color:#e6edf3
style BadVer fill:#2a1418,stroke:#ff5555,color:#e6edf3
style EmptyId fill:#2a1418,stroke:#ff5555,color:#e6edf3
Non-Destructive Session I/O & Error Recovery: Block by Block
When a user attempts to load a corrupt or incompatible JSON file in PcbReviewerApp::import_session_from_json(&mut self, raw: &str), the UI must never overwrite self.board with partial data or crash.Why Panel::right("session_panel")? Placing session persistence, snapshot controls, and error banners in a dedicated right-hand inspector panel keeps the left layer/BOM panel (Panel::left) and top cross-probe bar (Panel::top) within their vertical and horizontal layout budgets. Let's walk through all the new persistence and UI recovery blocks added in Lesson 0016 across crates/pcb-doc/src/lib.rs and src/main.rs, then inspect the complete assembled listing.
Block 1: Typed SessionIoError, ReviewSessionEnvelope & serde Derives (crates/pcb-doc/src/lib.rs)
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Current schema version for serialized PCB review sessions.
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
/// Typed, panic-free persistence errors for PCB & review session files.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum SessionIoError {
#[error("board_id must not be empty")]
EmptyBoardId,
#[error("unsupported schema_version {found} (expected {expected})")]
UnsupportedSchemaVersion { found: u32, expected: u32 },
#[error("malformed session JSON: {0}")]
MalformedJson(String),
}
/// Versioned envelope wrapping a serialized `BoardModel`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReviewSessionEnvelope {
pub schema_version: u32,
pub board_id: String,
pub board: BoardModel,
}
use serde::{Deserialize, Serialize};& Domain Derives: Withserde = { version = "1.0", features = ["derive"] }andemath = { version = "0.37", features = ["serde"] }enabled incrates/pcb-doc/Cargo.toml, we addSerialize, DeserializetoNetId,PinId,PinSeverity,ReviewStatus,ReviewNote,PcbLayer,TraceSegment,SmdPad,ComponentSymbol,BoardModel, andReviewSessionEnvelope.#[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum SessionIoError: Usesthiserror::Errorto generatestd::fmt::Displayandstd::error::Errorimplementations automatically from#[error("...")]format strings:EmptyBoardId: Triggered whenboard_id.trim()is empty on export or import.UnsupportedSchemaVersion { found, expected }: Interpolates named struct-variant fields ({found}and{expected}) into the error message whenschema_version != CURRENT_SCHEMA_VERSION.MalformedJson(String): Interpolates tuple field{0}whenserde_jsonencounters syntax or type errors.
ReviewSessionEnvelope: Wrapsboard: BoardModelalongsideschema_version: u32andboard_id: Stringso every saved JSON payload carries self-describing header metadata.
Block 2: Exporting & Validating Session JSON (to_session_json & from_session_json in crates/pcb-doc/src/lib.rs)
impl BoardModel {
/// Serializes the board and review pins into a versioned JSON session string.
pub fn to_session_json(&self, board_id: &str) -> Result<String, SessionIoError> {
let trimmed = board_id.trim();
if trimmed.is_empty() {
return Err(SessionIoError::EmptyBoardId);
}
let envelope = ReviewSessionEnvelope {
schema_version: CURRENT_SCHEMA_VERSION,
board_id: trimmed.to_owned(),
board: self.clone(),
};
serde_json::to_string_pretty(&envelope)
.map_err(|e| SessionIoError::MalformedJson(e.to_string()))
}
/// Deserializes and validates a versioned JSON session string without panicking.
pub fn from_session_json(raw: &str) -> Result<(String, Self), SessionIoError> {
let ReviewSessionEnvelope {
schema_version,
board_id,
board,
} = serde_json::from_str::<ReviewSessionEnvelope>(raw)
.map_err(|e| SessionIoError::MalformedJson(e.to_string()))?;
if schema_version != CURRENT_SCHEMA_VERSION {
return Err(SessionIoError::UnsupportedSchemaVersion {
found: schema_version,
expected: CURRENT_SCHEMA_VERSION,
});
}
let trimmed = board_id.trim();
if trimmed.is_empty() {
return Err(SessionIoError::EmptyBoardId);
}
Ok((trimmed.to_owned(), board))
}
}
to_session_json(&self, board_id: &str) -> Result<String, SessionIoError>: Trims leading/trailing whitespace fromboard_id, rejects blank IDs withErr(SessionIoError::EmptyBoardId), wrapsself.clone()in aReviewSessionEnvelopeatCURRENT_SCHEMA_VERSION, and formats human-readable JSON viaserde_json::to_string_pretty(&envelope).- Exhaustive Struct Destructuring (
let ReviewSessionEnvelope { schema_version, board_id, board } = ...): Unpacks all three fields without a..wildcard. If a future schema revision adds a field toReviewSessionEnvelope,rustcrefuses to compilefrom_session_jsonuntil that new field is explicitly handled. - Error Mapping (
map_err+?): Converts anyserde_json::ErrorintoSessionIoError::MalformedJson(e.to_string())and propagates it via the?operator before validatingschema_versionandboard_id.trim().
Block 3: Session State, Non-Destructive Import & Panel::right Error Banner (src/main.rs)
// Added to ViewState (initialized to "pwr-stage-rev-b" and None in ViewState::default()):
pub struct ViewState {
// ... existing layer, viewport, BOM, pin, and DRC fields ...
pub active_board_id: String,
pub last_io_error: Option<String>,
}
impl ViewState {
pub fn dismiss_io_error(&mut self) {
self.last_io_error = None;
}
}
#[derive(Default)]
pub struct PcbReviewerApp {
pub board: BoardModel,
pub view: ViewState,
pub saved_session_json: String,
}
impl PcbReviewerApp {
/// Serializes `self.board` into `self.saved_session_json`, recording any error in `view.last_io_error`.
pub fn export_session_snapshot(&mut self) -> bool {
match self.board.to_session_json(&self.view.active_board_id) {
Ok(json) => {
self.saved_session_json = json;
self.view.last_io_error = None;
true
}
Err(err) => {
self.view.last_io_error = Some(err.to_string());
false
}
}
}
/// Loads `raw` JSON into `self.board`, or surfaces a clean error banner without panicking.
pub fn import_session_from_json(&mut self, raw: &str) -> bool {
match BoardModel::from_session_json(raw) {
Ok((board_id, board)) => {
self.view.active_board_id = board_id;
self.board = board;
self.view.last_io_error = None;
true
}
Err(err) => {
self.view.last_io_error = Some(err.to_string());
false
}
}
}
pub fn render_session_panel(&mut self, ui: &mut egui::Ui) {
ui.heading("Session Persistence (JSON)");
ui.label(format!("Board ID: {}", self.view.active_board_id));
if ui.button("Export JSON").clicked() {
self.export_session_snapshot();
}
if ui.button("Reload JSON").clicked() {
let raw = self.saved_session_json.clone();
self.import_session_from_json(&raw);
}
if ui.button("Test Corrupt JSON").clicked() {
self.import_session_from_json("{corrupt");
}
if let Some(err) = self.view.last_io_error.clone() {
ui.label(format!("I/O Error: {err}"));
if ui.button("Dismiss Error").clicked() {
self.view.dismiss_io_error();
}
}
}
}
export_session_snapshot(&mut self) -> bool&import_session_from_json(&mut self, raw: &str) -> bool: BridgeBoardModel'sResult<..., SessionIoError>methods into UI state transitions. OnOk(...), they updatesaved_session_jsonor(active_board_id, board)and clearself.view.last_io_error = None. OnErr(err), they leaveself.boarduntouched and storeSome(err.to_string())inself.view.last_io_error.- Borrowing Around
&mut selfin"Reload JSON": Insiderender_session_panel,let raw = self.saved_session_json.clone();clones the saved JSON string before callingself.import_session_from_json(&raw)so we don't hold an immutable borrow ofself.saved_session_jsonacross a&mut selfmethod call. - Mounting
egui::Panel::right("session_panel")ineframe::App::ui: Insideimpl eframe::App for PcbReviewerApp, we addegui::Panel::right("session_panel").show(ui, |ui| { self.render_session_panel(ui); });beforeCentralPanel::default()soeguidocks the session inspector on the right edge of the viewport.
Block 4: Round-Trip Persistence & Error Banner Tests (crates/pcb-doc/src/lib.rs & src/main.rs)
// In crates/pcb-doc/src/lib.rs (#[cfg(test)] mod tests):
#[test]
fn session_json_round_trip_and_typed_validation_errors_are_lossless() {
let mut original = BoardModel::default();
original.add_review_pin_at(
pos2(52.0, 35.0),
"carol",
"Verify R12 footprint",
PinSeverity::Warning,
);
assert_eq!(
original.to_session_json(" "),
Err(SessionIoError::EmptyBoardId)
);
let json = original
.to_session_json(" pwr-stage-rev-b ")
.expect("valid board should serialize");
let (loaded_id, loaded_board) =
BoardModel::from_session_json(&json).expect("valid JSON should deserialize");
assert_eq!(loaded_id, "pwr-stage-rev-b");
assert_eq!(loaded_board, original);
let bad_version = json.replace("\"schema_version\": 1", "\"schema_version\": 99");
assert_eq!(
BoardModel::from_session_json(&bad_version),
Err(SessionIoError::UnsupportedSchemaVersion {
found: 99,
expected: CURRENT_SCHEMA_VERSION,
})
);
let empty_board_id =
json.replace("\"board_id\": \"pwr-stage-rev-b\"", "\"board_id\": \" \"");
assert_eq!(
BoardModel::from_session_json(&empty_board_id),
Err(SessionIoError::EmptyBoardId)
);
let err = BoardModel::from_session_json("{ truncated json").unwrap_err();
assert!(matches!(err, SessionIoError::MalformedJson(_)));
assert!(err.to_string().contains("malformed session JSON"));
}
// In src/main.rs (egui_kittest workflow inside standalone_layer_swatch_and_headless_ui_workflows_pass):
harness.get_by_label("Export JSON").click();
harness.run();
assert!(!harness.state().saved_session_json.is_empty());
harness.state_mut().board.review_pins.clear();
harness.run();
assert!(harness.query_by_label("Review Pins: 0").is_some());
harness.get_by_label("Reload JSON").click();
harness.run();
assert!(harness.query_by_label("Review Pins: 2").is_some());
harness.get_by_label("Test Corrupt JSON").click();
harness.run();
assert!(harness.state().view.last_io_error.is_some());
harness.get_by_label("Dismiss Error").click();
harness.run();
assert!(harness.state().view.last_io_error.is_none());
session_json_round_trip_and_typed_validation_errors_are_lossless: Exercises every persistence branch inpcb-doc: whitespace-onlyboard_idrejection on export, fullBoardModel+ReviewNoteround-trip equality (assert_eq!(loaded_board, original)),UnsupportedSchemaVersion { found: 99, expected: 1 },EmptyBoardIdon import, andMalformedJsondisplay formatting.- Headless
egui_kittestPersistence Flow: Clicks"Export JSON", wipesreview_pinsin memory (Review Pins: 0), clicks"Reload JSON"to prove the 2 pins are restored in the UI, triggers"Test Corrupt JSON"to verify the error banner appears without panicking, and clicks"Dismiss Error"to clearlast_io_error.
Complete Lesson 0016 Session Persistence Listing (crates/pcb-doc/src/lib.rs & src/main.rs)
Here are all the new and updated Lesson 0016 sections across crates/pcb-doc/src/lib.rs and src/main.rs assembled in full context:
// === crates/pcb-doc/src/lib.rs ===
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Current schema version for serialized PCB review sessions.
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
/// Typed, panic-free persistence errors for PCB & review session files.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum SessionIoError {
#[error("board_id must not be empty")]
EmptyBoardId,
#[error("unsupported schema_version {found} (expected {expected})")]
UnsupportedSchemaVersion { found: u32, expected: u32 },
#[error("malformed session JSON: {0}")]
MalformedJson(String),
}
/// Versioned envelope wrapping a serialized `BoardModel`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReviewSessionEnvelope {
pub schema_version: u32,
pub board_id: String,
pub board: BoardModel,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BoardModel {
pub traces: Vec<TraceSegment>,
pub pads: Vec<SmdPad>,
pub components: Vec<ComponentSymbol>,
pub review_pins: HashMap<PinId, ReviewNote>,
pub next_pin_id: u32,
}
impl BoardModel {
/// Serializes the board and review pins into a versioned JSON session string.
pub fn to_session_json(&self, board_id: &str) -> Result<String, SessionIoError> {
let trimmed = board_id.trim();
if trimmed.is_empty() {
return Err(SessionIoError::EmptyBoardId);
}
let envelope = ReviewSessionEnvelope {
schema_version: CURRENT_SCHEMA_VERSION,
board_id: trimmed.to_owned(),
board: self.clone(),
};
serde_json::to_string_pretty(&envelope)
.map_err(|e| SessionIoError::MalformedJson(e.to_string()))
}
/// Deserializes and validates a versioned JSON session string without panicking.
pub fn from_session_json(raw: &str) -> Result<(String, Self), SessionIoError> {
let ReviewSessionEnvelope {
schema_version,
board_id,
board,
} = serde_json::from_str::<ReviewSessionEnvelope>(raw)
.map_err(|e| SessionIoError::MalformedJson(e.to_string()))?;
if schema_version != CURRENT_SCHEMA_VERSION {
return Err(SessionIoError::UnsupportedSchemaVersion {
found: schema_version,
expected: CURRENT_SCHEMA_VERSION,
});
}
let trimmed = board_id.trim();
if trimmed.is_empty() {
return Err(SessionIoError::EmptyBoardId);
}
Ok((trimmed.to_owned(), board))
}
}
// === src/main.rs ===
pub use pcb_doc::{
BoardModel, CURRENT_SCHEMA_VERSION, ComponentSymbol, DrcViolation,
FORBIDDEN_UI_DEPS_BELOW_L6, NetId, PcbLayer, PinId, PinSeverity, Pos2, Rect, ReviewNote,
ReviewSessionEnvelope, ReviewStatus, RulerMeasurement, SessionIoError, SmdPad, TSTransform,
TraceSegment, Vec2, pad_edge_gap_mm, pos2, validate_l1_manifest_no_ui_deps, vec2,
};
impl ViewState {
pub fn dismiss_io_error(&mut self) {
self.last_io_error = None;
}
}
#[derive(Default)]
pub struct PcbReviewerApp {
pub board: BoardModel,
pub view: ViewState,
pub saved_session_json: String,
}
impl PcbReviewerApp {
/// Serializes `self.board` into `self.saved_session_json`, recording any error in `view.last_io_error`.
pub fn export_session_snapshot(&mut self) -> bool {
match self.board.to_session_json(&self.view.active_board_id) {
Ok(json) => {
self.saved_session_json = json;
self.view.last_io_error = None;
true
}
Err(err) => {
self.view.last_io_error = Some(err.to_string());
false
}
}
}
/// Loads `raw` JSON into `self.board`, or surfaces a clean error banner without panicking.
pub fn import_session_from_json(&mut self, raw: &str) -> bool {
match BoardModel::from_session_json(raw) {
Ok((board_id, board)) => {
self.view.active_board_id = board_id;
self.board = board;
self.view.last_io_error = None;
true
}
Err(err) => {
self.view.last_io_error = Some(err.to_string());
false
}
}
}
pub fn render_session_panel(&mut self, ui: &mut egui::Ui) {
ui.heading("Session Persistence (JSON)");
ui.label(format!("Board ID: {}", self.view.active_board_id));
if ui.button("Export JSON").clicked() {
self.export_session_snapshot();
}
if ui.button("Reload JSON").clicked() {
let raw = self.saved_session_json.clone();
self.import_session_from_json(&raw);
}
if ui.button("Test Corrupt JSON").clicked() {
self.import_session_from_json("{corrupt");
}
if let Some(err) = self.view.last_io_error.clone() {
ui.label(format!("I/O Error: {err}"));
if ui.button("Dismiss Error").clicked() {
self.view.dismiss_io_error();
}
}
}
}
impl eframe::App for PcbReviewerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.handle_hotkeys(ui.ctx());
egui::Panel::top("schematic_cross_probe_bar").show(ui, |ui| {
self.render_schematic_bar(ui);
});
egui::Panel::left("layer_panel").show(ui, |ui| {
self.render_side_panel(ui);
});
egui::Panel::right("session_panel").show(ui, |ui| {
self.render_session_panel(ui);
});
egui::CentralPanel::default().show(ui, |ui| {
self.render_canvas(ui);
});
self.render_active_pin_callout(ui.ctx());
}
}
serde_json Envelope & Typed Error Recovery
ReviewSessionEnvelope • thiserror • SessionIoError
Switch between a valid v1 session payload, an unsupported v99 schema, an empty board_id, or truncated JSON below. Observe how BoardModel::from_session_json returns a typed Result without ever panicking or clobbering the active board.
1. Session Payload Scenario
2. Raw JSON Input
3. from_session_json & ViewState
ReviewSessionEnvelope and Surface last_io_error
Skill Practice • serde & thiserror
Complete BoardModel::from_session_json and PcbReviewerApp::import_session_from_json so invalid schema versions or malformed JSON return typed errors and populate self.view.last_io_error without overwriting self.board.
pub fn from_session_json(raw: &str) -> Result<(String, Self), SessionIoError> {
let ReviewSessionEnvelope {
schema_version,
board_id,
board,
} = serde_json::
.map_err(|e| SessionIoError::MalformedJson(e.to_string()))?;
if schema_version != {
return Err(SessionIoError::UnsupportedSchemaVersion {
found: schema_version,
expected: CURRENT_SCHEMA_VERSION,
});
}
Ok((board_id.trim().to_owned(), board))
}
pub fn import_session_from_json(&mut self, raw: &str) -> bool {
match BoardModel::from_session_json(raw) {
Ok((board_id, board)) => {
self.view.active_board_id = board_id;
self.board = board;
self.view.last_io_error = ;
true
}
Err(err) => {
self.view.last_io_error = Some(err.to_string());
false
}
}
}
0016)
cargo test --workspace + cargo mutants
Verify that valid sessions round-trip losslessly (loaded_board == original) and that every error branch (EmptyBoardId, UnsupportedSchemaVersion, MalformedJson) and UI button ("Export JSON", "Reload JSON", "Test Corrupt JSON", "Dismiss Error") is mutation-covered:
cargo test --manifest-path ./apps/0016-loading-board-and-review-files-serde/Cargo.toml --workspace
cargo mutants --manifest-path ./apps/0016-loading-board-and-review-files-serde/Cargo.toml --workspace --in-place -F "to_session_json|from_session_json|export_session_snapshot|import_session_from_json|dismiss_io_error|render_session_panel"
Recommended Primary Source
thiserror Crate Documentation.
Arc<Vec<T>> and Arc::make_mut, where we upgrade BoardModel so taking a snapshot of 10,000+ pads and traces costs only four O(1) atomic reference-count increments!