Skip to main content

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}