sheets-diff 2.4.0

Structured diff engine for Microsoft Excel .xlsx workbooks
Documentation
# Handoff 03 — Exit code 3, and the CLI test that never existed

**Governing RFC.** RFC-013 (output formatters, CLI and exit codes)
**Roadmap.** M4 — the unit that makes this release a minor rather than a patch
**Sequence.** Any.

## Purpose

Emit the exit code RFC-013 specifies, and test the exit codes at all.

## Background

RFC-013 line 90: *"Invalid/corrupt inputs produce exit code 3."* No exit code 3
exists anywhere in the crate. `src/main.rs` emits:

- `exit(2)` for invalid CLI options
- `exit(1)` when differences are found
- `exit(2)` for every comparison error, corrupt input included

So a caller cannot distinguish "this file is not a workbook" from "you passed a
bad flag". Both are 2.

Separately, **no test invokes the built binary at all** — `tests/` contains no
`Command::new` or equivalent. The exit codes have never been verified by
anything, which is why the gap survived from RFC-013's acceptance to now.

## Change scope

`src/main.rs`, `tests/` (a new CLI test), `Cargo.toml` if a dev-dependency is
needed for subprocess testing, `README.md` and/or `docs/` where exit codes are
documented, `CHANGELOG.md`.

## Non-change scope

Do not change the library. Exit-code mapping belongs in `main.rs`; if it seems
to need a new error variant or a classification helper in `src/`, **stop and
report** — that would be a public API change and this unit is not the place.

Do not change codes 0, 1 or 2's existing meanings beyond narrowing 2 by moving
the corrupt-input subset to 3.

## Required implementation

1. **Decide which errors are "invalid/corrupt input", and justify the line.**
   `SheetsDiffError` distinguishes `OpenWorkbook` (with `OpenErrorKind`:
   `NotFound`, `PermissionDenied`, `NotXlsx`, `Corrupt`, `Locked`, `Other`),
   `ReadSheet`, `UnsupportedFormat`, `EncryptedWorkbook`, `InvalidOptions`,
   `Cancelled`, `LimitExceeded`, `Internal`.

   Not all are input problems. `NotFound` and `PermissionDenied` are arguably
   environment, not corruption; `InvalidOptions` is caller error; `Cancelled`
   and `LimitExceeded` are neither. **State the mapping you choose and why**, in
   the review request and in the documentation. This is the substance of the
   unit — the code is a `match`.
2. **Document the full exit-code contract** where a CLI user will find it. An
   exit code nobody documents is barely better than one nobody emits.
3. **Add the subprocess test.** This closes one of M5's three findings early,
   deliberately: implementing an exit code without testing it would be the same
   defect class this milestone exists to remove. Cover every code the CLI can
   produce, not only the new one.

## Required tests

A test that runs the built binary and asserts its exit status for: no
differences (0), differences found (1), invalid options (2), corrupt input (3),
and whatever else your §1 mapping produces. `tests/fixtures/corrupt/not_a_zip.xlsx`
already exists for the corrupt case.

Prefer `std::process::Command` with `env!("CARGO_BIN_EXE_sheets-diff")` over a
new dev-dependency; if you add one, justify it against the `deny.toml` gate.

## Acceptance criteria

1. Corrupt/invalid input exits 3; the mapping is stated and justified.
2. Every exit code the CLI emits is covered by a subprocess test.
3. The contract is documented where a CLI user will see it.
4. The library is unchanged — `git status` shows nothing under `src/` except
   `main.rs`.
5. CHANGELOG records this as a **behaviour change to the CLI contract**, not a
   bugfix footnote. A script matching `2` for corrupt input will now see `3`.
6. The fixture corpus does not move.
7. Gates green, including the `cli` feature leg.

## Prohibited shortcuts

- Do not map every error to 3 to make the criterion pass. The point is that 2
  and 3 mean different things.
- Do not add the exit code without the test. That is the defect this milestone
  removes, committed freshly.
- Do not reach into `src/` for a classification helper without reporting first.

## Compatibility constraints

**This is why M4 releases as 2.4.0 rather than 2.3.1.** Exit codes are an
interface. A consumer matching `2` for "operational error" will see `3` for the
corrupt-input subset after this change. It is the correct behaviour per RFC-013
and it is still a contract change — say so plainly in the CHANGELOG rather than
burying it.

## Known risks

- The mapping decision is genuinely open and reasonable people would draw it
  differently. That is why §1 asks for justification rather than a rule.
- Subprocess tests can be flaky about binary paths across platforms; the CI
  matrix includes Windows, so verify there rather than assuming.

## Required evidence

- The mapping, with reasoning
- The subprocess test and its output, on both platforms via CI
- `git status` showing only `main.rs` under `src/`
- Corpus hash unchanged
- CI run link

## Review request format

Per development policy §9.2, plus the §1 mapping and its justification.