labelzoom 1.0.0

Official Rust client for the LabelZoom label conversion API.
Documentation
![LabelZoom Logo](../docs/LabelZoom_Logo_f_400px.png)

# LabelZoom Rust SDK

Official Rust client for the [LabelZoom API](https://api.labelzoom.com). Converts barcode labels
between ZPL, EPL, TSPL, DPL, PDF, LabelZoom XML/JSON, and raster images.

Rust 1.85+. Blocking, with a rustls-backed HTTP stack — no async runtime, and no OpenSSL headers
needed to build it anywhere.

## Install

```sh
cargo add labelzoom
```

```toml
labelzoom = "1.0"
```

<details>
<summary>Bring your own HTTP stack</summary>

```toml
labelzoom = { version = "1.0", default-features = false }
```

Drops `ureq`, `rustls` and `ring` entirely and leaves you to supply a
[`Transport`](#testing-your-own-code). CI builds this configuration on every run, so it stays a
real, working mode rather than an aspiration.
</details>

## Quick start

**An API key is optional.** Without one you get the free tier — watermarked output, first label
only, a 1 MB request cap, and no multi-page, JSON-target, or image-to-image conversion.

```rust
use labelzoom::{ConversionOptions, ConvertRequest, LabelZoomClient, SourceFormat, TargetFormat};

let client = LabelZoomClient::new();          // anonymous; this works

let request = ConvertRequest::new(
    SourceFormat::Zpl,
    TargetFormat::Png,
    "^XA^FO20,20^A0N,28^FDHello^FS^XZ",
)
.options(ConversionOptions::new().dpi(300).label_size(4.0, 6.0));

client.convert(&request)?.save("label.png")?;
```

With a credential:

```rust
let client = LabelZoomClient::builder().api_key("lz_live_...").build()?;
```

`LabelZoomClient::new()` reads `LABELZOOM_API_KEY` from the environment. `.api_key("")` — or the
more readable `.anonymous()` — forces the free tier and suppresses that fallback.

### Why a value struct and not a fluent chain

The .NET, Node, Java and PHP SDKs expose `client.convert().fromZpl(body).toPng().execute()`. A
chain that defers its errors to a terminal `send()` is as un-Rust as it is un-Go, and `Option<T>`
already expresses "not set" without one. The wire behaviour is identical — that is what the shared
conformance suite proves. See [API_CONTRACT.md §9](../docs/API_CONTRACT.md#9-divergences).

## Formats

**Sources (13):** `Zpl` `Epl` `Tspl` `Dpl` `Xml` `Json` `Pdf` `Png` `Bmp` `Gif` `Jpeg` `Jpg` `Url`

**Targets (11):** `Zpl` `Epl` `Tspl` `Dpl` `Xml` `Json` `Pdf` `Png` `Bmp` `Gif` `Jpeg`

`SourceFormat` and `TargetFormat` are separate enums. `SourceFormat::Jpg` is an input spelling
that normalizes to `jpeg` on the wire, and `SourceFormat::Url` tells the server to go fetch a
document rather than naming a format — so neither has a `TargetFormat` counterpart, and
`TargetFormat::Url` does not compile.

Both enums are `#[non_exhaustive]`, and that is not boilerplate: `Epl`, `Tspl` and `Dpl` became
*targets* in contract 1.1.0, when the printer-language writers shipped. Without it, every such
addition would be a breaking change for any downstream `match`.

The printer languages round-trip: `pdf`→`epl` and `zpl`→`tspl` are real conversions. Their output
is `text/plain`, but EPL's `GW` and TSPL's `BITMAP` commands inline raw binary, so read
`result.bytes` rather than `result.text()` whenever a label might carry graphics.

## Options

Every field of `ConversionOptions` is an `Option`, and **only the ones you set are sent** —
`#[serde(skip_serializing_if = "Option::is_none")]` is that rule expressed in the type system. The
SDK never substitutes a default of its own, so a change to a server default reaches you without a
crate upgrade.

```rust
ConversionOptions::new()
    .dpi(300)                                   // server default 203
    .rotation(90)                               // must be a multiple of 90
    .scaling(75.0)                              // percent; server default 100
    .color_mode(ColorMode::Grayscale)
    .darkness(60)                               // 0-100 luminance threshold
    .watermark(false)                           // an explicit false IS sent
    .label_size(4.0, 6.0)                       // INCHES
    .pdf_page(0)                                // 0-BASED
    .zpl_commands_to_ignore(["^PQ"])
```

Two units are routinely misread and are pinned by the shared fixtures:

- `label_size` is in **inches**, not dots. Leave it unset to have the server detect the size.
- `pdf_page` is **0-based**. Leave it unset to convert every page.

`data` is one record per output label; `ConversionOptions::extra` carries anything the crate does
not model yet, since unknown keys are ignored server-side.

## Errors

Rust has no inheritance, so rule E4's "every API error shares one base type" is a single enum
variant:

```rust
match client.convert(&request) {
    Ok(result) => { /* … */ }
    Err(Error::Api(e)) if e.is_paid_feature() => { /* the anonymous tier hit a paywall */ }
    Err(Error::Api(e)) => eprintln!("request {:?} failed with {}: {}", e.request_id, e.status, e.message),
    Err(Error::Validation(e)) => eprintln!("bad {}: {}", e.parameter, e.message),
    Err(Error::Transport(e)) => eprintln!("no response: {e}"),
}
```

`ApiError` carries the status, the message, the untruncated raw body, and the `X-LZ-Request-Id`
support handle; `ApiErrorKind` says which class it is, with the per-status data on the variant
that has it (`Forbidden { is_paid_feature }`, `RateLimited { retry_after_seconds }`).

`Error::Validation` is a **sibling** of `Error::Api`, not a member: it reports a request rejected
locally, before any network call, which is a bug in the calling code rather than a server response.

## Retries

429s, 5xx responses and transport failures are retried automatically — twice by default, for three
attempts — with a 1s/2s/4s backoff under full jitter. A `Retry-After` header is honoured on any
retryable status when it asks for longer than the backoff would wait. No other 4xx is ever retried.

```rust
let client = LabelZoomClient::builder().max_retries(0).build()?;   // disable retrying
```

## Testing your own code

The HTTP backend, the sleeper and the environment lookup are all injectable, so a test never opens
a socket, never sleeps, and never picks up a developer's real key:

```rust
use labelzoom::{HttpRequest, HttpResponse, Transport, TransportError};

struct Stub;

impl Transport for Stub {
    fn execute(&self, _request: HttpRequest) -> Result<HttpResponse, TransportError> {
        Ok(HttpResponse {
            status: 200,
            headers: vec![("content-type".into(), "text/plain".into())],
            body: b"^XA^XZ".to_vec(),
        })
    }
}

let client = LabelZoomClient::builder()
    .transport(Arc::new(Stub))
    .sleeper(Arc::new(RecordingSleeper::default()))
    .jitter(false)
    .env_lookup(|_| None)
    .build()?;
```

`Transport` is part of the crate's design rather than a test-only afterthought — it is also how
`default-features = false` works.

## Development

```sh
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo build --no-default-features     # proves the Transport seam is real
```

The test suite is the shared conformance fixtures in [`../conformance/`](../conformance/) — the
same 83 cases the .NET, Node, Java, Python, PHP, Go and Ruby suites run — plus an assertion that it
executed every one of them. `conformance/skips/rust.json` is empty: Rust compiles, so the two
`typecheck/*` cases are run for real, by building a snippet from `tests/typecheck/snippets/` in a
throwaway crate and asserting the compiler rejects it with the expected error code.

`cargo run --example smoke` does one anonymous conversion against the live API. It is not part of
the test suite, which is offline by design.

## License

MIT — see [LICENSE](LICENSE).