sheets-diff 2.4.0

Structured diff engine for Microsoft Excel .xlsx workbooks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
# Changelog

## [2.4.0] - 2026-08-17

**Truth-telling release.** Every change here closes a gap between what this
crate said about itself and what it did. The CLI gained the exit code RFC-013
specified from the start and never emitted; `DiffMetrics.cells_compared` began
counting what its name and its 2.2.3 changelog entry both claimed;
`max_cells_compared` began bounding the resource it names rather than the one
`max_diffs_returned` already bounds; and the threat model stopped promising a
protection that limit was not providing. Source comments describing types that
were never built, and version anchors naming releases that had passed, are gone.

**Two compatibility events, both in `### Changed`** — the CLI exit-code change
and `max_cells_compared`'s enforcement. Neither is a library API break; both
can change what a consumer observes. Read that section before upgrading.

The comparison engine is untouched: no cell, sheet, alignment or diagnostic
result differs from 2.3.0. `DiffMetrics.cells_compared` does differ — it was
wrong — which moved 13 fixture goldens in that field alone.

### Added

- **The fixture corpus grew from 7 to 18 scenarios**, closing every gap
  ranked 1–5 by consequence in RFC-030 Handoff 01's coverage-dimension
  report (RFC-036). Each new scenario carries a dedicated assertion, not
  only a golden — RFC-036 §5.1 defines "covered" as an assertion that would
  fail if the behaviour broke, precisely because a golden alone cannot
  detect having been *born* wrong, which is what happened to the `formula`
  fixture for over a year. New coverage: row/formula origins shifted below
  row 1 (plus the D-04 negative control where the origins coincide);
  `AlignmentMode::RowSignature` and `HeaderColumn`, previously exercised by
  no test at any level; `CellError` comparison and
  `ValueDifferenceKind::ErrorKindChanged`, also previously untested at any
  level; `SheetChange::Moved`, never before distinguished from `Unchanged`
  by any assertion; ordinary serial-based dates in the golden corpus for
  the first time, despite dates being where four M2 defects lived; non-ASCII
  sheet names and cell text; a chart sheet beside a worksheet; a
  physically-present-but-empty leading cell (confirmed not to anchor the
  range origin, matching calamine's read source); and the ISO-datetime
  reachability case promoted from a hand-built test into a durable corpus
  trip-wire. Full matrix and the standing coverage obligation this creates
  for future changes to `normalize.rs`/`compare.rs`/`align.rs`/`diff.rs`:
  [`tests/fixtures/corpus/README.md`]tests/fixtures/corpus/README.md.
- `examples/gen-fixtures.rs` gained a `patch_xlsx_xml` helper (duplicated
  from `tests/support.rs`, consistent with the generator's existing
  independence from anything under `tests/`) for the two new scenarios
  `rust_xlsxwriter`'s public API cannot produce directly.

No comparison behaviour changed. This is test-corpus and test-infrastructure
work only; `src/` is untouched.

### Changed

- **CLI contract change: exit code 3 for invalid/corrupt input (M4).** RFC-013
  specified this from the start; it was never emitted. `src/main.rs` collapsed
  every non-option, non-comparison-result failure to exit code 2, including
  corrupt input — a caller could not distinguish "this file is not a workbook"
  from "you passed a bad flag." Now: **3** when something about the bytes at
  the given path make them unusable as a workbook (wrong format, corrupt
  internals, or encrypted); **2** narrows to everything else — reaching those
  bytes in the first place (missing file, permissions, a lock held elsewhere),
  caller misconfiguration, a resource limit, or an internal bug. Full mapping
  and reasoning: [`docs/src/migration/v1-to-v2.md`]docs/src/migration/v1-to-v2.md#cli-exit-codes,
  also printed by `sheets-diff --help`.

  **This is a behaviour change to the CLI contract, not a bugfix footnote.**
  A script matching exit code `2` for "something went wrong with the file"
  will now see `3` for the corrupt-input subset of that. It is correct per
  RFC-013 and it is still a compatibility event — this is why the M4 release
  is 2.4.0 rather than 2.3.1. The library is unchanged; only `src/main.rs`
  moved. Covered by five new subprocess tests
  (`tests/cli.rs`) exercising the real binary — no exit code had ever been
  verified by anything before this.

- **Compatibility event: `max_cells_compared` now bounds what it names, and
  a comparison that succeeded before can start returning `LimitExceeded`
  (M4).** The check inside `build_sheet_diff` compared `cell_diffs.len()`
  against the limit — a count that only grows when a coordinate produces a
  diff, so it measured diffs found, not coordinates visited. A workbook with
  millions of populated cells and few differences passed straight through
  regardless of the configured bound; the limit could not do what
  `options.rs`'s own doc comment says the linear limits exist for ("their
  cost scales predictably with input size"). It now counts coordinates
  compared, cumulatively across the whole comparison (matching
  `max_diffs_returned`'s existing cumulative check), before each sheet's
  comparison work begins rather than partway through it.

  **`Limits::hardened()` sets `max_cells_compared: Some(5_000_000)`. Under
  the old enforcement that bounded diffs; under the new one it bounds
  coordinates.** A caller using `hardened()` (or setting this limit
  explicitly) to compare a large workbook with few differences, which
  succeeded in 2.3.0, can now return `LimitExceeded` in 2.4.0 — this is the
  limit finally doing what it was always documented to do, and it is still
  a behaviour change a consumer can be surprised by. `Limits::default()`
  leaves this limit unset, so default-configured callers are unaffected.
  `LimitExceeded { observed, .. }` now reports the coordinate count, not a
  diff count, for this limit.

### Fixed

- **`DiffMetrics.cells_compared` now counts what it claims to (M4).** It
  previously equalled `cells_changed` exactly, always — a dead `filter(...)`
  term in its accumulation formula could never contribute, so every
  compared-but-unchanged coordinate went uncounted. It is now accumulated
  where the comparison actually happens (once per coordinate in the
  aligned/positional coordinate set built in `build_sheet_diff`, regardless
  of whether that coordinate produces a diff), so it is always
  `>= diffs_emitted` rather than always equal to it. **2.2.3's changelog
  entry claiming this was already fixed is true as of this release; it was
  not true when written** (flagged wrong in M2 unit 06's audit; this closes
  that annotation). `cells_read` and `diffs_emitted` were checked and are
  unaffected — both were already counting correctly.

  This changes `DiffMetrics.cells_compared`'s value for any comparison with
  at least one compared-but-unchanged cell — which is the normal case. The
  fixture corpus moved accordingly: 13 goldens changed, each in exactly the
  `cells_compared` field and nothing else.

- **Defensive: an I/O failure while reading a sheet would have reported as a
  corrupt workbook (M4).** **No released version could reach this path, and
  no user was affected** — the workbook reader is `Xlsx<Cursor<Vec<u8>>>`
  (input is fully drained before any parsing begins), so sheet reads touch no
  I/O and `XlsxError::Io` cannot arise at that stage. The fix is forward-
  looking, recorded because the misclassification was real and would have
  become user-visible the moment that stopped being true.

  `classify_read_error`'s catch-all routed every `calamine::XlsxError` other
  than `WorksheetNotFound` to `ReadErrorKind::MalformedSheet`, including
  `XlsxError::Io` — an I/O failure part-way through a read has nothing to do
  with the workbook's own content, and combined with the CLI's exit-code-3
  change (above) it would have told a user their file was corrupt when
  nothing was wrong with it. `XlsxError::Io` now classifies as
  `ReadErrorKind::Other`; `exit_code_for` maps `ReadSheet`'s sub-kinds
  individually rather than wholesale, with `Other` conservatively exiting 2
  rather than 3 — the same default already applied to `OpenErrorKind::Other`.
  **This changes which `ReadErrorKind` variant a given input can produce**
  (a `#[non_exhaustive]` public enum) — existing `match` arms compile
  unchanged since they must already have a catch-all, but the value observed
  for this input class changes.

### Documentation

- **Two false statements removed from source comments (M4).**
  `meta.rs`'s `compare_workbook_metadata` claimed metadata comparison could
  be disabled via a `Ignore` mode and had a `CompareAvailable` default;
  neither exists — `WorkbookMetadataMode` (RFC-021) was never built, the
  function's `_opts` parameter is unused, and metadata comparison always
  runs unconditionally. Comments now say so.
- **Three `CellValue` variants documented as unreachable.** `Integer`,
  `Duration` and `Unsupported` cannot be produced through any `.xlsx` input
  this crate accepts — six of the nine variants are live in practice, and
  nothing in the public documentation previously said so. Each now carries
  a doc comment stating the fact and its cause (RFC-007).
- **Nine stale version anchors dropped from comments and one public error
  message.** `model.rs` and `options.rs` stated several still-true facts as
  if scoped to a past minor version (e.g. "Always empty in v2.0",
  "not available in v2.0") — accurate today, but implying a change that
  never happened by the time a reader reaches them in 2.3.0. Reworded to
  state the fact without the version, including the wording of
  `SheetsDiffError::InvalidOptions`'s message for `FormulaCompareMode`
  variants without an implemented normaliser.

No behaviour changed; these are documentation-only corrections.

## [2.3.0] - 2026-08-16

**Security and integrity release.** Clears two denial-of-service advisories
reachable from untrusted workbook input, bounds the first-party paths that could
exhaust memory or abort the host process, and fixes four defects where the
engine reported "identical" for cells that differ. A threat model now records
what is defended, what is not, and where each control is checked:
[`docs/src/maintainers/threat-model.md`](docs/src/maintainers/threat-model.md).

### Security

- **MSRV raised from 1.85.0 to 1.88.0; `calamine` upgraded from 0.35 to
  0.36.** This is a real compatibility event for consumers on older
  toolchains, called out here rather than as a footnote. The driver is
  security: `calamine` 0.35 pulled in `quick-xml` 0.39.4, which carries
  `RUSTSEC-2026-0194` (quadratic runtime on duplicate-attribute checking) and
  `RUSTSEC-2026-0195` (unbounded namespace-declaration allocation), both
  denial-of-service on XML input and both fixed in `quick-xml` >= 0.41.
  `calamine` 0.36 resolves `quick-xml` to 0.41.0 and `zip` to 8.6.0; neither
  advisory is reachable from the dependency tree after this change.
  Consumers that read `.xlsx` files they did not author — this crate's
  documented threat model — were exposed to both advisories through this
  path. Verified with `cargo audit` before and after: 0.35 shows 3
  vulnerabilities (the two above plus one unrelated, dev-only advisory in
  `crossbeam-epoch` via `criterion`), 0.36 shows only the unrelated one.
  `calamine`'s public API used by this crate (`Data`, `CellErrorType`,
  `XlsxError`, `SheetType`, `SheetVisible`, the `Reader` trait) is
  byte-identical between versions, and the full fixture corpus — all seven
  `expected.json` goldens — is unchanged, confirming the migration alters no
  comparison behaviour.

- **`#![forbid(unsafe_code)]` crate-wide.** The crate's one `unsafe` block
  (`address::col_to_label`'s `String::from_utf8_unchecked`) is replaced with
  the safe `String::from_utf8().expect(..)` — the bytes pushed are always
  ASCII uppercase, so the conversion cannot fail, and nothing is given up by
  going through the safe path.

### Added

- **Resource bounds on superlinear and input-size paths** (RFC-035). Two new
  `Limits` fields, both `Some` by default:
  - `max_alignment_product` (default 25,000,000, empirically measured — see
    RFC-035 §9) bounds the `old_rows × new_rows` row-alignment LCS matrix.
    When exceeded, the affected sheet degrades to positional comparison and
    emits an `alignment_bound_exceeded` diagnostic — it never errors.
  - `max_input_bytes` (default 500 MiB) bounds the input size, checked
    *before* any read begins (`fs::metadata` before `fs::read`, a `Seek` to
    measure length before `read_to_end`, or a length check before the
    internal `to_vec()`). Exceeding it is a hard `LimitExceeded` error, since
    unbounded allocation here happens before any comparison logic can
    observe or report it.

  `Limits::hardened()` now also sets both of the above, plus a preset for
  every other `Limits` dimension, for callers comparing untrusted input.
  New `DiffOptionsBuilder` methods: `max_alignment_product`,
  `max_input_bytes`, `limits`. New diagnostic codes:
  `alignment_bound_exceeded`, `duplicate_alignment_key`. New
  `LimitKind::InputBytes`.

- **`CellDateTime::has_serial: bool`** (D-01, see Fixed below) — distinguishes
  a genuine Excel date serial from the placeholder used when only an ISO
  string is available.

### Changed

- **Comparison output changes for four correctness fixes (D-01 through
  D-04, above).** These are patch-level in the sense that no public type
  signature changed beyond one additive field, but in substance they change
  what a comparison reports: cells the previous release silently reported as
  *identical* — ISO-typed dates/durations with different values, and rows
  affected by the alignment coordinate collision — will now correctly be
  reported as *different*, and a formula previously attached to the wrong
  cell will now attach to the right one. If you persist or diff against
  stored `WorkbookDiff` output from a prior release, expect these cases (if
  present in your data) to change. This is the fix, not a regression — the
  previous behaviour was silent data loss in a diff/merge context.
- **`DiffOptions::default()` now bounds alignment and input size.** Previously
  every `Limits` field defaulted to `None` (unbounded). The two new fields
  above default to `Some` (see Added), so a caller relying on
  `DiffOptions::default()` who compares a workbook pair whose row-alignment
  product or input size exceeds the new defaults will now see the alignment
  degrade to positional (no error) or the input rejected with
  `LimitExceeded` (a new error), where previously it ran unbounded. Opt back
  out with `Limits { max_alignment_product: None, max_input_bytes: None,
  ..Limits::default() }`.

### Fixed

- **ISO-typed date/time and duration values always compared equal (D-01).**
  `Data::DateTimeIso`/`Data::DurationIso` cells (calamine's `t="d"` path) had
  no genuine Excel serial — `serial` was hardcoded `0.0`, `is_1904` hardcoded
  `false` — so **any two ISO-typed values of the same kind compared equal
  regardless of their actual dates**: `2024-01-01T00:00:00` and
  `2099-12-31T23:59:59` were reported identical, as were `PT1H` and `PT99H`.
  In a diff/merge workflow this is a silent data-loss path: a real change is
  shown as "no change." `CellDateTime` gains a `has_serial: bool` field
  distinguishing a genuine serial from the `0.0` placeholder (a legitimate
  date can itself serialise to `0.0`, so the placeholder needed its own
  signal); comparison now uses `iso` when `has_serial` is `false` on both
  sides, and a value with a serial is never silently treated as equal to an
  ISO-only value with no serial. `CellValue::Duration` (always ISO-only in
  practice — see below) now compares via `iso` when present.
- **`is_1904` was hardcoded `false`, so `DateComparePolicy::NormalizeEquivalentDateTimes`
  was dead code (D-02).** The 1900/1904 epoch flag is workbook-level
  (`Xlsx::has_1904_epoch()`), not per-cell; it is now read once when a
  workbook is opened (`OpenedWorkbook::is_1904`) and threaded into every
  cell's `CellDateTime`. A caller who selected
  `NormalizeEquivalentDateTimes` previously got silence, never an error —
  the policy could never actually reconcile two dates across epochs because
  both were always flagged 1900. It now works.
- **Row alignment could silently merge two unrelated cells into one
  coordinate (D-03).** When a row-alignment mode was active, matched and
  removed rows were numbered in the *old* sheet's row space while inserted
  rows were numbered in the *new* sheet's — but both were inserted into the
  same `(row, col)` coordinate set. Whenever an inserted row's new-side
  number numerically coincided with an unrelated matched or removed old-side
  row number (common on any sheet with more than a handful of rows), the set
  silently deduplicated two distinct logical cells into one, and the lookup
  that followed could then compare the wrong pair of cells, or drop the
  inserted row's content entirely. Only reachable under a non-`Positional`
  alignment mode, which is why the fixture corpus never caught it. The
  internal coordinate key now carries which row-numbering space it came
  from, so a numeric coincidence can never merge two different cells.
- **Formula text could attach to the wrong cell (D-04).** `calamine`'s
  formula range and value range are independent `Range`s with their own
  origins — `worksheet_formula`'s range is built only from cells that
  actually carry formula text, so its top-left corner is the first *formula*
  cell, not the first populated cell. The formula lookup applied
  value-range-relative row/column indices directly to the formula range
  (`Range::get`, which is relative to *that* range's own origin), silently
  offsetting or dropping formula text whenever the two origins differed —
  for example, a text label in the first populated row with a formula
  starting further down. Now translates through absolute coordinates
  (`Range::get_value`), which is correct regardless of whether the two
  ranges' origins coincide.
- **Alignment duplicate-key diagnostic was misclassified.** `align.rs`
  reported duplicate row-alignment keys using `DiagnosticKind::UnsupportedCellValue`
  (documented meaning: "a cell value could not be normalised" — not what
  happened) with a message claiming a partial positional fallback that never
  actually occurred (LCS still ran on the full, duplicate-containing
  sequences). Replaced with `DiagnosticKind::DuplicateAlignmentKey` and a
  message that describes what actually happens.
- **Alignment's row-count guard was wired to the wrong limit.** The LCS
  matrix's row-count guard read `Limits::max_cells_compared` — a *cell*-count
  bound — as a *row* bound, and on tripping it silently built a fake
  low-confidence identity mapping with no diagnostic at all. It now reads
  the dedicated `max_alignment_product` bound (see Added, above), checked
  before any mode-specific alignment work, and degrades to the caller's
  existing true-positional path with an explicit diagnostic.
- **`src/objects.rs`'s coverage diagnostic corrected — the 2.2.3
  `cells_compared` claim documented as still wrong, not fixed.** Two
  unrelated corrections, both about claims this project made about itself:
  - The `UnsupportedWorkbookFeature` coverage message (emitted on every
    comparison) said "calamine 0.35 does not expose object content" and
    listed hyperlinks, tables, and pivot tables alongside charts and images
    as uniformly unavailable. Both are now wrong: the version is stale, and
    RFC-035 Handoff 01's spike established that calamine 0.36 *does* expose
    hyperlinks, merged regions, tables, and pivot tables — this crate simply
    does not call those APIs yet. The message now distinguishes "not
    exposed by calamine's API at all" (charts, images, comments, data
    validation, conditional formatting) from "available upstream, not yet
    used by this crate" (hyperlinks, merged regions, tables, pivot tables).
    `DiagnosticKind::code()` is unchanged (`unsupported_workbook_feature`)
    — only the human-readable message moved, which is why this changed all
    seven fixture goldens as a pure string substitution; see the corpus
    guide for what that first-bless lesson was about.
  - The 2.2.3 entry below claims `DiffMetrics.cells_compared` was fixed to
    count all coordinate pairs visited, not just changed cells. Verified at
    `0ba6aeb`: it does not, and never did — `build_sheet_diff` only ever
    pushes a `CellDiff` for a coordinate with an actual value or formula
    change, so the "compared but unchanged" term the accumulator adds is
    always zero. `cells_compared == cells_changed`, silently, since 2.2.3.
    Not fixed here — see the annotated entry below for why — but the claim
    is no longer left standing as true.

### Removed

- **The `parallel` feature is removed** (RFC-025, roadmap decision D2). It never
  compiled: `src/diff.rs` referenced `ExecutionMode::Parallel`, which
  `src/options.rs` never defined, so `cargo build --features parallel` has
  failed since 2.2.0. The design remains sound and RFC-025 stays `accepted/`,
  amended with the corrected rationale and a re-introduction gate. See the
  2.2.0 entry below, which is annotated rather than deleted.

## [2.2.3] - 2026-06-11

### Fixed (audit)

- **Dead code removed:**
  - `OpenedWorkbook::sheet_names()` was never called; removed.
  - `AlignmentModeLabel` enum and `AlignmentSummaryData.mode` field were
    written but never read; removed.
  - `make_renamed_workbook` in `benches/workbook_diff.rs` was unused; removed.
  - Crate-level `#![allow(dead_code)]` removed — no longer needed.
- **Metrics corrected:** `DiffMetrics.cells_read` now reflects the actual cell
  count from `read_sheet_cells` (was `1` per sheet). `DiffMetrics.cells_compared`
  now counts all coordinate pairs visited, not just changed cells.
  **Correction (see Unreleased):** the second half of this entry is wrong. It
  was wrong when written and is still wrong today — `cells_compared` counts
  only changed cells, exactly as before this entry claims to have fixed.
- **`compare` module made `pub(crate)`** — it is internal machinery. The
  `compare_values_pub` test helper is now `#[cfg(test)]` only.
- **Stale doc comments updated:** `WorkbookChange` / `WorkbookObjectChange` /
  `WorkbookDiff` comments no longer reference "v2.0" or "always empty in v2.0";
  they correctly describe the v2.2 state (RFC-021/023 surface through
  `diagnostics`; structured variants reserved for future).
  **Correction (see Unreleased, M4):** this entry is also wrong. `WorkbookChange`'s
  and `WorkbookObjectChange`'s doc comments still read "Always empty in v2.0"
  immediately before M4 unit 01 removed them — the same defect as the
  `cells_compared` entry two bullets above, in the same audit section that
  first named the problem, uncaught until now.
- **`criterion::black_box` deprecation** resolved — switched to
  `std::hint::black_box` throughout `benches/workbook_diff.rs`.

### Added (audit)

- `#[non_exhaustive]` added to all 26 public model types that were missing it
  (RFC-031 compliance).
- `CellDisplay::new()` and `CellSnapshot::new()` constructors — necessary
  because `#[non_exhaustive]` blocks struct literal construction outside the
  crate.
- `DiffOptionsBuilder::number_compare_policy()` builder method.
- Integration tests for `compare_readers` / `compare_readers_with_options`
  (RFC-004, previously untested) and `TypeMismatchPolicy::CompareDisplayString`
  (RFC-010, previously untested).

## [2.2.2] - 2026-06-11

### Changed

- Updated `criterion` from `0.5` to `0.8` (latest).
- Moved `criterion` from `[dependencies]` (optional) to `[dev-dependencies]`
  where it belongs — it is a benchmarking tool and has no place in the
  published dependency tree. The `bench` feature flag is removed; benches
  now compile unconditionally with `cargo build --benches`.
- Fixed two pre-existing bugs in `benches/workbook_diff.rs` that were
  previously hidden behind `required-features = ["bench"]`: a lifetime
  error in `bench_many_sheets` and a stale variable reference in
  `bench_alignment_vs_positional`.

## [2.2.1] - 2026-06-11

Additive response to integration feedback from ForskScope. No breaking changes.

### Added

- `output::view::CellChangeRow` now carries `old_formula: Option<&str>` and
  `new_formula: Option<&str>`, borrowed from the underlying `CellDiff`. GUI
  consumers can render formula changes without reaching past the view layer
  into the raw model.
- `output::view::OwnedCellChangeRow` — a fully owned counterpart to
  `CellChangeRow`, plus `CellChangeRow::to_owned_row()`. Convenience for
  consumers whose model outlives the `WorkbookDiff`.
- `ChangeAnchor` now derives `serde::Serialize` (under the `serde` feature).

### Documentation

- `Cancellation` trait: added an `Arc<AtomicBool>` cancellation example and a
  "Cancellation latency" section documenting that `is_cancelled()` is polled
  once per sheet pair (not mid-sheet).
- `DiagnosticKind::code()`: documented as the stable programmatic surface for
  diagnostics, with a full table of the current code strings.
- `CellDiff`: documented the "one `CellDiff` per address" consumer model and
  confirmed `change_kind()`'s derivation as stable API.
- `compare_paths`: documented that non-UTF-8 paths are fully supported with no
  internal `to_str()`/`unwrap()` on the path.
- `WorkbookDiff`: documented that `summary`, `metrics`, and the per-sheet
  `change` list are cheap to extract so bulky `cell_diffs` can be dropped.

## [2.2.0] - 2026-06-11

### Added

- **RFC-023 — Object / unsupported-feature coverage diagnostics**: every
  comparison emits an `Info`-level `UnsupportedWorkbookFeature` diagnostic
  explaining that charts, images, comments, hyperlinks, tables, pivot tables,
  and data validation are not compared. Non-worksheet sheet types (ChartSheet,
  MacroSheet, VBA) emit a `Warning`. Controlled by `ObjectCompareMode` (default
  `WarnIfPresent`); suppressible via `DiffOptionsBuilder::object_mode(Ignore)`.
- **RFC-024 — `DiffMetrics`**: `WorkbookDiff.metrics` carries `sheets_read`,
  `cells_read`, `cells_compared`, `diffs_emitted`, and `diagnostics_emitted`
  for benchmarking and performance analysis.
- **RFC-025 — Parallel sheet comparison** (`parallel` feature, off by default):
  `ExecutionMode::Parallel` processes sheets in parallel with `rayon`, then
  sorts results by original workbook order to guarantee identical output.
  Enable with `--features parallel`; select via
  `DiffOptionsBuilder::execution_mode(ExecutionMode::Parallel)`.
  **Correction (see Unreleased):** this entry is wrong. The feature never
  compiled — `ExecutionMode::Parallel` did not exist in `src/options.rs` — and
  its only test was gated on the same feature, so it never ran. The feature
  was removed rather than fixed; see RFC-025's amendment for why.
- **RFC-027 — Benchmarks** (`bench` feature): `benches/workbook_diff.rs`
  covers all eight RFC-027 scenarios (small-business, wide, tall, sparse,
  many-sheets, formula, rename, alignment cascade). Run with
  `cargo bench --features bench`.
- **RFC-028 — Fuzz targets** (`fuzz/`): four `cargo-fuzz` targets covering
  `compare_bytes` on arbitrary input, `col_to_label` roundtrip,
  `ComparedRange::union`, and `DiffOptionsBuilder::build`. Corpus seeds in
  `fuzz/corpus/fuzz_open_xlsx_bytes/`. See `fuzz/README.md`.
- **RFC-020 — Display formatting types**: `CellDisplay`, `CellSnapshot`,
  `CellNumberFormat`, `DisplaySource` added to the public model. `CellDisplay`
  carries a deterministic display string, an optional number-format record
  (`None` in calamine 0.35 — reserved for RFC-022), and a `DisplaySource` tag.
  `CellSnapshot` groups a `CellValue`, optional `FormulaText`, and optional
  `CellDisplay` with a `preferred_display()` helper. `CellValue::display_default()`
  is an alias for `display_string()` as per RFC-020 §6.
- **RFC-030 — Extended fixture corpus**: `tests/gen.rs` generates seven scenario
  fixtures (wide_columns, renamed_sheet, typed_values, formula, empty_sheet,
  sparse_range, row_insertion_cascade) into `tests/fixtures/generated/`, each
  with a `scenario.toml` and (with `--features serde`) an `expected.json`
  golden file. `tests/fixtures/corpus/README.md` documents the contribution policy.
- `ComparedRange::union` made `pub` (was `pub(crate)`).
- `DiffOptionsBuilder::object_mode`, `::execution_mode`, `::format_compare`
  builder methods.

## [2.1.0] - 2026-06-11

### Added

- **RFC-011 — Row alignment** (`AlignmentMode::RowKey`, `RowSignature`):
  opt-in row matching by key columns or content signature to reduce
  false-positive cascades after row insertions/deletions.
  `SheetDiff.alignment_summary` is populated when alignment is active.
- **RFC-021 — Workbook metadata diffs**: defined-name additions, removals, and
  target changes are reported as `Info`-severity diagnostics in
  `WorkbookDiff.diagnostics`. Sheet visibility changes are similarly reported.
  Defined-name scope is unavailable in calamine 0.35; a
  `DefinedNameScopeUnknown` diagnostic is attached when names are present.
- **RFC-022 — Format comparison policy**: `FormatCompareMode` enum added to
  `ComparisonOptions`. Selecting anything other than `Ignore` returns
  `SheetsDiffError::InvalidOptions` — calamine 0.35 exposes no cell-style API
  and the policy is honest about that.
- **RFC-029 — GUI view adapters** (`output::view`): `DiffView`, `CellChangeRow`,
  `SheetSummaryRow`, `ChangeAnchor`, `ViewFilter`. Framework-neutral borrowed
  iterators for sheet-tree, flat change-list, and prev/next navigation.
- `DiffOptionsBuilder::build_with_matching` convenience method.
- `FormatCompareMode` re-exported from crate root.

## [2.0.1] - 2026-06-11

### Added

- Expanded integration test corpus covering all RFC-015 fixture categories:
  corrupt inputs, wide-column A1 encoding (A–XFD), typed-value distinctions,
  formula handling, sheet rename/add/remove, empty and sparse ranges, resource
  limits, progress events, cancellation, text output, and JSON output.
- `tests/support.rs` — shared programmatic fixture builders.
- `tests/fixtures/corrupt/not_a_zip.xlsx` — committed corrupt binary fixture.
- `docs/src/migration/v1-to-v2.md` — migration guide (RFC-017): entry points,
  sheet changes, cell value model, duplicate-address policy, errors,
  diagnostics, text output, CLI exit codes, and a v1-style flattening helper.
- `docs/src/SUMMARY.md` and `docs/src/README.md` — mdbook scaffolding.

### Changed

- `compare` module is now `pub` so integration tests can call
  `compare_values_pub` directly; the function is `#[doc(hidden)]`.

## [2.0.0] - 2026-06-11

Complete rewrite.  v2 is a structured, library-first `.xlsx` diff engine.

### Breaking changes from v1

- **New public types**: `WorkbookDiff`, `SheetDiff`, `CellDiff`, `CellValue`
  replace the old `Diff`/`SheetDiff`/`CellDiff` string model.
- **Typed cell values**: `CellValue::Integer`, `Number`, `Bool`, `DateTime`,
  `Duration`, `Error`, `Empty` — no more stringly-typed old/new fields.
- **One `CellDiff` per address**: value and formula changes are subfields
  (`value`, `formula`), not separate entries.
- **Structured errors**: `SheetsDiffError` is `#[non_exhaustive]`; no more
  panics on ordinary bad input.
- **No stdout/stderr writes** from library code.
- Entry points: `compare_paths`, `compare_bytes`, `compare_readers` (and
  `_with_options` variants).

### New features

- Conservative sheet rename detection (`SheetMatchingMode::ExactNameThenConservativeRename`).
- `DiffOptions` grouped tree with builder; `Limits`, `ProgressSink`,
  `Cancellation` hooks.
- `EncryptedWorkbook` error for password-protected files.
- Correct Excel A1 addressing through column `XFD` (column 16 384).
- Deterministic result ordering by sheet index, then `(row, col)`.
- Text and unified-diff output formatters over `WorkbookDiff`.
- Optional `serde` feature: `Serialize` derives on all public model types.

### Migration from v1

See `docs/migration/v1-to-v2.md` (RFC-017 deliverable, to be added).

Quick reference:

| v1 | v2 |
|---|---|
| `Diff::new(old, new)` | `compare_paths(old, new)?` |
| `diff.cell_diffs[i].old` (String) | `diff.sheets[s].cell_diffs[c].value.as_ref().map(|v| v.old.display_string())` |
| `CellDiffKind::Value / Formula` | `CellDiff.value.is_some()` / `.formula.is_some()` |
| panic on bad input | `Err(SheetsDiffError::...)` |