# `json.rs` Design Doc
*[日本語](json.md)*
Design doc for `src/json.rs`. This implements Phase 5, "JSON generation (return)," as defined by [architecture.md](architecture.en.md). It serializes the fully analyzed and resolved [`model::Workbook`](model/workbook.en.md) into JSON carrying attributes such as `row_span` / `col_span` needed for frontend rendering (requirements chapter 5).
## Responsibility / Scope
- Serializes [`model::Workbook`](model/workbook.en.md) into JSON that includes `row_span`/`col_span` and a value kind tag
- Writes cells one at a time, directly to the serializer, from the iterator [`Sheet::iter_cells`](model/sheet.en.md) returns (which iterates only origin cells), never building an intermediate `Vec` for a whole sheet on the heap (reflects the [PR #10 review](https://github.com/MinamiyamaKotaro/xlsxparser/pull/10#pullrequestreview-4949223332), resolving Open Question 5 — keeps peak memory in check for the "grid-paper Excel"-scale sheets the requirements target)
- Computes `row_span`/`col_span` using [`Sheet::merged_region_at`](model/sheet.en.md), never including a merged cell's virtual coordinates in the JSON output (implements requirements 3.2 and chapter 5)
- Emits, for each [`CellValue`](model/cell.en.md) variant, the JSON value along with a kind tag (`type: "number" | "text" | "boolean" | "error" | "dateTime"`; a valueless cell, or a value that cannot be represented in JSON, falls back to `"empty"`)
- **Not responsible for**: resolving or validating model data itself (`resolve/` — by the time data reaches this file, `Workbook` holds only valid data that has already passed every phase's validation), preparing the `Write` implementation passed to `to_json_writer` itself (opening a file, obtaining an HTTP response body, etc. — the caller's responsibility)
## Key Types / Functions (draft)
```rust
use crate::error::Error;
use crate::model::cell::{Cell, CellRef, CellValue};
use crate::model::sheet::{Sheet, SheetVisibility};
use crate::model::workbook::Workbook;
use serde::ser::{SerializeSeq, SerializeStruct};
use serde::{Serialize, Serializer};
use std::io::Write;
/// Streams `workbook` out as JSON to `writer`. Each element of the `cells`
/// array is converted from [`Sheet::iter_cells`](model/sheet.en.md) and
/// written out one at a time, without buffering a whole sheet's
/// `Vec<JsonCell>` in memory. If `writer` is, say, a `BufWriter<File>`,
/// additional memory usage stays at O(1) — one cell's worth at a time.
pub fn to_json_writer<W: Write>(workbook: &Workbook, writer: W) -> Result<(), Error> {
let json_workbook = JsonWorkbook { workbook };
serde_json::to_writer(writer, &json_workbook)
.map_err(|source| Error::JsonSerialize { source: Box::new(source) })
}
/// A convenience version of `to_json_writer` that targets an in-memory
/// `Vec<u8>`. Since the entire output must be held as one `String`,
/// additional memory usage is O(n) in the output size (unlike
/// `to_json_writer`'s O(1) — prefer `to_json_writer` whenever the caller
/// can write directly to a file, HTTP response, etc.).
pub fn to_json_string(workbook: &Workbook) -> Result<String, Error> {
let mut buf = Vec::new();
to_json_writer(workbook, &mut buf)?;
// serde_json is guaranteed to always emit valid UTF-8, so this
// conversion cannot fail in practice, but per the library-wide policy
// of never using `unwrap`/`expect` internally (error.md Error Handling
// Policy), it is still handled as a `Result`.
String::from_utf8(buf).map_err(|source| Error::JsonSerialize { source: Box::new(source) })
}
/// A borrowing wrapper over `model::Workbook`. Owns no value; its
/// `Serialize` impl walks the model on demand, achieving streaming (not
/// exposed outside this file).
struct JsonWorkbook<'a> {
workbook: &'a Workbook,
}
impl<'a> Serialize for JsonWorkbook<'a> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut state = serializer.serialize_struct("Workbook", 1)?;
state.serialize_field("sheets", &SheetSeq { workbook: self.workbook })?;
state.end()
}
}
struct SheetSeq<'a> {
workbook: &'a Workbook,
}
impl<'a> Serialize for SheetSeq<'a> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let sheets = self.workbook.sheets();
let mut seq = serializer.serialize_seq(Some(sheets.len()))?;
for sheet in sheets {
seq.serialize_element(&JsonSheet { sheet })?;
}
seq.end()
}
}
struct JsonSheet<'a> {
sheet: &'a Sheet,
}
impl<'a> Serialize for JsonSheet<'a> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut state = serializer.serialize_struct("Sheet", 5)?;
state.serialize_field("name", &self.sheet.name)?;
state.serialize_field("visibility", visibility_tag(self.sheet.visibility))?;
state.serialize_field("maxRow", &self.sheet.max_row)?;
state.serialize_field("maxCol", &self.sheet.max_col)?;
state.serialize_field("cells", &CellSeq { sheet: self.sheet })?;
state.end()
}
}
/// Converts each cell from [`Sheet::iter_cells`](model/sheet.en.md) into a
/// `JsonCell` and writes it straight to the serializer, one at a time,
/// without ever building an intermediate `Vec<JsonCell>` (the core of the
/// design that resolves Open Question 5).
struct CellSeq<'a> {
sheet: &'a Sheet,
}
impl<'a> Serialize for CellSeq<'a> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// Sheet::iter_cells is exposed only as `impl Iterator` and makes no
// size-hint guarantee, so `None` is used here (whether to commit
// `ExactSizeIterator` as part of the public API is a design
// decision left to model/sheet.md — see Open Question 5).
let mut seq = serializer.serialize_seq(None)?;
for (cell_ref, cell) in self.sheet.iter_cells() {
seq.serialize_element(&cell_to_json(self.sheet, cell_ref, cell))?;
}
seq.end()
}
}
/// The conversion result for a single cell. `CellSeq::serialize` produces
/// one of these per stream element, short-lived (not exposed to callers).
#[derive(Debug, Serialize)]
struct JsonCell {
row: u32,
col: u32,
value: JsonCellValue,
/// Omitted entirely when 1 (not merged).
#[serde(rename = "rowSpan", skip_serializing_if = "is_one")]
row_span: u32,
#[serde(rename = "colSpan", skip_serializing_if = "is_one")]
col_span: u32,
// Style output (font, fill, etc.) is blocked on ResolvedStyle gaining
// those fields — see Open Question 4.
}
fn is_one(n: &u32) -> bool {
*n == 1
}
/// A kind-tagged value representation. `#[serde(tag = "type", content =
/// "value")]` serializes as `{"type": "number", "value": 42.0}`.
#[derive(Debug, Serialize)]
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
enum JsonCellValue {
Number(f64),
/// The concrete string representation (ISO 8601, etc.) is to be decided
/// once `model::DateTimeValue`'s type is settled — see Open Question 3.
DateTime(String),
Text(std::sync::Arc<str>),
Boolean(bool),
Error(String),
/// A cell with no value (formatting only), or the fallback destination
/// for a value JSON cannot represent (non-finite floats — see below).
Empty,
}
fn cell_to_json(sheet: &Sheet, cell_ref: CellRef, cell: &Cell) -> JsonCell {
let (row_span, col_span) = sheet
.merged_region_at(cell_ref)
.map(|r| (r.row_span(), r.col_span()))
.unwrap_or((1, 1));
JsonCell {
row: cell_ref.row,
col: cell_ref.col,
value: cell_value_to_json(cell.value.as_ref()),
row_span,
col_span,
}
}
fn cell_value_to_json(value: Option<&CellValue>) -> JsonCellValue {
match value {
None => JsonCellValue::Empty,
Some(CellValue::Number(n)) if n.is_finite() => JsonCellValue::Number(*n),
// Silently substituting 0.0 for NaN/Infinity would make it
// indistinguishable, downstream, from a value that legitimately
// evaluated to zero, risking incorrect aggregation results (given
// requirements chapter 1's accounting/business-system use case;
// reflects the PR #10 review, resolving Open Question 2). Falling
// back to Empty (JSON `null`) instead lets the frontend safely
// treat it as "no value present."
Some(CellValue::Number(_)) => JsonCellValue::Empty,
Some(CellValue::DateTime(dt)) => JsonCellValue::DateTime(format_date_time(dt)),
Some(CellValue::Text(s)) => JsonCellValue::Text(s.clone()),
Some(CellValue::Boolean(b)) => JsonCellValue::Boolean(*b),
Some(CellValue::Error(e)) => JsonCellValue::Error(e.clone()),
}
}
/// Converts a `model::DateTimeValue` (its type still undecided — see
/// [model/cell.md Open Question 4](model/cell.en.md)) into its string
/// representation. The concrete format is to be decided together with that
/// type.
fn format_date_time(dt: &crate::model::cell::DateTimeValue) -> String {
let _ = dt;
unimplemented!()
}
fn visibility_tag(v: SheetVisibility) -> &'static str {
match v {
SheetVisibility::Visible => "visible",
SheetVisibility::Hidden => "hidden",
SheetVisibility::VeryHidden => "veryHidden",
}
}
```
## Dependencies
- Depends on: [`model/workbook.rs`](model/workbook.en.md) (`Workbook`), [`model/sheet.rs`](model/sheet.en.md) (`Sheet::iter_cells`, `Sheet::merged_region_at`, `SheetVisibility`), [`model/cell.rs`](model/cell.en.md) (`Cell`, `CellRef`, `CellValue`, `DateTimeValue`), [`error.rs`](error.en.md) (`Error::JsonSerialize` — newly added to represent I/O or serialization failure during streaming writes; added as part of the redesign following the [PR #10 review](https://github.com/MinamiyamaKotaro/xlsxparser/pull/10#pullrequestreview-4949223332)), the external `serde` crate (manual and derived `Serialize` impls) and `serde_json` (streaming serialization via `to_writer`). `serde` needs its `rc` feature enabled: `CellValue::Text`'s `Arc<str>` field only gets a `Serialize` impl with that feature on (found at implementation time — without it, `Arc<str>` doesn't implement `Serialize` at all, since serde gates `Rc`/`Arc` support behind `rc` to avoid the footgun of silently duplicating shared data across independent serializations).
- Depended on by: `lib.rs` (calls it explicitly on a `Workbook` — see [pipeline.md Open Question 1](pipeline.en.md); `pipeline.rs`'s `run` itself never calls it)
`JsonWorkbook` / `SheetSeq` / `JsonSheet` / `CellSeq` each hold only a borrow of the model (`&'a Workbook` / `&'a Sheet`), never owning a value. Their `Serialize` impls only walk the model once actually invoked, which naturally lines up with the sequential calls `serde_json::to_writer` makes internally — no intermediate data structure representing a whole sheet or the whole book is ever built on the heap.
`model::Workbook` / `Sheet` / `Cell` are deliberately not annotated with `#[derive(Serialize)]` directly; instead this file converts them into its own borrowing wrapper types before serializing. The reasoning mirrors [error.md](error.en.md)'s design decision to type-erase `Error::XmlParse::source` so `quick-xml` never becomes a public dependency: pulling a `serde` dependency into `model/` would let `serde`'s breaking changes ripple into `model/`'s own type definitions. Interposing this wrapper layer keeps `model/` as architecture.md's policy intends — "a pure data structure with no dependency on XML parsing or resolution logic" — and lets the JSON output's concrete field names and shape evolve independently of `model/`'s type definitions.
## Error Handling Policy
- `to_json_writer` / `to_json_string` return `Result<_, Error>`. `serde_json::to_writer` is specified to return `Err` when it encounters a non-finite float (`NaN`/`Infinity`), but this file always converts a non-finite `f64` into `JsonCellValue::Empty` inside `cell_value_to_json` before it ever reaches `serde_json`, so in practice no error arises from that path. `Result` is still returned regardless, for two reasons: (1) if `writer` is a `File`, network socket, or other I/O-backed implementation, the write itself can genuinely fail; (2) to uphold the existing policy of never using `unwrap`/`expect` internally ([error.md Error Handling Policy](error.en.md))
- `serde_json::Error` is never placed directly as a concrete type on an `Error` field; it is wrapped, type-erased as `Box<dyn std::error::Error + Send + Sync + 'static>`, in the newly added `Error::JsonSerialize` variant — the same reasoning as `error.md`'s `XmlParse::source`, keeping `serde_json` out of the public dependency surface
- The one value JSON cannot represent — `f64`'s `NaN`/`Infinity` (which `CellValue::Number` can in theory hold) — does not cause the whole document's serialization to be abandoned via `Err`; instead, only that cell falls back to `JsonCellValue::Empty` (equivalent to JSON `null`), and processing continues. It is not silently substituted with a valid number like `0.0`, because in the accounting/business-system use case requirements chapter 1 targets, that would make "a failed or undefined computation" indistinguishable from "a value that legitimately evaluated to zero" for any downstream aggregation (reflects the [PR #10 review](https://github.com/MinamiyamaKotaro/xlsxparser/pull/10#pullrequestreview-4949223332))
## Testing Strategy
- Verify that a `Workbook` with a single sheet and a single (numeric) cell converts to the expected JSON string via `to_json_string`
- Verify that for a sheet with a merged cell, the origin cell's `rowSpan`/`colSpan` are computed correctly and the virtual cell coordinates are never included in the `cells` array (wiring to `Sheet::iter_cells`'s origin-only design)
- Verify that for an unmerged, ordinary cell, the `rowSpan`/`colSpan` fields are omitted from the serialized output (`skip_serializing_if`)
- Verify that each `CellValue` variant (`Number`/`Text`/`Boolean`/`Error`) serializes correctly with the corresponding `type` tag and `value`
- Verify that `value: None` (a formatting-only cell) serializes as `type: "empty"`
- **Verify that a cell holding `CellValue::Number(f64::NAN)` / `CellValue::Number(f64::INFINITY)` never returns `Err` and instead outputs `type: "empty"`** (a regression test for the fallback specification added by the PR #10 review — explicitly distinguishing it from the previous `0.0` fallback)
- Verify that for a `Workbook` containing `Hidden`/`VeryHidden` sheets, every sheet appears in the output with its `visibility` field (wiring to [model/workbook.md](model/workbook.en.md)'s "include every sheet regardless of visibility" policy)
- Verify that a `Workbook` with zero sheets serializes correctly as `{"sheets": []}`
- **A regression test verifying that calling `to_json_writer` on a sheet with many cells does not cause additional heap allocation to grow significantly beyond `Sheet`'s own memory footprint (i.e. `JsonCell`s are never collected into a `Vec`)** (a test substantiating the peak-memory design intent raised by the PR #10 review; the concrete verification method is to be settled at implementation time, together with the choice of memory-profiling tooling)
- Verify that `Error::JsonSerialize` propagates when `to_json_writer` is given a `Write` implementation (a test mock) that fails partway through writing
## Open Questions
1. ~~Whether to tag value kinds in the JSON structure~~ → **Resolved**: keep the tagged representation, `{"type": "number", "value": 42}` (reflects the [PR #10 review](https://github.com/MinamiyamaKotaro/xlsxparser/pull/10#pullrequestreview-4949223332)). Dropping the tag in favor of native JSON types alone would leave the frontend unable to distinguish `dateTime` from a plain string (`Text`), forcing string parsing wherever a date picker or formatting needs to apply; it would also remove the ability to distinguish `error` (a formula error value) from an ordinary string for grid warning styling; and it would prevent a type-safe TypeScript client built on a Discriminated Union keyed by `type`.
2. ~~Fallback value for non-finite floating-point numbers (`NaN`/`Infinity`)~~ → **Resolved**: falls back to `JsonCellValue::Empty` (equivalent to `null`) rather than `0.0` (reflects the [PR #10 review](https://github.com/MinamiyamaKotaro/xlsxparser/pull/10#pullrequestreview-4949223332)). See Error Handling Policy for details.
3. **`DateTime`'s string representation format**: undecided, tied to [model/cell.md Open Question 4](model/cell.en.md)'s finalization of the `DateTimeValue` type. ISO 8601 (e.g. `"2024-01-01T00:00:00"`) is the leading candidate, but how to handle date-only or time-only cells (Excel does not distinguish date/time precision as a type) needs consideration. **Interim decision at implementation time**: since `DateTimeValue` is still the data-less placeholder unit struct from [model/cell.md](model/cell.en.md), `format_date_time` (in the draft above) could never produce a real value, and leaving it as `unimplemented!()` would make `to_json_writer` panic on any resolved date/time cell — unacceptable given the crate-wide "never panic on data reachable from untrusted input" policy, since `resolve/style.rs`'s `serial_to_date_time` already returns a real (if empty) `DateTimeValue` for in-range numeric cells today. `format_date_time` was therefore dropped, and `cell_value_to_json`'s `CellValue::DateTime(_)` arm falls back to `JsonCellValue::Empty` — the same treatment as an unrepresentable `Number` — rather than fabricating a string not derived from any actual data. The `JsonCellValue::DateTime(String)` variant itself is kept (unconstructed by the conversion path, but its serialized shape is pinned down by a dedicated test, `date_time_value_currently_serializes_as_empty`) so the wiring is ready the moment `DateTimeValue` gains real fields.
4. **JSON output of style information**: same topic as [model/style.md Open Question 1](model/style.en.md). Style output fields cannot be added to `JsonCell` until `ResolvedStyle` gains concrete font/fill/border fields.
5. ~~Peak memory from batch construction~~ → **Resolved**: switched to a streaming design that never pre-builds a `Vec<JsonCell>` — the iterator from `Sheet::iter_cells` is fed directly into `serde::ser::SerializeSeq` inside `CellSeq::serialize` (reflects the [PR #10 review](https://github.com/MinamiyamaKotaro/xlsxparser/pull/10#pullrequestreview-4949223332)). Note that `to_json_string` (the convenience version backed internally by a `Vec<u8>` buffer) still requires O(n) memory proportional to the output size; a caller that truly wants O(1) additional memory needs to pass `to_json_writer` a real I/O destination such as a `BufWriter<File>`. Also, since `Sheet::iter_cells` makes no `ExactSizeIterator` guarantee, `serialize_seq`'s element-count hint is passed as `None` (this doesn't affect the JSON output's correctness, but forgoes a minor optimization opportunity some serializer implementations could otherwise take) — whether [model/sheet.md](model/sheet.en.md) should commit to `ExactSizeIterator` as part of its public API remains an open consideration there.
6. **Relationship between `to_json_writer`/`to_json_string` and `lib.rs`'s public API**: how `lib.rs` exposes these functions separately from `parse_workbook` (which returns `Workbook`) is tied to [pipeline.md Open Question 1](pipeline.en.md) and is to be settled when `lib.rs` is designed.