Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Working on smart-package-tracker

Orientation for developers and AI agents. Read this before changing code.
`CLAUDE.md` points here; this file is the single source of truth.

## What this crate is

A `no_std` + `alloc` library that mints package tracking IDs (`PKG-9ED9285C`)
and renders them as Code 128 or QR barcodes, to PNG or SVG. No network I/O, no
async, no carrier integrations — those are deliberately out of scope (see
"Boundaries").

## The one diagram that explains the layout

```text
TrackingId ──▶ Symbology::encode ──▶ Symbol ──▶ Renderer::render ──▶ bytes
src/id/ src/symbology/ (BitMatrix) src/render/
```

The `BitMatrix` in the middle is the seam. Encoders never know about pixels;
renderers never know about symbologies. A linear barcode is a 1-row matrix, a
QR code is a square one — that is the *only* difference the renderers see.

**If a change makes a renderer match on `SymbologyKind`, the seam has been
broken. Push the difference into `Symbol`/`SymbologyKind` metadata instead.**

## Repo map

| Path | What lives there |
|------|------------------|
| `src/lib.rs` | Crate docs, feature table, re-exports. Public API surface starts here. |
| `src/error.rs` | The single `Error` enum. `#[non_exhaustive]` — add variants, never repurpose them. |
| `src/id/mod.rs` | `TrackingId` newtype + parsing/validation. `MAX_LEN`, the charset rule. |
| `src/id/generator.rs` | `IdGenerator`/builder: prefix, entropy width, checksum policy. |
| `src/id/checksum.rs` | ISO/IEC 7064 MOD 37,36 check character. |
| `src/symbology/mod.rs` | `BitMatrix`, `Symbol`, the `Symbology` trait, `SymbologyKind` metadata. |
| `src/symbology/code128.rs` | Code 128 encode + decode, wrapping the `code128` crate. |
| `src/symbology/qr.rs` | QR encode, wrapping the `qrcode` crate. |
| `src/render/mod.rs` | `RenderOptions`, `Length`, `Color`, `QuietZone`, and **`Layout`** — the shared geometry calculation. |
| `src/render/png.rs` | Rasterises `Layout` into RGBA, wraps it in a PNG with a `pHYs` chunk. |
| `src/render/svg.rs` | Emits the same `Layout` as `<rect>` runs. |
| `src/render/hri.rs` | Embedded 5x7 bitmap font for the human-readable text line. |
| `tests/integration.rs` | Consumer-level end-to-end tests (Code 128). |
| `tests/qr.rs` | QR output decoded back with `rqrr`, an independent decoder. |
| `examples/label.rs` | Runnable end-to-end demo: `cargo run --example label`. |

## Invariants — do not break these silently

1. **Quiet zones are conformant by default.** Code 128 needs 10 modules per
side, QR needs 4 on all four sides (`SymbologyKind::required_quiet_zone`).
Nothing — including the human-readable text line — may be drawn inside them.
This is the number one cause of "looks fine, will not scan".
2. **Modules snap to whole pixels.** A bar straddling a pixel boundary renders
as a grey edge and costs the contrast a scanner needs. `Layout` is integer
pixels throughout.
3. **PNG and SVG describe identical geometry.** Both consume the same `Layout`.
Any geometry change belongs in `Layout::compute`, never in one renderer.
4. **Fallible operations return `Result`, they do not panic.** The README
promises this. Accessors tolerate out-of-range input rather than panicking,
and the handful of constructors that do assert say so in a `# Panics`
section. Keep it that way.
5. **Output is byte-for-byte reproducible.** No timestamps, no hash-map
iteration order, no system fonts. That is why the HRI font is embedded.
6. **`no_std` + `alloc` core.** Only `std`, `png`, and `qr` may assume `std`.
Use `alloc::` imports, not `std::`, outside `#[cfg(feature = "std")]`.
7. **`#![forbid(unsafe_code)]`.** Non-negotiable.

## Commands

```sh
cargo test --all-features # everything, incl. doctests
cargo clippy --all-features --all-targets -- -D warnings
cargo fmt --all --check
cargo run --example label # writes label*.png / label*.svg

# no_std still builds
cargo build --target thumbv7em-none-eabihf --no-default-features --features code128,svg

# feature combinations compile in isolation (CI covers a subset)
for f in "" std serde os-rng code128 svg qr png; do
cargo check --no-default-features --features "$f" --quiet || echo "FAIL [$f]"
done
```

MSRV is **1.85** (set by `getrandom` 0.4 / edition 2024) and is tested in CI.
Raising it is a breaking change.

## How to add things

* **A new symbology** — implement `Symbology` in `src/symbology/`, add a
`SymbologyKind` variant with its `required_quiet_zone()` and `is_linear()`,
gate it behind a feature. Touch no rendering code. QR was added this way.
* **A new renderer** — implement `Renderer` in `src/render/`, consume `Layout`,
compute no geometry of your own.
* **A new error case** — add a variant to `src/error.rs` with a lowercase,
actionable `#[error(...)]` message. The enum is `#[non_exhaustive]`, so this
is not a breaking change.
* **A new public item** — it needs a doc comment (`missing_docs` is warned on),
an `# Errors` section if fallible, and a `# Panics` section if it can panic.

## Test conventions

* Unit tests live in a `#[cfg(test)] mod tests` at the bottom of their file and
gate on features (`#[cfg(all(test, feature = "code128"))]`).
* Integration tests assert on *decoded output*, not on internals — PNG bytes are
decoded back to pixels, QR output is decoded by `rqrr`.
* Prefer a test that would fail for the real-world reason (a scanner cannot read
this) over one that asserts an implementation detail. Note that a decoder
handed a perfectly cropped image is far more forgiving than a scanner pointed
at a parcel — see the comment in `tests/qr.rs`.

## Boundaries

Out of scope on purpose: image scanning, shipment events, carrier APIs, GS1
identifier issuance. They bring network I/O, async runtimes, and vendor licence
terms into a dependency graph that is currently 16 permissively-licensed crates
with no copyleft (`cargo deny check` enforces this).

## Release — irreversible

Pushing a `v*.*.*` tag runs `.github/workflows/release.yml`, which publishes to
crates.io behind a `crates-io` environment approval. **A published version can
be yanked but never removed or replaced.** Do not create version tags unless
publishing is the explicit intent. `workflow_dispatch` with `dry_run: true`
verifies and packages without publishing.

## Where the bugs have actually come from

Every defect found in this crate so far has been one of four shapes. They are
worth checking for by name in any change:

1. **A rule applied to one symbology but not the other.** The human-readable
text line was placed a fixed one module below the symbol — correct for
Code 128, which has no vertical quiet zone, and a quiet-zone violation for
QR, which needs four clear modules underneath.
2. **Two limits chosen independently that have to agree.** `MAX_ENTROPY_BITS`
allowed a body longer than `MAX_LEN` permitted, so the generator could mint
IDs its own parser refused. Tie limits like these together with a
`const _: () = assert!(...)` so they cannot drift.
3. **A validated type reachable through a back door.** Deriving
`Deserialize` on `TrackingId` let any string in, bypassing `parse`
completely. Anything that constructs a validated type must go through its
validator.
4. **A guard that does not guard what its comment claims.** The image size
limit was per-axis while the allocation is the product of both.

And one about tests: a test that passes because a decoder is lenient proves
nothing about a scanner. Assert on the rendered pixels.

Fixed instances of all four are described in
[CHANGELOG.md](CHANGELOG.md#unreleased).
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Human-readable text no longer intrudes into a QR code's quiet zone.** The
HRI line was placed one module below the symbol, which is right for Code 128
— a linear symbology has no vertical quiet zone — and wrong for QR, which
requires four clear modules on every side. At default settings 708 pixels of
text ink landed inside the band a scanner needs to find the symbol. Text is
now placed below the quiet zone. Linear output is unchanged.
- **`IdGenerator` can no longer mint IDs that `TrackingId::parse` rejects.**
`MAX_ENTROPY_BITS` (512, so a 128-character body) and the format's overall
length limit (128) had been chosen independently, so a 512-bit policy
produced a 132-character ID that failed to parse on the way back out of
storage. The length limit is now 160, derived from the widest policy the
generator accepts, and a static assertion fails the build if the two ever
drift apart again. IDs that parsed before still parse.
- **`serde` deserialisation validates.** `TrackingId` derived `Deserialize`
with `#[serde(transparent)]`, so any string at all — `""`,
`"pkg-lowercase"`, `"total garbage!!"` — became a `TrackingId` without going
through `parse`. Worse, `"no-separator-here"` was accepted and then reported
`prefix() == "no"` and `body() == "separator"`, silently dropping the rest.
Deserialisation now runs the same validation as `parse` and fails with the
same message. Serialisation is unchanged.
- **`BitMatrix::row` no longer panics on an out-of-range row.** It returns an
empty slice, matching `get` and `set`, which already tolerated out-of-range
coordinates. `BitMatrix::from_row` now documents the panic it has always had.
- **The image size limit bounds the allocation, not just each axis.** Two
dimensions individually under the 20,000 px cap multiplied out to 400
megapixels — a 1.6 GiB single allocation in the PNG rasteriser, which is
exactly what the limit exists to prevent. Geometry over 64 megapixels is now
rejected with an actionable message.
- `IdGenerator::validate` now checks that a body's entropy characters are
uppercase hexadecimal. `PKG-ZZZZZZZZ` used to pass under the default policy
despite the generator only ever emitting `0-9A-F`. The check character is
still drawn from the wider 36-character alphabet and verified separately.
- A prefix containing multi-byte characters is now reported as a charset
error rather than as being too long — the length check counts bytes, and ran
first.
- The Code 128 bar blitter clamps both ends of its slice. An upstream bar
reported at or past the trailing quiet zone would have sliced backwards and
panicked; no released version of `code128` emits one, but a version bump no
longer risks turning that into a crash.

### Added

- `AGENTS.md`, an orientation guide for contributors and coding agents: the
repo map, the invariants a change must not break, where to add a new
symbology or renderer, and the shapes the bugs above kept taking. `CLAUDE.md`
points at it.
- README section mapping every `Error` variant to its cause and its fix.
- Tests covering each fix above, including a QR quiet-zone assertion that
renders with human-readable text enabled and inspects the pixels around the
symbol, rather than relying on a decoder that is far more forgiving than a
scanner.

### Changed

- Editor and agent tooling files (`.claude/`, `.entire/`, `CLAUDE.md`,
`hello-entire.md`) are excluded from the published crate. Publishing is
irreversible, so the tarball should carry only what a consumer needs.

## [0.2.0] - 2026-08-22

### Added
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ documentation = "https://docs.rs/smart-package-tracker"
readme = "README.md"
keywords = ["barcode", "code128", "tracking", "shipping", "logistics"]
categories = ["encoding", "multimedia::images", "no-std"]
exclude = ["/.github", "/target"]
# Editor, agent and tooling files mean nothing to a consumer of the crate,
# and publishing is irreversible. AGENTS.md and CHANGELOG.md stay in: they
# are about the crate rather than about one tool.
exclude = ["/.github", "/target", "/.claude", "/.entire", "/CLAUDE.md", "/hello-entire.md"]

[dependencies]
code128 = { version = "0.2.1", default-features = false, optional = true }
Expand Down
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ the PNG prints.
* **Set C compression.** Digit runs encode two per symbol, which roughly halves
the width of numeric carrier tracking numbers.
* **Reproducible output.** Identical inputs produce byte-identical files.
* **No panics on bad input.** Every fallible operation returns `Result`.
* **No panics on bad input.** Every fallible operation returns `Result`, and
out-of-range accessors return empty rather than panicking.

## QR codes

Expand Down Expand Up @@ -110,6 +111,35 @@ generator.validate(&id)?;
Randomness alone never guarantees uniqueness. Keep a unique constraint at the
storage layer and retry on conflict.

## When something goes wrong

Every fallible call returns `Result<_, Error>`. `Error` is `#[non_exhaustive]`,
so match with a `_` arm. What each variant means in practice:

| Variant | Typical cause | What to do |
|---------|---------------|------------|
| `InvalidTrackingId` | A string from storage or user input is not `PREFIX-BODY`, or is not `A-Z`/`0-9` | The message names the first violation; IDs are uppercase-only by design |
| `IdPolicyMismatch` | The ID is well-formed but was not minted by this `IdGenerator` — wrong prefix, wrong body length, or a failed check character | Compare against the generator that issued it; a failed check character usually means a misread or typo |
| `InvalidIdConfig` | `IdGenerator::builder()` was given a bad prefix or entropy width | Prefix: 1–16 chars of `A-Z0-9`. Entropy: a multiple of 4, between 16 and 512 |
| `Entropy` | The OS randomness source is unavailable | The crate never falls back to a weaker source. Use `generate_from_entropy` with your own bytes |
| `InsufficientEntropy` | `generate_from_entropy` was handed too few bytes | Supply at least `generator.entropy_bytes()` |
| `Unencodable` | Code 128: the payload is outside Latin-1. QR: it does not fit the pinned version, or the version is not 1–40 | Use QR for non-Latin-1 payloads; raise the version or lower the ECC level |
| `EmptyPayload` | An empty string was passed to an encoder | Barcodes cannot encode nothing |
| `InvalidRenderOptions` | Non-positive DPI/width/height, or geometry over 20,000 px on an axis | The message names the computed size; reduce `module_width`, `height`, or `dpi` |
| `Render` | The PNG encoder failed | Rare; the message carries the underlying error |
| `Decode` | `Barcode::decode()` was called on a non-Code 128 symbol, or on an invalid pattern | There is no QR decoder in this crate |
| `Io` | A `*_to_file` helper could not write | Standard `std::io::Error`; only present with the `std` feature |

Two failure modes deserve naming because they produce a file that *looks*
correct and does not scan:

* **Quiet zones.** `QuietZone::None` and small `QuietZone::Modules(n)` values
are honoured exactly as asked. `QuietZone::Standard` is the only setting that
guarantees conformance.
* **Human-readable text.** The embedded font covers `0-9`, `A-Z`, space, and
`- . / * + $ % :`. Anything else prints as a hollow box. Check with
`hri_supports(text)` before it reaches a label, or disable HRI.

## Feature flags

| Feature | Default | Effect |
Expand Down Expand Up @@ -185,10 +215,16 @@ See [CHANGELOG.md](CHANGELOG.md).

```sh
cargo test --all-features
cargo clippy --all-features -- -D warnings
cargo run --example label
cargo clippy --all-features --all-targets -- -D warnings
cargo fmt --all --check
cargo run --example label # writes label*.png and label*.svg
```

[AGENTS.md](AGENTS.md) is the orientation guide for contributors and coding
agents: the repo map, the invariants a change must not break, where to add a new
symbology or renderer, and the release procedure. `CLAUDE.md` points at it.


## License

Licensed under either of [Apache License 2.0](LICENSE-APACHE) or
Expand Down
Loading