rusty-pv 0.2.0

Pipe viewer — a Rust port of Andrew Wood's `pv(1)` with progress bar, ETA, rate display, token-bucket rate limiting, IEC/SI unit math, SIGWINCH-aware terminal redraw, SIGUSR1 size refresh, multi-instance cursor coordination, and a typed library API.
Documentation
# rusty-pv — v0.2.0 Feature Layout

**Status**: implementation draft for the v0.2.0 Cargo features convention
backfill (spec 00011, Phase 9 — rusty-pv).

**Authority**:
- `specs/adrs/0006-cargo-features-convention-for-portfolio-ports.md` (why)
- `project-instructions.md` §Cargo Feature Surface (what)
- This document — the per-port carving + WHY for each leaf, per HINT-003
  + HINT-009 of spec 00011.

**Reference port**: rusty-figlet v0.2.0 — see `../../rusty-figlet/docs/feature-layout.md`
(FROZEN reference port) for the format anchor. rusty-pv conforms to the
same shape with the minimum-convention surface dictated by its
single-capability scope. The companion sibling ports rusty-sponge v0.2.0,
rusty-vipe v0.2.0, rusty-pee v0.2.0, rusty-pwgen v0.2.0, and rusty-detox
v0.2.0 (see `../../rusty-sponge/docs/feature-layout.md`,
`../../rusty-vipe/docs/feature-layout.md`,
`../../rusty-pee/docs/feature-layout.md`,
`../../rusty-pwgen/docs/feature-layout.md`, and
`../../rusty-detox/docs/feature-layout.md`)
established the zero-leaf precedent for single-capability ports.

**Iteration model**: v0.2.0 is a **purely additive** SemVer-minor release.
Every v0.1.x feature name and composition is preserved verbatim; new
umbrellas (`full`, `pv-classic`, `pv-minimal`) are layered on top
without renaming or narrowing the existing `cli` / `default` features.
Library and binary API surfaces are unchanged.

## Single-capability port — spec 00011 §Scope Edge Cases

rusty-pv is a **single-capability port**: it has exactly one documented
capability — show progress / throughput / ETA / rate while data flows
through a pipe (a Rust port of Andrew Wood's `pv(1)` v1.10.5). Spec 00011
§Scope Edge Cases dictates that single-capability ports apply the
**minimum convention**:

> ports with only one capability adopt the minimum convention:
> `full = ["cli"]` and `<port>-classic = ["cli"]` are the required
> umbrellas; ZERO leaves carved beyond those required umbrellas.

This document records the carving exercise and the explicit decision
to NOT split orthogonal sub-capabilities into leaves — every additional
behavior of `rusty-pv` (the display switches `-p` / `-t` / `-e` / `-I` /
`-r` / `-b` / `-a` / `-n` / `-q`; the data-path modifiers `-L` / `-B` /
`-E` / `-s`; output modifiers `-W` / `-D` / `-i` / `-N` / `-f` / `-w` /
`-H`; count modes `-l` / `-0`; cursor coordination `-c`; SIGUSR1 size
refresh; SIGWINCH-aware redraw; IEC/SI unit math; multi-file streaming;
Strict-mode argv pre-scanner; `completions` subcommand) is part of the
single core capability surface and removing any of them would break the
documented public CLI / library contract or change byte-output for
upstream-pinned v1.10.5 EMA smoothing (α = 0.3 is locked at v0.1.0 per
the lockstep-SemVer policy).

## Source-tree walk

`src/` modules (v0.1.0, post-Phase-1 baseline):

| Module        | Always-on?   | CLI-only deps                          | Notes                                                                          |
|---------------|--------------:|----------------------------------------|--------------------------------------------------------------------------------|
| `error.rs`    | yes          | (thiserror — always-on)                | `PvError` enum; library + binary need it.                                      |
| `ema.rs`      | yes          | none                                   | EMA smoothing (α = 0.3 locked).                                                |
| `throttle.rs` | yes          | none                                   | Token-bucket rate limiter (`-L`).                                              |
| `units.rs`    | yes          | none                                   | IEC/SI unit parsing + formatting.                                              |
| `lib.rs`      | yes          | none                                   | Public API (`Pv`, `PvBuilder`, `Reporter`, `Progress`, `PvError`, ...).        |
| `cursor.rs`   | no — `cli`   | fd-lock                                | Per-tty file lock for `-c` cursor coordination.                                |
| `signals.rs`  | no — `cli` + `cfg(unix)` | signal-hook                | SIGUSR1 / SIGWINCH atomic flag plumbing (Unix-only).                           |
| `cli.rs`      | no — `cli`   | clap                                   | clap-derive `Cli` struct + `Subcommand::Completions`.                          |
| `mode.rs`     | no — `cli`   | none (gated by `cli`)                  | CompatibilityMode resolver (`--strict` > env > argv[0]).                       |
| `strict.rs`   | no — `cli`   | (clap pulled by `cli`)                 | Hand-rolled Strict-mode argv pre-scanner.                                      |
| `main.rs`     | no — `cli`   | clap, clap_complete, anyhow, crossterm, signal-hook, fd-lock | Binary entry; gated by `required-features = ["cli"]`. |

## Leaf-carving criteria (HINT-009)

A capability becomes a leaf when ALL of the following hold:

1. It is **self-containable** — gated cleanly via `#[cfg(feature = "<leaf>")]`
   at the module or top-level item boundary (HINT-004).
2. Either (a) it has a **sole optional dependency** that no other leaf needs
   (HINT-005), OR (b) it is a pure-cfg-gate of an internal module worth
   exposing as a knob.
3. Disabling it does NOT break any always-on library/CLI surface.

A capability does NOT become a leaf when:

- It is foundational (the throttle, EMA, units modules; the
  `Pv` / `PvBuilder` / `Reporter` trait; the `Progress` snapshot
  struct — each is part of the always-on library surface).
- It is part of the single documented capability surface (display
  switches, output modifiers, data-path modifiers, count modes, cursor
  coordination, signal handling, Strict-mode dispatcher, completions
  subcommand, multi-file streaming).
- It would create more than ~6 leaves (FR-007 + HINT-003 envelope).

## v0.2.0 Carved Leaves

**ZERO new leaves carved at v0.2.0**. Every capability inside rusty-pv
is either:

1. Foundational always-on library code (EMA smoothing, token-bucket
   throttle, IEC/SI unit math, `PvBuilder` / `Pv` / `Reporter` trait,
   `Progress` snapshot, `PvError` types) — cannot be stripped without
   breaking the public library surface.
2. Already gated by the v0.1.x `cli` umbrella (clap-derived argument
   parsing, completions subcommand, mode resolver, Strict-mode argv
   pre-scanner, cursor coordination, signal handlers, stderr renderer).

### Leaves intentionally NOT carved

The following candidate leaves were considered + rejected:

- **`meter-progress` / `meter-eta` / `meter-rate` / `meter-bytes` /
  `meter-timer` / `meter-numeric`**: Each display switch is a per-flag
  toggle of the `DisplayMask` inside the binary's stderr renderer.
  They are NOT orthogonal capabilities — they share the same renderer,
  the same `Progress` snapshot, the same `UnitSystem`, and the same
  fixed visual order `[<name>:] <p> <t> <e/I> <r> <b> <a> <n>` (locked
  at v0.1.0 per the lockstep-SemVer stability policy in `lib.rs`).
  Carving them as leaves would create false orthogonality: a build
  with `meter-progress` but not `meter-bytes` would render an unusable
  partial line whose absent fields produce no observable byte savings
  (the format strings are inlined in the renderer). The single
  documented capability is "show pv-style progress" — splitting the
  display switches into leaves would be a feature shape with no
  user-visible win. Rejected per HINT-009 criteria 1 + 3.

- **`throttle` (rate-limit `-L`)**: Foundational library code — the
  `throttle::TokenBucket` is part of the public library API surface
  (`Pv::rate_limit()`, `PvBuilder::rate_limit()`). Stripping it would
  break the lockstep library + binary API contract. Rejected per
  HINT-009 criterion 3.

- **`cursor` (multi-instance `-c`)**: The `cursor.rs` module IS already
  gated by the v0.1.x `cli` umbrella (it pulls `fd-lock` which is in
  the `cli` dep list). Carving it out separately would require
  splitting `fd-lock` away from `cli` and renaming the existing `cli`
  feature — both of which violate the additive-v0.2.0 contract.
  Library API surface does not expose cursor coordination — it is
  binary-only. The capability survives untouched inside the existing
  `cli` composition.

- **`signals-unix` (SIGUSR1 + SIGWINCH)**: The `signals.rs` module IS
  already double-gated by the v0.1.x `cli` umbrella + `#[cfg(unix)]`.
  It pulls `signal-hook` which is in the `cli` dep list. Carving it
  out separately would require splitting `signal-hook` away from
  `cli` and renaming — violates the additive-v0.2.0 contract. The
  Windows build already excludes this module via `cfg(unix)`, so
  no cross-platform asymmetry remains to gate via Cargo feature.

- **`strict-compat`**: rusty-pv's Strict mode dispatches inline in
  `main.rs` via `mode::resolve` and the hand-rolled getopt mirror in
  `src/strict.rs`. Both are gated by the `cli` umbrella in v0.1.x
  (since `--strict` flag detection consumes `clap`'s argv pre-scan
  output). Carving out a separate `strict-compat` leaf would require
  splitting `strict.rs` away from `cli.rs`, which is more refactoring
  than the additive v0.2.0 release allows. The capability survives
  untouched inside the existing `cli` composition. (Note: rusty-figlet
  carves `strict-compat` because its Strict-mode parser is dep-free
  hand-rolled getopt; rusty-pv's Strict dispatcher reuses `clap`'s
  `--help`/`--version` plumbing, so it cannot stand alone without
  `cli`.)

- **`completions`**: Could be carved as `["dep:clap_complete"]`, but per
  spec 00011 §Scope Edge Cases minimum-convention single-capability
  ports declare ZERO new leaves. `clap_complete` is bundled into the
  v0.1.x `cli` umbrella verbatim. Carving it would either rename `cli`
  (breaking SemVer additivity) or duplicate the surface.

- **`units-iec` vs `units-si`**: Both unit systems are foundational
  library code (`units::UnitSystem` is part of the public API). They
  share the same parse + format machinery; splitting would not save
  a single line of code or dep. The `--si` flag is a runtime knob,
  not a compile-time feature. Rejected per HINT-009 criterion 1.

## Preset bundles (FR-007 — 2 required for single-capability ports)

Per spec 00011 §Scope Edge Cases + FR-007, even single-capability ports
declare 2 preset bundles to give the keep-list workaround documentation
something concrete to point at.

### `pv-classic` (REQUIRED — bare port, 1:1 with upstream pv 1.10.5)

```toml
pv-classic = ["cli"]
```

- Includes `cli` so the binary exists.
- Single-capability port; the `cli` umbrella IS the bare-port surface.
- Use case: `cargo install rusty-pv --no-default-features --features pv-classic`
  for a pv 1.10.5 drop-in replacement (Strict mode is invoked via
  the `--strict` flag, `RUSTY_PV_STRICT` env var, or `pv` /
  `pv-alias` argv[0] auto-detect — none of these require additional
  features).

### `pv-minimal`

```toml
pv-minimal = ["cli"]
```

- Same composition as `pv-classic` (single-capability port has no
  smaller subset to carve).
- Use case: explicit "minimal CLI install" alias for users who prefer
  the `<port>-minimal` naming convention seen across other Rusty ports
  (figlet-minimal, ts-minimal, sponge-minimal, vipe-minimal,
  pee-minimal, pwgen-minimal, detox-minimal).
  Documented as an intentional semantic alias rather than a distinct
  composition.

## Cross-port glossary candidates

No leaves carved → no cross-port glossary contributions from rusty-pv
in this iteration. If a future minor release adds an orthogonal
capability (e.g., a `meter-html` leaf rendering progress as HTML for
embedded dashboards, or a `format-custom` leaf adding the deferred
`-F` custom-format-string surface, or a `peek-window` leaf adding the
deferred `-A` peek-window surface, or a `bits-rate` leaf adding the
deferred `--bits` rate-as-bits switch), the leaf would be a candidate
for promotion to `docs/feature-vocabulary.md` per FR-053.

## CI matrix shape (FR-010..FR-014)

Per plan §Per-Port v0.2.0 CI Matrix, scaled to a zero-leaf port:

- **Tier 1 — `test-default`**: full DDR-003 cross-compile matrix
  (5 targets). Post-v0.2.0 `default = ["full"]` and `full = ["cli"]`,
  so the kitchen-sink test resolves to the same set as v0.1.0
  `default = ["cli"]` — no regression in coverage.
- **Tier 2 — `test-no-default`**: Linux x86_64 only. `cargo test
  --no-default-features --lib` + dep-tree audit (SC-001 evidence).
- **Tier 3 — `test-<bundle>`**: one job per preset bundle. Linux only.
  - `test-pv-classic`
  - `test-pv-minimal`
- **Tier 4 — `check-leaf-<leaf>`**: SKIPPED. Zero leaves → no
  per-leaf compile-check jobs. A placeholder comment in `ci.yml`
  documents why this tier is empty.
- **Tier 5 — `lint-convention`**: single Linux job invoking the
  vendored `tools/feature-lint/run.sh` script.

Per FR-014, bundle/lint jobs are constrained to Linux x86_64.

## Vendored feature-lint

Per spec 00011 §Phase 2 iteration 6 precedent (rusty-figlet vendored
the lint script because the umbrella `jsh562/rustylib` is private and
cross-repo `actions/checkout` cannot reach it), rusty-pv vendors
`tools/feature-lint/{lint.sh,run.sh,README.md}` from the umbrella into
the port repo. The vendored copy is byte-equal to the umbrella source
of truth as of the freeze commit (post the dev-tooling-allowlist +
benches/tests-search + additive-CHANGELOG-support fixes from rusty-ts
v0.2.0 / E011 Phase 3 iteration 2, the path-sanitization fixes from
rusty-sponge v0.2.0 / E011 Phase 4, and the additional sibling-port
iterations through rusty-detox v0.2.0 / E011 Phase 8).

## Why no new leaves — explicit rationale

Spec 00011 §Scope Edge Cases anticipates this case verbatim:

> Some ports have only one orthogonal capability. Those ports adopt the
> minimum convention: `full = ["cli"]` and `<port>-classic = ["cli"]`
> as aliases; the convention SHAPE is consistent across the portfolio
> even when the per-port leaf carving yields zero leaves.

rusty-pv deliberately chooses the zero-leaf path because:

1. The EMA smoothing constant α = 0.3 and the fixed display visual order
   `[<name>:] <p> <t> <e/I> <r> <b> <a> <n>` are **locked at v0.1.0**
   per the lockstep-SemVer stability policy in `lib.rs`. Carving any
   of the display machinery (`meter-progress`, `meter-eta`, etc.)
   into opt-out leaves would let users silently break shell-script
   output reproducibility — a MAJOR-bump behavior change disguised
   as a feature flag.
2. The cost of carving a speculative leaf (cfg-gate scaffolding,
   per-leaf CI matrix entry, README/CHANGELOG row, glossary candidacy)
   outweighs the value when no orthogonal capability exists to gate.
3. The portfolio-wide convention shape (umbrella set, README "Cargo
   Features" section, lint compliance) is preserved verbatim — a
   downstream library consumer reading the README for rusty-pv
   gets the same one-glance feature matrix UX as one reading
   rusty-figlet, rusty-ts, rusty-sponge, rusty-vipe, rusty-pee,
   rusty-pwgen, or rusty-detox.
4. v0.2.0 is **purely additive**. Every v0.1.x feature is preserved
   verbatim; no SemVer break. Future minor releases can add leaves
   without breaking the v0.2.0 contract: a hypothetical
   `format-custom` v0.3.0 feature adding upstream `-F` would slot in
   as `format-custom = []` alongside the existing umbrellas with
   zero migration cost.