# Error Handling Overhaul — miette Diagnostics for `audio_samples`
## Context
The crate already has a well-structured error *hierarchy* (`src/error.rs`): a root
`AudioSampleError` (`#[non_exhaustive]`) wrapping five domain enums (`Conversion`,
`Parameter`, `Layout`, `Processing`, `Feature`) plus standalone variants, all built on
`thiserror 2.0`, with ergonomic constructors and `From` impls. The roadmap note in
`src/lib.rs:285` states the problem precisely:
> *"Improve the error system — it has breadth currently, but not depth or aesthetics.
> Rust set a new standard for error handling (errors that tell you exactly what went
> wrong, where, and how to fix it)."*
So the **breadth is done**; what's missing is **depth, actionability, correctness of the
taxonomy, and presentation**:
- No machine-stable error **codes**.
- No **help/fix-it** text telling the caller how to recover.
- No **source spans** — text-parsing failures (note names, enum `FromStr`) report a flat
message instead of pointing at the offending character.
- **Information loss**: `.map_err(|_| …)` discards source errors (10+ sites), the
`From<&str>`/`From<String>` shortcuts stamp `"unknown"` parameter/algorithm names
(5 sites), and 22+ bare `EmptyData` returns carry no context about *which* operation
failed.
- **Wrong-shaped errors**: some failures are modelled too broadly (one variant covering
several distinct causes), and some carry stringly-typed payloads where a structured
variant or enum would be clearer and matchable. Bringing miette in is only half the win —
the other half is making sure we have **the right errors in the right places**,
independent of any rendering library.
This overhaul therefore has two intertwined goals:
1. **Adopt miette** for codes, help text, severity, and source-span rendering.
2. **Right-size the error taxonomy** — split overly-broad variants, replace stringly-typed
payloads with structured/enumerated data, and put each failure in the correct domain —
so the diagnostics miette renders are *accurate* and *actionable*, not just prettier.
**Decisions taken (confirmed with user):**
1. **Use miette**, with the **`fancy`** renderer — accept the dependency cost for the
functionality (codes, help, severity, real `SourceSpan` rendering).
2. **Breaking `2.0` is allowed** — we may restructure variants, delete lossy `From` impls,
and add required fields.
3. **Scope = errors only** — enrich the ~316 `Err`/59 `map_err` sites. The ~1463
`unwrap`/`expect`/`panic!` sites are **out of scope** (mostly tests/doctests/invariants).
4. **Full source spans on parsable paths** — invest in `#[source_code]`/`#[label]` wherever
user-supplied *text* is parsed.
5. **Improve the taxonomy itself** — narrow over-broad variants and replace stringly-typed
errors with structured ones wherever it improves clarity, matchability, or correctness
(see §A7), regardless of miette.
---
## Part A — What the errors will look like (design)
### A1. Dependency & feature wiring (`Cargo.toml`)
```toml
miette = "7" # core Diagnostic trait + derive, no renderer
# fancy renderer gated so downstreams aren't forced to pull graphical deps:
[features]
fancy = ["miette/fancy"] # enables coloured caret-underline rendering
```
- Library code only ever needs `#[derive(Diagnostic)]` from the core crate. The `fancy`
renderer (`miette/fancy`, which pulls `owo-colors`, `supports-color`, etc.) is gated
behind our own `fancy` feature so library consumers opt in.
- **Enable `fancy` by default for our `examples/` and the `educational` module** (add
`fancy` to the relevant example/bin requirements and to the default feature set used in
`cargo run --example …`) so the pretty output is visible out of the box, per the
"bite the bullet" decision. Downstream library users still get plain `Display` unless
they turn `fancy` on.
- MSRV: miette 7 requires rustc ≥ 1.82; the crate is edition 2024, so this is satisfied.
### A2. Every error variant gains `#[derive(Diagnostic)]` + `code` + `help`
`AudioSampleError` and all five sub-enums get `Diagnostic` derived alongside the existing
`Error` derive. Each variant gets:
- **`code(...)`** — a stable, namespaced identifier, scheme:
`audio_samples::<domain>::<variant>`, e.g.
`audio_samples::parameter::out_of_range`, `audio_samples::layout::non_contiguous`,
`audio_samples::conversion::audio_conversion`, `audio_samples::feature::not_enabled`.
- **`help(...)`** — an **actionable** recovery hint. Static where possible; a
`#[help] help: Option<String>` field where the fix depends on runtime values. Examples:
- `OutOfRange` → *"pass a value in [{min}, {max}]; got {value}"* (can derive from the
`#[error]` fields via a format string in `help(...)`).
- `FeatureError::NotEnabled` → already embeds `--features {feature}`; promote that to the
`help()` slot and keep the `#[error]` message short.
- `Layout::NonContiguous` → *"call `.to_contiguous()` / clone the data before `{operation}`"*.
- `ConversionError::UnsupportedConversion` → *"use `to_format`/`to_type` for audio-aware
conversion, or `cast_as`/`cast_to` for raw bit-casts"*.
- **`url(docsrs)`** on the sub-enums so `fancy` output links straight to the variant's
docs page (the doc pass already wrote rich per-variant docs — this surfaces them).
- **`severity(...)`** — default `Error`; reserve `Warning` for genuinely recoverable
conditions (none currently, but the slot is wired for future use).
### A3. Source-span variants for parsable text (the "where")
Add `#[source_code]` + `#[label]` to the variants that arise from parsing user text. The
two concrete targets from the inventory:
1. **Music-notation parsing** — `src/utils/audio_math.rs::note_to_midi` (~lines 800–833).
Introduce a dedicated diagnostic, e.g.
```rust
#[derive(Error, Debug, Diagnostic, Clone)]
#[error("invalid note name")]
#[diagnostic(code(audio_samples::parse::note_name),
help("expected scientific pitch notation like `A4`, `C#3`, `Bb2`"))]
pub struct NoteParseError {
#[source_code] input: String,
#[label("{kind} here")] span: SourceSpan, kind: String, }
```
This replaces the current `map_err(|_| invalid_value(…))` at line 821 (which discards the
integer-parse error) and the flat string at lines 806–809.
2. **Enum `FromStr` impls** in `src/operations/types.rs` (`PadSide`, `NormalizationMethod`,
`VadMethod`, `ResamplingQuality`) and `src/operations/onset/mod.rs`
(`SpectralFluxMethod::from_str`). Each gets a span pointing at the unrecognised token
plus a `help()` listing the valid alternatives. A single shared
`EnumParseError { input, span, expected: &'static [&str] }` diagnostic covers all of them.
These thread up into `AudioSampleError::Parse` (which is generalised to carry an optional
`#[diagnostic_source]`), so the rich span survives propagation through `?`.
### A4. Foreign errors become `#[diagnostic_source]` chains, not stringly-typed
Stop flattening foreign errors to strings. Where a real error type exists, keep it as a
nested source so `fancy` prints the full cause chain:
- `ndarray::ShapeError` — keep the `From` impl but attach the original as
`#[source]`/`#[diagnostic_source]` instead of `err.to_string()` (`src/error.rs:491`).
- `spectrograms::SpectrogramError` — already `#[from]`; add `#[diagnostic_source]` so its
own diagnostics (if any) render nested.
- **rubato** resampler errors (`src/resampling.rs:460–536`) — capture the real error type as
a source rather than `format!("…: {e}")`.
- **`non_empty_slice` empty errors** — add `From<EmptyError> for AudioSampleError` and a new
contextful `EmptyData { operation: String }` so the 10+ `map_err(|_| EmptyData)` sites
(resampling.rs:615, codecs/perceptual/{mod,stereo}.rs, editing.rs:1885/1903/1924) keep the
source and say *which* operation produced no samples.
- **`std::io::Error`** in `src/educational/mod.rs:140` — wrap in a dedicated `Io` variant
instead of leaking a raw `io::Error`.
### A5. Breaking cleanups (the "2.0" budget)
- **Delete** `From<&str>`/`From<String>` for `ParameterError` and `ProcessingError`
(`src/error.rs:973–1011`) — they manufacture `"unknown"` names. Each of their call sites
is rewritten to name the real parameter/algorithm. (Inventory: the shortcuts are the
source of every `"unknown"` placeholder.)
- **`AudioSampleError::layout(msg)`** helper (`src/error.rs:289`) — drop the hardcoded
`operation: "unknown"`; require an operation name, or remove in favour of
`LayoutError::shape_error(operation, info)`.
- **`EmptyData`** → `EmptyData { operation: String }` (carries context).
- **`Unsupported(String)`** → structured `Unsupported { operation, reason }`, and fix the
vague "not yet implemented" sites (iir_filtering.rs:1484, 1596) to name the exact
unsupported configuration.
### A6. Before / after (what a caller sees)
*Before* (flat `Display`):
```
Invalid value for parameter 'cutoff_hz': Cutoff frequency must be less than Nyquist frequency
```
*After* (with `fancy`):
```
× parameter `cutoff_hz` is out of range
╭─[note_to_midi]
│ cutoff_hz = 25000
· ──┬──
· ╰── must be in [20, 22050]
╰────
help: the Nyquist limit for 44100 Hz is 22050; lower the cutoff or raise the sample rate
code: audio_samples::parameter::out_of_range
docs: https://docs.rs/audio_samples/latest/audio_samples/error/enum.ParameterError.html
```
### A7. Right-sizing the taxonomy (miette-independent)
Bringing miette in only renders what we already model. This section is about modelling the
*right* errors — work that stands on its own value even without a single `#[diagnostic]`
attribute. The implementation phase must, at every touched call site, ask three questions
and fix what it finds:
**(a) Is this variant too broad?** Split a single catch-all into causes the caller can
actually act on differently.
- `ProcessingError::AlgorithmFailure { algorithm, reason }` is the dumping ground (every
`From<&str>`/`From<String>` lands here with `algorithm: "unknown"`). Audit its real call
sites and promote recurring causes to first-class variants — e.g. a distinct
*unstable-filter* condition vs. a *numerical-overflow* condition vs. a genuinely opaque
algorithm failure. Only keep the generic variant for the genuinely-opaque tail.
- `LayoutError::InvalidOperation(String, String)` overlaps with `ShapeError`,
`DimensionMismatch`, and `IncompatibleFormat`. Many "X is only supported for mono audio"
sites (transforms.rs, processing.rs) are really a single recurring concept — a
**mono-only / channel-count** precondition. Consider a dedicated
`LayoutError::ChannelCountUnsupported { operation, required, actual }` so the ~10
near-identical "only supported for mono" strings collapse into one structured,
matchable variant with a uniform help ("convert to mono with `.to_mono()` first").
**(b) Is this stringly-typed where it should be structured/enumerated?** Replace free-text
payloads that encode a closed set of cases with real types.
- Conversion errors store `source_type`/`target_type` as `String`. These are a *closed set*
(the `SampleType` enum already exists in `repr.rs`). Carry `SampleType` (or a small
copy-able type id) instead of `String` so callers can match on the exact pair and so we
can't typo a type name.
- The "not yet implemented" filter sites encode a *filter response kind* in prose. Carry the
actual enum value (filter type / response) in the variant rather than describing it in a
string.
- Anywhere a `reason: String` actually conveys one of a handful of fixed conditions, prefer
an enum field over prose.
**(c) Is this error in the right domain?** Re-home misfiled variants.
- "X is only supported for mono audio" is currently raised as **both**
`ParameterError::invalid_value("self", …)` (transforms.rs:173, 756) **and**
`LayoutError::invalid_operation(…)` (transforms.rs:278, 334, …) for the *same* class of
failure. Pick one home (Layout — it's a structural precondition) and apply it uniformly.
- Confirm conversion/parameter/layout/processing boundaries hold after the splits above; a
failure should be discoverable under exactly one domain.
**Guard-rails for this work:**
- Do **not** invent variants speculatively. Promote a cause to its own variant only when it
appears at **2+ real call sites** or when callers plausibly need to branch on it. The
`#[non_exhaustive]` attribute means we can add more later without breaking changes.
- Every new/changed variant still gets its `code`/`help`/`url` per §A2 — taxonomy and
diagnostics land together, not in separate passes.
- Record each split/merge/re-home decision inline in the PR description so the rationale is
reviewable.
---
## Part B — Why we benefit
- **Actionable**: every variant ships a `help()` recovery hint — the caller is told *how to
fix it*, not just what broke.
- **Locatable ("where")**: parse failures point a caret at the offending character; every
operation error names its operation/parameter and the actual-vs-expected values.
- **Correctly modelled**: splitting over-broad variants and replacing stringly-typed payloads
(§A7) means callers can `match` on the *real* cause and we stop encoding closed sets as
free text — a correctness and ergonomics win independent of rendering.
- **Stable codes**: `audio_samples::<domain>::<variant>` lets downstreams match
programmatically and lets us write a docs page per code.
- **No information loss**: source-error chains via `#[diagnostic_source]` replace the
lossy `map_err(|_| …)` and `format!("…: {e}")` patterns.
- **Aesthetics**: `fancy` rendering in examples/educational gives the "new standard" look
the roadmap asks for — immediately, since examples already propagate with `?`.
- **Zero-risk for consumers**: `Diagnostic` is purely additive over `std::error::Error`;
the `fancy` renderer is feature-gated, so library users who don't want it pay nothing.
---
## Part C — Implementation roadmap (coverage-driven)
> Implementation is a **separate phase**. This is the ordered plan; coverage of all ~316
> `Err`/59 `map_err` sites is the explicit success criterion.
**Phase 0 — Taxonomy audit (design lock-in for §A7).** Before touching `Cargo.toml`, sweep
the inventory and produce the concrete variant list: which `AlgorithmFailure`/`InvalidOperation`
clusters get promoted, which `String` payloads become enums (`SampleType`, filter kinds),
and which variants get re-homed. Output is the final `enum` shapes that Phase 1 implements.
This prevents churning the error module twice.
**Phase 1 — Foundation (`src/error.rs` + `Cargo.toml`)**
- Add `miette` dep + `fancy` feature.
- Derive `Diagnostic` on root + 5 sub-enums; add `code`/`help`/`url`/`severity` to **every**
variant.
- Apply the Phase-0 taxonomy changes: split over-broad variants, swap stringly-typed payloads
for structured types, add the `ChannelCountUnsupported` (or equivalent) variant, re-home
misfiled cases.
- Restructure standalone variants per A5 (`EmptyData{operation}`, `Unsupported{operation,reason}`,
generalise `Parse` to hold a `#[diagnostic_source]`).
- Add new diagnostics: `NoteParseError`, `EnumParseError`, `Io`.
- Add `From<EmptyError>`; rework `From<ShapeError>` to keep the source.
- **Delete** the `From<&str>`/`From<String>` shortcuts and the `"unknown"`-stamping
`layout()` helper.
- Update `src/lib.rs` re-exports (add new public diagnostic types).
**Phase 2 — Fix the compile fallout = the coverage sweep.** Deleting the lossy `From` impls
and changing variant shapes makes every affected call site fail to compile. Work the
compiler errors module-by-module, and at each site (i) upgrade the error to name its real
operation/parameter/values, (ii) attach sources, and (iii) apply the §A7 three-question
check — pick the right variant/domain, not just the nearest one. Module order by density
(from inventory): `repr.rs` (≈18 `EmptyData`), `operations/channels.rs`,
`operations/processing.rs`, `operations/iir_filtering.rs`, `operations/hpss.rs`,
`resampling.rs`, `utils/audio_math.rs`, `codecs/perceptual/*`,
`operations/{onset,vad,transforms,editing,…}.rs`, `operations/types.rs`.
**Phase 3 — Spans.** Implement `NoteParseError` in `note_to_midi` and `EnumParseError`
across the `FromStr` impls (types.rs, onset/mod.rs).
**Phase 4 — Presentation.** Enable `fancy` for `examples/` + `educational`; convert the
`unwrap()` in `educational/mod.rs:25` to `?`/proper handling; add a top-level example
showing a rendered diagnostic.
**Phase 5 — Tests & docs.** Extend `src/error.rs` tests to assert `code()` and `help()`
output; update the module doc + the doctests that match on variant shapes (any test asserting
on `EmptyData`/`Unsupported`/the split variants); refresh the `## Error Handling` section in
`lib.rs`.
---
## Verification
- `cargo build --all-features` and `cargo build` (default features) both clean — confirms the
`fancy` gating works and no consumer is forced into graphical deps.
- `cargo test` and `cargo test --doc` green (doctests that match `EmptyData`/`Unsupported`/the
split variants will need updates — covered in Phase 5).
- `cargo clippy --all-features` clean.
- **Manual fancy check**: `cargo run --example processing --features fancy` (or whichever
example deliberately triggers a parameter error) and eyeball the caret-underline + help +
code rendering.
- **Span check**: a unit test calling `note_to_midi("H4")` / `note_to_midi("C#x")` asserts the
rendered diagnostic contains the label at the right offset and the help text.
- **Taxonomy check**: a test (or grep) confirms the "only supported for mono" concept now
raises a single structured variant everywhere, and that conversion errors carry typed
`SampleType` rather than `String`.
- **Coverage check**: `rg "map_err\(\|_\|" src/` returns nothing (all lossy discards removed);
`rg '"unknown"' src/` returns nothing in non-test code.