pmcp_workbook_runtime/render/layout.rs
1//! The shared, versioned [`LayoutDescriptor`] serde model (Phase 12, Plan 01).
2//!
3//! This is the SINGLE shared definition (umya-free, zip-free) the offline
4//! emitter (`workbook-compiler::artifact::layout::build_layout_descriptor`) and
5//! the serve-time writer (Plan 02) BOTH use — the Codex HIGH #2 single-definition
6//! discipline that `artifact_model.rs` already follows for `CellMap`/`BundleLock`.
7//! Defining it here (and NOT in either the compiler or the served binary) keeps
8//! the descriptor's serde shape free of any `umya`/`rust_xlsxwriter` type: it
9//! derives ONLY over `String`/`Option`/`Vec`/`bool`/`u32`/`u16`/`f64`.
10//!
11//! The descriptor captures the FULL ingested workbook layout (D-05) — a "copy of
12//! the workbook," not a synthetic minimal stub — so the writer can replay it and
13//! inject the computed values (D-06). It is hashed into the `BUNDLE.lock` combined
14//! hash exactly like `cell_map.json` (so the boot integrity check covers it).
15//!
16//! The descriptor stores each cell's A1 `addr`; the writer converts A1 → the
17//! `rust_xlsxwriter` `(row, col)` coordinate via [`crate::resolve::parse_a1`]
18//! (RESEARCH Pitfall 3 — never re-parse A1).
19
20use serde::{Deserialize, Serialize};
21
22/// The current [`LayoutDescriptor`] schema version (review item 6 — the
23/// descriptor is explicitly versioned + attributable so the writer can refuse a
24/// future incompatible shape).
25pub const LAYOUT_DESCRIPTOR_VERSION: u32 = 1;
26
27/// One captured cell: its A1 address within the owning sheet + the original
28/// formula/value text + the number format + the fill/font ARGBs. Every field is
29/// owned + `Option`-where-absent so the writer replays exactly what the offline
30/// ingest captured (no umya type crosses this boundary).
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
32pub struct CellLayout {
33 /// A1 address within the owning sheet (e.g. `"C11"`). The writer converts this
34 /// to a `(row, col)` coordinate via [`crate::resolve::parse_a1`].
35 pub addr: String,
36 /// The original formula text WITHOUT the leading `=` (`None` when not a
37 /// formula). The writer may replay this as a formula-with-cached-result.
38 pub formula: Option<String>,
39 /// The cell's original computed/literal value as text (`None` when empty).
40 pub value: Option<String>,
41 /// The number-format code (e.g. `"#,##0.00"`), `None` when General/unset.
42 pub number_format: Option<String>,
43 /// The fill (background) ARGB (e.g. `"FFE2EFDA"`), `None` when unset.
44 pub fill_argb: Option<String>,
45 /// The font colour ARGB (e.g. `"FF0000FF"`), `None` when unset.
46 pub font_argb: Option<String>,
47}
48
49/// One captured sheet: its name + visibility + every captured cell + the merges
50/// (A1 ranges) + the per-column widths + the hidden columns.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
52pub struct SheetLayout {
53 /// The sheet name (e.g. `"7_Quote"`).
54 pub name: String,
55 /// `true` iff the sheet is hidden (or very-hidden) in the source workbook.
56 pub hidden: bool,
57 /// Every captured cell on the sheet.
58 pub cells: Vec<CellLayout>,
59 /// Merged-cell ranges as A1 strings (e.g. `"A1:B2"`).
60 pub merges: Vec<String>,
61 /// Per-column widths as `(1-based col index, width)` pairs.
62 pub col_widths: Vec<(u16, f64)>,
63 /// The 1-based column indices flagged hidden.
64 pub hidden_cols: Vec<u16>,
65}
66
67/// The FULL captured workbook layout (D-05) — the bundle's `layout.json` member.
68///
69/// Carries an explicit [`descriptor_version`](LayoutDescriptor::descriptor_version)
70/// (review item 6) and the optional `source_workbook_hash` provenance anchor (the
71/// SAME canonical content projection the `BUNDLE.lock` records), plus every
72/// captured sheet.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
74pub struct LayoutDescriptor {
75 /// The schema version (= [`LAYOUT_DESCRIPTOR_VERSION`] when emitted).
76 pub descriptor_version: u32,
77 /// The canonical source-workbook content hash this layout was captured from
78 /// (`None` when not anchored).
79 pub source_workbook_hash: Option<String>,
80 /// Every captured sheet, in workbook order.
81 pub sheets: Vec<SheetLayout>,
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 fn sample() -> LayoutDescriptor {
89 LayoutDescriptor {
90 descriptor_version: LAYOUT_DESCRIPTOR_VERSION,
91 source_workbook_hash: Some("a".repeat(64)),
92 sheets: vec![SheetLayout {
93 name: "7_Quote".to_string(),
94 hidden: false,
95 cells: vec![
96 CellLayout {
97 addr: "C11".to_string(),
98 formula: Some("SUM(C9:C10)".to_string()),
99 value: Some("1594.93".to_string()),
100 number_format: Some("#,##0.00".to_string()),
101 fill_argb: Some("FFE2EFDA".to_string()),
102 font_argb: None,
103 },
104 CellLayout {
105 addr: "C9".to_string(),
106 formula: None,
107 value: Some("532.66".to_string()),
108 number_format: None,
109 fill_argb: None,
110 font_argb: Some("FF0000FF".to_string()),
111 },
112 ],
113 merges: vec!["A1:B1".to_string()],
114 col_widths: vec![(3, 12.5)],
115 hidden_cols: vec![7],
116 }],
117 }
118 }
119
120 #[test]
121 fn layout_descriptor_round_trips_serialize_deserialize() {
122 // Mirror artifact_model's bundle_lock_hashes_stable / the crate's
123 // ir_round_trip discipline: serialize -> deserialize is an equal value.
124 let d = sample();
125 let json = serde_json::to_string_pretty(&d).expect("serialize");
126 let back: LayoutDescriptor = serde_json::from_str(&json).expect("deserialize");
127 assert_eq!(d, back, "LayoutDescriptor round-trips to an equal value");
128 }
129
130 #[test]
131 fn layout_descriptor_serializes_sheet_name_and_cell_addr_and_format() {
132 // A cell carrying an addr, a formula, and a number_format serializes to
133 // pretty JSON containing the sheet name + "C11".
134 let d = sample();
135 let json = serde_json::to_string_pretty(&d).expect("serialize");
136 assert!(json.contains("7_Quote"), "sheet name present: {json}");
137 assert!(json.contains("C11"), "cell addr present");
138 assert!(json.contains("SUM(C9:C10)"), "formula present");
139 assert!(json.contains("#,##0.00"), "number_format present");
140 }
141
142 #[test]
143 fn layout_descriptor_carries_a_serializing_version_field() {
144 // review item 6: the descriptor_version key serializes.
145 let d = sample();
146 let v = serde_json::to_value(&d).expect("to value");
147 assert_eq!(
148 v["descriptor_version"], LAYOUT_DESCRIPTOR_VERSION,
149 "descriptor_version serializes to the version key"
150 );
151 let json = serde_json::to_string(&d).expect("serialize");
152 assert!(
153 json.contains("descriptor_version"),
154 "the emitted JSON carries the version key"
155 );
156 }
157
158 #[test]
159 fn layout_descriptor_optional_fields_round_trip_when_absent() {
160 // None number_format/fill/font + empty col_widths must round-trip.
161 let d = LayoutDescriptor {
162 descriptor_version: LAYOUT_DESCRIPTOR_VERSION,
163 source_workbook_hash: None,
164 sheets: vec![SheetLayout {
165 name: "1_Inputs".to_string(),
166 hidden: true,
167 cells: vec![CellLayout {
168 addr: "E6".to_string(),
169 formula: None,
170 value: None,
171 number_format: None,
172 fill_argb: None,
173 font_argb: None,
174 }],
175 merges: vec![],
176 col_widths: vec![],
177 hidden_cols: vec![],
178 }],
179 };
180 let json = serde_json::to_string(&d).expect("serialize");
181 let back: LayoutDescriptor = serde_json::from_str(&json).expect("deserialize");
182 assert_eq!(d, back);
183 }
184}