# pack-io v0.3.0 — Wire-format freeze + collections + streaming

**The hard part of the roadmap, intentionally not deferred.** v0.3.0 ships the normative wire-format specification, freezes the byte shape for the entire `1.x` line, brings the standard library's collection surface under the codec (`Vec`, `HashMap`, `BTreeMap`, `HashSet`, `BTreeSet`), and adds the streaming `IoEncoder<W>` / `IoDecoder<R>` pair so the same `Serialize` / `Deserialize` impls work through `std::io::Write` / `Read` end-to-end. 177 tests pass on stable and MSRV 1.85 across Linux / macOS / Windows.

## What's new in 0.3.0

### Wire-format freeze ([`docs/WIRE_FORMAT.md`](https://github.com/jamesgober/pack-io/blob/main/docs/WIRE_FORMAT.md))

The byte-level specification is normative as of this release. It is written so a reader who has never seen the source code can implement a compatible encoder or decoder. Every primitive encoding, every compound type, the canonical map / set ordering rule, the full error taxonomy, and the allocation-cap defence are spelled out with MUST / MUST NOT semantics.

From this release onward, any `1.x` decoder reads any `1.x`-or-earlier encoding. Changes that would break the format require a `2.x` major version bump and are not accepted inside the `1.x` line. The wire format is now a contract, not a moving target.

### Collections

`Vec<T>`, `BTreeMap<K, V>`, `BTreeSet<T>`, `HashMap<K, V, S>`, and `HashSet<T, S>` all implement `Serialize` and `Deserialize`. The hash-based collections (`HashMap`, `HashSet`) are gated on the default `std` feature.

The wire shape is `varint(count) ++ entries`, with entries sorted **lexicographically by their encoded key bytes**. This canonical ordering is the load-bearing property — a `HashMap` and a `BTreeMap` holding the same logical data encode to **identical bytes**, regardless of insertion order or build-flag-dependent hash randomisation:

```rust
use std::collections::{BTreeMap, HashMap};

let mut h: HashMap<&str, u32> = HashMap::new();
h.insert("zeta", 26); h.insert("alpha", 1); h.insert("mu", 13);

let mut b: BTreeMap<&str, u32> = BTreeMap::new();
b.insert("alpha", 1); b.insert("mu", 13); b.insert("zeta", 26);

assert_eq!(pack_io::encode(&h).unwrap(), pack_io::encode(&b).unwrap());
```

Without this property, hashing, signing, or content-addressing a `HashMap` payload would fall over the first time a producer changed insertion order. With it, the encoded bytes are a function of the logical data alone.

### Streaming codec — `IoEncoder<W>` / `IoDecoder<R>`

The Tier-2 codec now comes in two flavours:

- **In-memory** ([`Encoder`](https://github.com/jamesgober/pack-io/blob/main/src/codec.rs), [`Decoder`](https://github.com/jamesgober/pack-io/blob/main/src/codec.rs)) — unchanged from v0.2.
- **Streaming** ([`IoEncoder<W>`](https://github.com/jamesgober/pack-io/blob/main/src/io.rs), [`IoDecoder<R>`](https://github.com/jamesgober/pack-io/blob/main/src/io.rs)) — wraps any `std::io::Write` / `Read`. Plus single-shot helpers [`encode_into`](https://github.com/jamesgober/pack-io/blob/main/src/io.rs) and [`decode_from`](https://github.com/jamesgober/pack-io/blob/main/src/io.rs) for the common case of "write one message into this writer" / "read one message from this reader".

```rust
use pack_io::{IoEncoder, IoDecoder};
use std::fs::File;
use std::io::{BufReader, BufWriter};

// Write directly into a file, no intermediate Vec<u8>.
let file = File::create("data.pack")?;
let mut enc = IoEncoder::new(BufWriter::new(file));
enc.write(&("hello", 42_u64))?;

// Read directly from the file.
let file = File::open("data.pack")?;
let mut dec = IoDecoder::new(BufReader::new(file));
let (s, n): (String, u64) = dec.read()?;
assert_eq!((s.as_str(), n), ("hello", 42));
# Ok::<(), Box<dyn std::error::Error>>(())
```

The new `SerialError::Io { kind, message }` variant captures `std::io::ErrorKind` and a stringified message, preserving `Clone + Eq` on `SerialError` while still letting callers branch on the kind.

### Trait refactor — `Encode` and `Decode` (breaking change)

`Serialize` and `Deserialize` are now generic over two new behaviour traits, `Encode` and `Decode`. One hand-rolled impl works through every encoder flavour the crate ships — in-memory **and** streaming — without duplication:

```rust
use pack_io::{Decode, Encode, Result, Serialize, Deserialize};

struct Point { x: i32, y: i32 }

impl Serialize for Point {
    fn serialize<E: Encode + ?Sized>(&self, enc: &mut E) -> Result<()> {
        self.x.serialize(enc)?;
        self.y.serialize(enc)
    }
}

impl Deserialize for Point {
    fn deserialize<D: Decode + ?Sized>(dec: &mut D) -> Result<Self> {
        Ok(Point {
            x: i32::deserialize(dec)?,
            y: i32::deserialize(dec)?,
        })
    }
}
```

The breaking change is contained: only callers who wrote hand-rolled `Serialize` / `Deserialize` impls in v0.2 are affected. Code that calls `encode()` / `decode()` / `Encoder::write()` / `Decoder::read()` is unaffected.

### `Encode` / `Decode` defaults — write the minimum, get the rest

Implementors of the new behaviour traits only have to provide the byte-level primitives:

- `Encode`: `write_byte`, `write_bytes`. Defaults handle `reserve`, `write_varint_u64`, `write_varint_u128`.
- `Decode`: `read_byte`, `read_into`, `max_alloc`. Defaults handle `read_varint_u64`, `read_varint_u128`, `read_length_prefixed`.

Concrete implementations like [`Decoder`](https://github.com/jamesgober/pack-io/blob/main/src/codec.rs) override `read_length_prefixed` for a faster zero-extra-validation path (the in-memory decoder already knows the remaining buffer length).

### Test suite — invariants enforced at every level

177 tests, all green on stable and MSRV 1.85:

| Suite | Count | Covers |
|---|---|---|
| Unit tests (in-source) | 62 | every primitive impl, varint corners, codec mechanics |
| [tests/roundtrip.rs](https://github.com/jamesgober/pack-io/blob/main/tests/roundtrip.rs) | 25 | `decode(encode(v)) == v` for every primitive |
| [tests/determinism.rs](https://github.com/jamesgober/pack-io/blob/main/tests/determinism.rs) | 21 | same value → same bytes, per primitive |
| [tests/adversarial.rs](https://github.com/jamesgober/pack-io/blob/main/tests/adversarial.rs) | 20 | random bytes / truncations / hostile lengths |
| [tests/collections.rs](https://github.com/jamesgober/pack-io/blob/main/tests/collections.rs) | 18 | collection round-trip + canonical-encoding contract + adversarial |
| [tests/streaming.rs](https://github.com/jamesgober/pack-io/blob/main/tests/streaming.rs) | 11 | `IoEncoder` vs `Encoder` byte equivalence + round-trip + I/O error mapping |
| Doctests | 20 | every public-item example compiles and runs |

The collection determinism suite explicitly tests the load-bearing property: `HashMap` and `BTreeMap` over the same data encode identically, and `HashMap` insertion order is irrelevant to the encoded output.

### Examples

```bash
cargo run --example collections_tour --release  # Vec / HashMap / BTreeMap / sets, canonical encoding
cargo run --example streaming_io --release      # Event records to a tempfile via IoEncoder / IoDecoder
```

(Plus the v0.2 examples: `basic_roundtrip`, `primitive_tour`, `reuse_buffer`.)

## Breaking changes

1. **Hand-rolled `Serialize` / `Deserialize` impls** — change the parameter type:
   - `fn serialize(&self, &mut Encoder)` → `fn serialize<E: Encode + ?Sized>(&self, &mut E)`
   - `fn deserialize(&mut Decoder<'_>)` → `fn deserialize<D: Decode + ?Sized>(&mut D)`
2. **`Encoder` no longer carries a `Config`** — `Config` is consumed only by the decode side now.
3. **Specialised `Vec<u8>` impls replaced by generic `Vec<T>` impls** — the wire format is identical; very-large-`Vec<u8>` decode is fractionally slower until the v0.6 optimisation pass restores a fast byte-slice path.

`pack_io::encode()` / `pack_io::decode()` / `Encoder::write()` / `Decoder::read()` are unaffected.

## Verification

Run on Windows x86_64, Rust stable + 1.85 (MSRV); identical commands pass on Linux (WSL2 Ubuntu) and via the configured CI matrix on macOS:

```bash
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo build --no-default-features              # no_std build
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo audit
cargo deny check

cargo +1.85 clippy --all-targets --all-features -- -D warnings
cargo +1.85 test --all-features
RUSTDOCFLAGS="-D warnings" cargo +1.85 doc --no-deps --all-features
```

All green. Test counts at this tag (stable, `--all-features`):

- **62** unit tests.
- **25** round-trip property tests.
- **21** determinism property tests.
- **20** adversarial decode tests.
- **18** collection tests (round-trip, canonical encoding, adversarial).
- **11** streaming tests (in-memory vs streaming byte equivalence, I/O error mapping).
- **20** doctests.
- **177** total, every one passing.

All five example programs run end-to-end and round-trip their values.

## What's next

- **0.4.0 — `View<T>` zero-copy decode + `derive` macro.** `View<T>` exposes string and byte fields as borrows directly out of the input buffer, no per-message `String` / `Vec<u8>` allocation. The `#[derive(pack_io::Serialize, pack_io::Deserialize)]` proc-macros write sound impls for any struct or enum, so user code stops needing the hand-rolled four-line `serialize` / `deserialize` body for the common case.

## Installation

```toml
[dependencies]
pack-io = "0.3"

# no_std build:
pack-io = { version = "0.3", default-features = false }
```

MSRV: Rust 1.85 (2024 edition).

## Documentation

- [README](https://github.com/jamesgober/pack-io/blob/main/README.md)
- [API Reference](https://github.com/jamesgober/pack-io/blob/main/docs/API.md)
- [Wire Format Spec](https://github.com/jamesgober/pack-io/blob/main/docs/WIRE_FORMAT.md)
- [CHANGELOG](https://github.com/jamesgober/pack-io/blob/main/CHANGELOG.md)

---

**Full diff:** [`v0.2.0...v0.3.0`](https://github.com/jamesgober/pack-io/compare/v0.2.0...v0.3.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/pack-io/blob/main/CHANGELOG.md#030---2026-05-28).
