Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Battery Pack logo

What is a Battery Pack?

A battery pack is a curated set of crates arranged around a common theme. There’s one for building CLIs, one for error handling, one for setting up CI, one for embedded development, and more. You install cargo bp and then:

cargo bp ls               # search crates.io for available battery packs
cargo bp add cli          # add CLI libraries to your project
cargo bp add embedded     # pick your HAL, concurrency model, peripherals
cargo bp new cli          # scaffold a new project from a template

The key ideas:

  • You use the real crates directly. Battery packs don’t wrap or re-export anything. cargo bp add cli puts clap and dialoguer in your Cargo.toml — you use their APIs, their docs, their proc macros. A battery pack is just a list of recommendations.
  • Anybody can publish one. A battery pack is itself a crate on crates.io. If you have opinions about what crates people should use for some domain, you can package those opinions and share them.
  • You’re never locked in. You don’t depend on a battery pack at runtime. It’s purely a source of truth for cargo bp to read. If you don’t like one of its choices, swap it out — your code doesn’t know the difference.

What’s next

Getting Started

Install the CLI

cargo install cargo-bp

This gives you the cargo bp command.

Browse available battery packs

cargo bp ls

This searches crates.io for published battery packs and lists what’s available:

                                              Battery Packs

┌──────────────────────────────────────────────────────────────────────────────────────────┐
│> backend-service    0.1.1    Opinionated battery pack for resilient async backend services│
│  ci                 0.1.5    Battery pack for CI/CD workflows in Rust projects            │
│  cli                0.6.2    Battery pack for building CLI applications in Rust           │
│  error              0.6.4    Error handling done well — anyhow for apps, thiserror for …  │
│  logging            0.5.2    Battery pack for logging and tracing in Rust                 │
└──────────────────────────────────────────────────────────────────────────────────────────┘
↑↓/jk Navigate | Enter Select | q Quit

Inspect a battery pack

cargo bp show ci

This shows you what’s inside — curated crates, features, and templates. Use p to preview a template’s rendered output before committing to it.

ci-battery-pack 0.1.5

┌──────────────────────────────────────────────────────────────────────────────────────────┐
│Features:                                                                                 │
│  benchmarks → criterion                                                                  │
│  fuzzing → arbitrary, libfuzzer-sys                                                      │
│  xtask → xflags, xshell                                                                 │
│                                                                                          │
│Templates:                                                                                │
│  benchmarks - Criterion bench scaffold + Bencher CI                                      │
│  clippy-sarif - Clippy with GitHub PR annotations via SARIF                              │
│  full - Full CI setup with optional benchmarks, fuzzing, mdbook, spellcheck, and xtask   │
│  fuzzing - cargo-fuzz scaffold + CI workflows                                            │
│  mdbook - mdBook scaffold + GitHub Pages deployment                                      │
│  mutation-testing - Mutation testing with cargo-mutants                                   │
│  spellcheck - crate-ci/typos config + CI workflow                                        │
│  stress-test - nextest stress test workflow                                               │
│  trusted-publishing - release-plz with OIDC trusted publishing                           │
│  xtask - cargo-xtask scaffold with codegen --check                                       │
│                                                                                          │
│Actions:                                                                                  │
│> Open on crates.io                                                                       │
└──────────────────────────────────────────────────────────────────────────────────────────┘
↑↓/jk Navigate | Enter Open/Select | Esc/q Quit

Add crates from a battery pack

cargo bp add cli

This opens an interactive picker where you toggle the crates and features you want:

────────────────────────────────────────────────────────────────────────────────────────────
 ▼ Features: (pick any number)
 > [ ] ✦ config [etcetera]
   [ ] ✦ indicators [console, indicatif]
   [ ] ✦ search [ignore, regex]

 ▼ Dependencies: (11 items selected)
   [x] anstream (1.0.0)
   [x] anstyle (1.0.14)
   [x] anyhow (1)
   [x] clap (4, features: derive)
   [x] dialoguer (0.11)
   [x] human-panic (2.0.8)
   [ ] console (0.15)
   [ ] indicatif (0.17)
   …

 ▼ Actions: (pick any number)
   [ ] Add `simple` template — Minimal CLI with argument parsing
   [ ] Add `subcmds` template — CLI with subcommands
────────────────────────────────────────────────────────────────────────────────────────────
 cli-battery-pack v0.6.2  ↑↓/jk Navigate | Space Toggle | p Preview

When you confirm, the selected crates are added to your Cargo.toml:

[package.metadata.battery-pack]
cli-battery-pack = "0.6.2"

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
dialoguer = "0.11"
human-panic = "2.0.8"
# ... and so on

The [package.metadata.battery-pack] section records which battery packs you’ve installed. The actual crates are real entries in [dependencies] that you use directly.

Features and categories

Some battery packs group crates into features you can opt into:

cargo bp add cli -F indicators

Others use categories to present alternatives — “pick one HAL for your chip family” or “pick an allocator”. The interactive picker shows these as radio buttons (pick one) or checkboxes (pick any). See Our Battery Packs for examples.

Start a new project from a template

cargo bp new cli

You’ll be prompted for a project name and directory. The result is a ready-to-go Rust project with the battery pack’s recommended crates and structure already in place.

See Templates for template options, merge behavior, placeholders, and non-interactive mode.

Templates

Battery pack templates scaffold files into your project. There are two modes: merging into an existing project (cargo bp add -t) and creating a new project from scratch (cargo bp new).

Choosing and previewing templates

To see what templates a battery pack offers:

cargo bp show ci                  # lists templates in the detail view
cargo bp show ci -t spellcheck    # preview the rendered output
cargo bp show ci -t full -d fuzzing -d repo_owner=myorg  # preview with placeholder overrides

If a battery pack has multiple templates and you don’t pass -t, you’ll be prompted to pick one.

Template variables

Templates can define variables (called placeholders) that are prompted interactively. Use -d to set them from the command line:

cargo bp add ci -t fuzzing -d ci_platform=github -d repo_owner=myorg

Bare -d benchmarks implies =true for boolean placeholders.

Merging a template into an existing project

Some battery packs include small, single-purpose templates (spellcheck config, fuzzing scaffold, CI workflows) that you can merge into an existing project:

cargo bp add ci -t spellcheck
cargo bp add ci -t fuzzing -d ci_platform=github
cargo bp add ci -t trusted-publishing

New files are written directly. Existing files are handled based on type:

  • .toml files are merged: new deps and sections are added, existing ones are left alone.
  • .yml / .yaml files are merged: new top-level keys are added, existing ones are left alone.
  • Everything else prompts you to skip, overwrite, or view a diff.

Each prompt has a single-key shortcut shown in brackets (e.g., [a]ccept, [s]kip). Uppercase variants ([A]ccept all, [S]kip all) apply to all remaining files.

For TOML and YAML merges, you can also open the result in $EDITOR before accepting.

Flags

cargo bp add ci -t spellcheck --overwrite   # overwrite non-TOML/YAML files without prompting
cargo bp add ci -t spellcheck -N            # non-interactive: skip conflicts, auto-apply merges
cargo bp add ci -t spellcheck -N --overwrite # non-interactive + overwrite everything

TOML and YAML files are always merged, never overwritten, regardless of flags.

Notes

  • If your working tree has uncommitted changes, you’ll be warned before proceeding. In -N mode, this is an error (since you can’t be prompted to confirm) unless --overwrite is passed.
  • The project name for template variables comes from your Cargo.toml [package].name (or the directory name as fallback).
  • Some templates print follow-up instructions after the merge (e.g., “add mod errors; to your lib.rs”).
  • In the TUI, select a template in the detail view and press u to merge it.

Creating a new project from a template

Templates can also scaffold an entirely new project:

cargo bp new cli
cargo bp new cli --template subcmds
cargo bp new cli --name my-app -d description="My CLI tool"

You’ll be prompted for a project name (or pass --name). Template selection, previewing, and -d placeholders work the same as merging.

You can also create new projects from the TUI’s “New project” tab.

Our Battery Packs

Anyone can publish a battery pack to crates.io — see Creating a Battery Pack if you’d like to make your own.

The following battery packs are maintained by the battery-pack-rs organization:

PackDescription
backend-serviceOpinionated battery pack for resilient async backend services in Rust
ciBattery pack for CI/CD workflows in Rust projects
cliBattery pack for building CLI applications in Rust
embeddedOpinionated battery pack for embedded Rust — curates HALs, drivers, RTOSes, and no_std utilities from the awesome-embedded-rust ecosystem
errorError handling done well — anyhow for apps, thiserror for libraries
loggingBattery pack for logging and tracing in Rust

backend-service-battery-pack

Opinionated, curated dependencies, templates, and skills for building simple but resilient async backend services in Rust.

Generate a service

cargo bp new backend-service --template service

Add to an existing project

Pull the curated dependencies into a project you already have:

cargo bp add backend-service

Inspect it first

cargo bp show backend-service              # crates, features, and templates
cargo bp show backend-service -t service   # preview the rendered template

See the backend service skills for guidance on the observability, resilience, and performance choices these templates make.

Battery pack contents

Global Allocator (pick at most one)

Pick a high-performance allocator (or use the system default)

NameDescription
jemallocjemalloc (not available on MSVC)
mimalloc-allocmimalloc (works everywhere including MSVC)

HTTP Middleware Layers

Tower-HTTP middleware for your service

NameDescription
http-catch-panicConvert panics to 500 responses
Crates: tower, tower-http
http-on-early-dropHandle early client disconnects
Crates: tower, tower-http
http-request-idX-Request-Id propagation
Crates: tower, tower-http
http-timeoutRequest timeout enforcement
Crates: tower, tower-http
http-traceRequest/response tracing spans
Crates: tower, tower-http

Dependencies

NameDescription
anyhowFlexible concrete Error type built on std::error::Error
axumHTTP routing and request handling library that focuses on ergonomics and modularity
clapA simple to use, efficient, and full-featured Command Line Argument Parser
criterion
dial9-tokio-telemetry
failsafe
httpA set of types for representing HTTP requests and responses.
metriqueLibrary for generating wide event metrics
metrique-utilAdditional utilities for metrique
moka
reqwesthigher level HTTP client library
serdeA generic serialization/deserialization framework
serde_jsonA JSON serialization file format
thiserrorderive(Error)
tokioAn event-driven, non-blocking I/O platform for writing asynchronous I/O backed applications.
tower_governor
tracingApplication-level tracing for Rust.
tracing-appenderProvides utilities for file appenders and making non-blocking writers.
tracing-subscriberUtilities for implementing and composing tracing subscribers.

Templates

NameDescription
serviceResilient request-response service (axum or hyper) with observability, graceful shutdown, and a Redis or HTTP downstream

ci-battery-pack

A battery pack for GitHub Actions CI in Rust projects. Generates pinned workflows with the project’s MSRV.

Adding CI to an existing project

Each workflow or scaffold is available as a standalone template you can merge into your project with cargo bp add:

cargo bp add ci -t spellcheck
cargo bp add ci -t fuzzing -d ci_platform=github
cargo bp add ci -t security-scanning
cargo bp add ci -t dependency-policy
cargo bp add ci -t trusted-publishing

New files are written directly. For existing files, TOML and YAML are merged (new keys added, existing keys preserved), and other file types prompt you to skip, overwrite, or view a diff. See the templates docs for the full merge behavior and flags.

Available standalone templates: benchmarks, binary-release, clippy-sarif, dependency-policy, fuzzing, mdbook, mutation-testing, security-scanning, spellcheck, stress-test, trusted-publishing, xtask.

Preview any template before applying it:

cargo bp show ci -t fuzzing
cargo bp show ci -t full -d benchmarks -d fuzzing

Creating a new project

The full template scaffolds a complete project (Cargo.toml, src/lib.rs, README with badges) plus CI configuration:

cargo bp new ci --name my-project

Use -d all to enable every optional feature, or pass individual flags:

cargo bp new ci --name my-project -d benchmarks -d fuzzing -d spellcheck

What the full template generates

Core CI (GitHub Actions)

  • CI workflow: fmt, clippy, warnings check, docsrs check, build matrix (stable × nightly), feature powerset, MSRV, semver-checks, gate job
  • RustSec audit workflow
  • Dependency policy workflow
  • Dependabot config for Cargo and GitHub Actions updates

Template flags

FlagDefaultWhat it addsCurated deps
trusted_publishingtruerelease-plz with OIDC trusted publishing
dependency_policytruecargo-deny license, bans, and source policy
binary_releasefalseCross-platform binary builds for GitHub Releases + cargo-binstall
benchmarksfalseCriterion bench scaffold + Bencher regression detectioncriterion
fuzzingfalsecargo-fuzz scaffold + PR smoke test + nightly extended runlibfuzzer-sys, arbitrary
stress_testsfalsenextest stress test workflow
mdbookfalsemdBook scaffold + GitHub Pages deployment
spellcheckfalsetypos config + workflow
xtaskfalsecargo-xtask scaffold with codegen --checkxshell, xflags
mutation_testingfalsecargo-mutants mutation testing
cross_platformfalseTest suite on macOS and Windows
clippy_sariffalseClippy with GitHub PR annotations via SARIF

SHA pinning

All GitHub Actions are pinned to commit SHAs at generation time per GitHub’s security guidance. Use Dependabot to keep them up to date.

Setup

After generating your project, set ci-pass as the required status check in branch protection.

release-plz

  1. Configure trusted publishing on crates.io
  2. In repo settings → Actions → General, enable “Allow GitHub Actions to create and approve pull requests”

Without binary_release, GITHUB_TOKEN works fine and no further setup is needed.

If you enabled binary_release, you also need a PAT so the release event triggers the binary build:

  1. Create a fine-grained PAT with contents: write and pull-requests: write for your repo
  2. Add it as a RELEASE_PLZ_TOKEN repo secret

Alternatively, you can avoid the PAT by moving the binary build steps into the release workflow itself.

See release-plz docs for more.

Bencher (if benchmarks enabled)

  1. Create a project on Bencher
  2. Add BENCHER_API_TOKEN as a repo secret
  3. Add your project slug as a BENCHER_PROJECT repo variable

Clippy SARIF (if clippy_sarif enabled)

Uploads clippy results to GitHub Code Scanning, showing warnings as inline PR annotations. Works automatically on public repos. For private repos, enable Code Scanning at Settings → Security → Code security.

mdBook (if mdbook enabled)

Enable GitHub Pages in repo settings (Settings → Pages → Source: GitHub Actions).

Dependency policy (if dependency_policy enabled)

Review deny.toml before enforcing it; license policy is project-specific.

License

Licensed under either of:

at your option.

Battery pack contents

Documentation

NameDescription
mdbookmdBook scaffold + GitHub Pages deployment

Code Quality

Static analysis and testing tools

NameDescription
benchmarksCriterion bench scaffold + Bencher CI
clippy-sarifClippy with GitHub PR annotations via SARIF
dependency-policycargo-deny license, ban, and source checks
fuzzingcargo-fuzz scaffold + CI workflows
Crates: arbitrary, libfuzzer-sys
mutation-testingMutation testing with cargo-mutants
security-scanningRustSec audit workflow
spellcheckcrate-ci/typos config + CI workflow
stress-testnextest stress test workflow

Dependencies

NameDescription
xflags
xshell

Templates

NameDescription
benchmarksCriterion bench scaffold + Bencher CI
binary-releaseCross-platform binary builds for GitHub Releases + cargo-binstall
clippy-sarifClippy with GitHub PR annotations via SARIF
dependency-policycargo-deny license, ban, and source checks
fullFull CI setup with dependency policy and optional benchmarks, fuzzing, mdbook, spellcheck, and xtask
fuzzingcargo-fuzz scaffold + CI workflows
mdbookmdBook scaffold + GitHub Pages deployment
mutation-testingMutation testing with cargo-mutants
security-scanningRustSec audit workflow
spellcheckcrate-ci/typos config + CI workflow
stress-testnextest stress test workflow
trusted-publishingrelease-plz with OIDC trusted publishing
xtaskcargo-xtask scaffold with codegen –check

cli-battery-pack

A battery pack for building CLI applications in Rust.

Quick Start

cargo bp add cli

Want progress bars too?

cargo bp add cli -F indicators

License

Licensed under either of:

at your option.

Battery pack contents

User Input

Argument parsing and interactive prompts

NameDescription
configXDG/platform config directories (etcetera)

Terminal Output

Color, hyperlinks, and progress display

NameDescription
indicatorsProgress bars and spinners (indicatif + console)
Crates: console, indicatif

Dependencies

NameDescription
anstreamIO stream adapters for writing colored text that will gracefully degrade according to your terminal’s capabilities.
anstyleANSI text styling
anstyle-hyperlinkANSI escape code hyperlinks (OSC 8)
anyhowFlexible concrete Error type built on std::error::Error
clapA simple to use, efficient, and full-featured Command Line Argument Parser
colorchoice-clapClap mixin to override console colors
dialoguerA command line prompting library.
human-panicPanic messages for humans
ignore
regexAn implementation of regular expressions for Rust. This implementation uses finite automata and guarantees linear time matching on all inputs.
supports-hyperlinksDetects whether a terminal supports rendering hyperlinks.
wildGlob (wildcard) expanded command-line arguments on Windows

Dev dependencies

NameDescription
snapboxSnapshot testing toolbox

Templates

NameDescription
simpleMinimal CLI with argument parsing
subcmdsCLI with subcommands

embedded-battery-pack

A battery pack for embedded Rust — curates HALs, concurrency frameworks, drivers, and no_std utilities from the awesome-embedded-rust ecosystem.

Quick start

cargo bp new embedded

This scaffolds a project with your chosen HAL, concurrency model, and peripherals already wired up.

To add embedded crates to an existing project instead:

cargo bp add embedded -F stm32f4 -F embassy -F panic-probe -F defmt-logging

Learn more

This battery pack is inspired by and draws from the awesome-embedded-rust list. For more about the embedded Rust ecosystem and how to get involved, visit the Embedded Rust Working Group.

Battery pack contents

Concurrency Framework (pick at most one)

Pick your async/RTOS model (mutually exclusive)

NameDescription
embassyEmbassy — async/await runtime for embedded
Crates: embassy-executor, embassy-sync, embassy-time
rticRTIC — interrupt-driven real-time concurrency
Crates: cortex-m, cortex-m-rt, critical-section, rtic

Display & Graphics

Screens, LEDs, and 2D drawing

NameDescription
display-ssd1306SSD1306 OLED display (I2C/SPI, 128x64)
Crates: embedded-graphics, ssd1306
display-st7789ST7789 color LCD (SPI, used in PineTime)
Crates: embedded-graphics, st7789
embedded-graphics2D drawing library for any embedded display

Platform-agnostic peripheral/sensor drivers via embedded-hal

NameDescription
display-ssd1306SSD1306 OLED display (I2C/SPI, 128x64)
Crates: embedded-graphics, ssd1306
display-st7789ST7789 color LCD (SPI, used in PineTime)
Crates: embedded-graphics, st7789
sensor-bme280BME280 temperature/humidity/pressure (I2C/SPI)
sensor-lis3dhLIS3DH 3-axis accelerometer (I2C/SPI)
usb-deviceUSB device stack (CDC-ACM serial, HID)
Crates: usb-device, usbd-serial

Hardware Abstraction Layer (pick at most one)

Pick the HAL for your target chip family (mutually exclusive)

NameDescription
atsamdMicrochip SAMD (Cortex-M0+/M4, Adafruit boards)
Crates: atsamd-hal, cortex-m, cortex-m-rt, critical-section, embedded-hal
esp32ESP32 (Xtensa, WiFi + BT, via esp-hal no_std)
Crates: embedded-hal, esp-hal
esp32c3ESP32-C3 (RISC-V, WiFi + BLE, via esp-hal no_std)
Crates: embedded-hal, esp-hal
esp32s3ESP32-S3 (Xtensa, WiFi + BLE, via esp-hal no_std)
Crates: embedded-hal, esp-hal
nrf52832Nordic nRF52832 (Cortex-M4F, BLE)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, nrf52832-hal
nrf52840Nordic nRF52840 (Cortex-M4F, BLE + USB)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, nrf52840-hal
nrf9160Nordic nRF9160 (Cortex-M33, LTE-M/NB-IoT)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, nrf9160-hal
rp2040RP2040 (Dual Cortex-M0+, Raspberry Pi Pico)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, rp2040-hal
stm32f0STM32F0xx family (Cortex-M0)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f0xx-hal
stm32f1STM32F1xx family (Cortex-M3, e.g. Blue Pill)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f1xx-hal
stm32f3STM32F3xx family (Cortex-M4F)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f3xx-hal
stm32f4STM32F4xx family (Cortex-M4F, e.g. F4 Discovery)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f4xx-hal
stm32f7STM32F7xx family (Cortex-M7)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f7xx-hal
stm32h7STM32H7xx family (Cortex-M7, high-performance)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32h7xx-hal
stm32l0STM32L0xx family (ultra-low-power Cortex-M0+)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32l0xx-hal
stm32l4STM32L4xx family (low-power Cortex-M4F)
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32l4xx-hal

Logging & Debugging

Device logging and diagnostic tools

NameDescription
defmt-loggingdefmt — efficient deferred formatting for constrained devices
Crates: defmt, defmt-rtt
rtt-targetRTT (Real-Time Transfer) output channel

Networking

no_std networking stacks and protocols

NameDescription
smoltcp-stacksmoltcp — no_std TCP/IP stack

Panic Handler (pick at most one)

Choose how panics are surfaced on the device (mutually exclusive)

NameDescription
panic-haltHalt the processor on panic
Crates: defmt, defmt-rtt, panic-halt
panic-probeLog panic via probe-rs debugger
Crates: defmt, defmt-rtt, panic-probe
panic-rttLog panic via RTT (SEGGER/probe-rs)
Crates: defmt, defmt-rtt, panic-rtt-target
panic-semihostingPrint panic via semihosting (Cortex-M only)

Portable Ecosystem

Trait abstractions and utilities that work across any HAL

NameDescription
critical-section-implcritical-section — cross-platform mutex primitive
heapless-allocStatic-friendly Vec, String, and data structures (no heap)
embedded-halTrait abstractions for embedded I/O (v1.0)
embedded-ioRead/Write traits for embedded byte streams
embedded-storageTraits for NOR flash and other storage

Storage & Memory

Flash, EEPROM, and filesystem support

NameDescription
embedded-sdmmcSD/MMC card with FAT16/FAT32 filesystem
spi-flashGeneric SPI NOR flash driver

Dev Tools

Testing, flashing, and development utilities

NameDescription
embedded-hal-mockMock embedded-hal traits for host-side testing
embedded-testOn-device test harness (unit + integration tests)

Dependencies

Templates

NameDescription
blinkyMinimal blinky LED example for your chosen HAL + concurrency model

error-battery-pack

Error handling done well. A battery pack that curates the essential error handling crates for Rust.

Quick Start

cargo bp add error

This adds anyhow and thiserror to your [dependencies] and sets up build-time validation.

When to Use Which

  • anyhow — Use in application code (binaries, CLI tools, servers) where you want to propagate errors with context and don’t need callers to match on specific variants.
  • thiserror — Use in library code where callers need to inspect and match on specific error variants.

They compose naturally: library functions return Result<T, MyError> (thiserror), and application code wraps them with anyhow::Result<T> adding .context().

Examples

Run the included examples to see the patterns in action:

# Basic anyhow usage with .context()
cargo run --example basic -p error-battery-pack

# Custom error types with thiserror + anyhow interop
cargo run --example custom-errors -p error-battery-pack

# Multi-layer error context chains
cargo run --example context-chain -p error-battery-pack

Acknowledgments

The skill files in skills/ are adapted from an error handling guide originally authored by @terminalwitchcraft.

License

Licensed under either of:

at your option.

Battery pack contents

NameDescription
anyhowFlexible concrete Error type built on std::error::Error
thiserrorderive(Error)

logging-battery-pack

A battery pack for logging and tracing in Rust.

Quick Start

cargo bp add logging

License

Licensed under either of:

at your option.

Battery pack contents

NameDescription
tracingApplication-level tracing for Rust.
tracing-subscriberUtilities for implementing and composing tracing subscribers.

Creating a Battery Pack

A battery pack is a normal Rust crate published on crates.io. It has no real code — just a Cargo.toml that curates dependencies, plus documentation and optionally templates.

Scaffolding

cargo bp new battery-pack --name my-battery-pack

This creates a battery pack project from the built-in template with the right structure, a starter README, and license files.

Dependencies are recommendations

The crates in your [dependencies] are what gets recommended to users. When someone runs cargo bp add my-pack, these crates are added to their Cargo.toml:

[dependencies]
anyhow = "1"
thiserror = "2"

[dev-dependencies]
expect-test = "1.5"

The section they live in determines the default dependency kind for users — [dev-dependencies] become dev-deps in the user’s crate, and so on.

Features are named groups

Use Cargo’s [features] to organize crates into toggleable groups:

[dependencies]
clap = { version = "4", features = ["derive"] }
dialoguer = "0.11"
indicatif = { version = "0.17", optional = true }
console = { version = "0.15", optional = true }

[features]
default = ["clap", "dialoguer"]
indicators = ["indicatif", "console"]

The default feature determines what a user gets with a plain cargo bp add. Crates marked optional = true are only installed when the user enables a feature that includes them (e.g., cargo bp add cli -F indicators).

If you don’t define a default feature, all non-optional crates are included by default.

A feature can also augment Cargo features on a crate using dep/feature syntax:

[features]
tokio-full = ["tokio/full"]

Auto-generated documentation

Every battery pack has a build.rs that generates documentation at compile time:

fn main() {
    battery_pack::build::generate_docs().unwrap();
}

This reads your Cargo.toml, README.md, and docs.handlebars.md, then renders them into a docs.md that becomes the crate’s docs.rs page. The default template is:

\{{readme}}

# Renamed dep: the version is resolved by the real crate name (`tokio`): tokio_rt = { package = "tokio", bp-managed = true }

Two keys conflict with bp-managed, since each already provides the version: version and workspace. The marker’s value must be truebp-managed = false (or any non-true value) is an error, so drop the key to opt out. Managed deps are also resolved under platform-gated [target.<cfg>.*] tables.

Validating templates

cargo bp validate automatically generates each template, runs cargo check and cargo test on the result, and reports failures. This catches broken templates before they reach users. {{crate-table}}


`{{readme}}` inlines your README. `{{crate-table}}` auto-generates a table of your curated crates grouped by category, split by dependency kind, with links to crates.io. You never need to maintain a crate list by hand — it's derived from your `Cargo.toml`.

The `src/lib.rs` just includes the generated output:

```rust
#![doc = include_str!(concat!(env!("OUT_DIR"), "/docs.md"))]

See Documentation and Examples for more on customizing the docs template and adding runnable examples.

Getting fancy with metadata

Battery packs support additional metadata in [package.metadata.battery-pack.*] for richer behavior:

  • Hidden Dependencies — hide internal crates (like battery-pack itself) from the user-facing picker and docs
  • Categories — group items thematically and express “pick at most one” constraints (e.g., choose one HAL, one allocator)
  • Templates — scaffold new projects or merge config files into existing ones

Hidden Dependencies

Some crates in your battery pack are internal tooling — not something users would want to install. Every battery pack should at minimum hide the battery-pack build dependency (used for doc generation):

[package.metadata.battery-pack]
hidden = ["battery-pack"]

Hidden crates don’t appear in the TUI picker, in cargo bp show output, or in the auto-generated docs.

Adding more hidden crates

Any internal plumbing crates should be hidden:

[package.metadata.battery-pack]
hidden = ["battery-pack", "bphelper-manifest", "snapbox"]

Globs

You can use glob patterns:

[package.metadata.battery-pack]
hidden = ["serde*"]

Hiding everything

If your battery pack is purely templates (no curated crates for users to pick), hide all dependencies:

[package.metadata.battery-pack]
hidden = ["*"]

Categories

Categories let you group related items and optionally constrain selection. Without categories, the picker shows a flat list of features and dependencies. With categories, items are grouped into labeled sections — and you can mark some as “pick at most one” to present alternatives.

Defining a category

Categories are declared in [package.metadata.battery-pack.categories.<name>]:

[package.metadata.battery-pack.categories.allocator]
title = "Global Allocator"
description = "Pick a high-performance allocator (or use the system default)"
pick = "at-most-one"

[package.metadata.battery-pack.categories.middleware]
title = "HTTP Middleware Layers"
description = "Tower-HTTP middleware for your service"

Fields:

  • title — display name shown in the picker and docs
  • description (optional) — explanatory text shown under the title
  • pick"at-most-one" or "any" (default: "any")

Assigning items to categories

Features, dependencies, and templates can be assigned to one or more categories using per-item metadata:

[package.metadata.battery-pack.features.jemalloc]
description = "jemalloc (not available on MSVC)"
categories = ["allocator"]

[package.metadata.battery-pack.features.mimalloc-alloc]
description = "mimalloc (works everywhere including MSVC)"
categories = ["allocator"]

[package.metadata.battery-pack.dependencies.embedded-hal]
description = "Trait abstractions for embedded I/O"
categories = ["portable"]

The description is shown next to the item in the picker and in the generated docs.

at-most-one vs any

Use at-most-one when alternatives are mutually exclusive:

  • Which HAL for your chip family (stm32f4 or nrf52840, never both)
  • Which async runtime (tokio or async-std)
  • Which allocator (jemalloc or mimalloc)

Use any (the default) for thematic groupings where users might want multiple items:

  • HTTP middleware layers (tracing and timeout and request-id)
  • Portable embedded utilities (embedded-hal and heapless and defmt)

How it looks

In the TUI, at-most-one categories render as radio buttons (select one deselects others). any categories render as checkboxes. In docs, categories become sections with tables.

On the command line, requesting two features from the same at-most-one category is an error:

cargo bp add embedded -F stm32f4 -F nrf52840
# error: features 'stm32f4' and 'nrf52840' are exclusive (category: hal)

Full example

[package.metadata.battery-pack.categories.hal]
title = "Hardware Abstraction Layer"
description = "Pick the HAL for your target chip family"
pick = "at-most-one"

[package.metadata.battery-pack.categories.rtos]
title = "Concurrency Framework"
description = "Pick your async/RTOS model"
pick = "at-most-one"

[package.metadata.battery-pack.categories.portable]
title = "Portable Ecosystem"
description = "Works with any HAL"

[package.metadata.battery-pack.features.stm32f4]
description = "STM32F4xx family (Cortex-M4F)"
categories = ["hal"]

[package.metadata.battery-pack.features.nrf52840]
description = "Nordic nRF52840 (Cortex-M4F, BLE + USB)"
categories = ["hal"]

[package.metadata.battery-pack.features.embassy]
description = "Embassy — async/await runtime for embedded"
categories = ["rtos"]

[package.metadata.battery-pack.dependencies.embedded-hal]
description = "Trait abstractions for embedded I/O (v1.0)"
categories = ["portable"]

Items not assigned to any category appear in generic “Dependencies” / “Dev dependencies” sections.

Templates

Templates let users scaffold new projects with cargo bp new or merge config files into existing projects with cargo bp add <pack> -t <name>.

Structure

A template lives in a subdirectory under templates/:

templates/
└── default/
    ├── bp-template.toml
    ├── _Cargo.toml
    └── src/
        └── main.rs

Note: Template Cargo.toml files must be named _Cargo.toml. cargo package treats any subdirectory containing a Cargo.toml as a separate crate and excludes it. The template engine maps _Cargo.toml back to Cargo.toml in the output.

Register templates in your Cargo.toml:

[package.metadata.battery.templates]
default = { path = "templates/default", description = "A basic starting point" }
subcmds = { path = "templates/subcmds", description = "Multi-command CLI" }

If you have multiple templates, users choose with --template:

cargo bp new my-pack --template subcmds

Placeholders

The bp-template.toml configures template variables using MiniJinja syntax:

[placeholders.description]
type = "string"
prompt = "What does this project do?"
default = "A new project"

Placeholder names must use snake_case (my_value, not my-value) because MiniJinja treats - as minus.

Types

# String (default)
[placeholders.description]
type = "string"
prompt = "Project description"
default = "A new project"

# Bool — yes/no prompt, defaults to false
[placeholders.benchmarks]
type = "bool"
prompt = "Include benchmarks?"

# Select — arrow-key selection, requires explicit default
[placeholders.ci_platform]
type = "select"
prompt = "CI platform"
options = ["github", "none"]
default = "github"

Bool values work naturally in templates: {% if benchmarks %}. On the command line, bare -d benchmarks implies =true.

Built-in variables

These are always available (no declaration needed):

  • {{ project_name }} — the project name from --name
  • {{ crate_name }} — derived from project_name with - replaced by _

Built-in functions

  • {{ pin_github_action("actions/checkout", "v6") }} — resolves a GitHub Action tag to a SHA-pinned reference at generation time
  • {{ rust_stable_version() }} — returns the current stable Rust version

Category-linked placeholders

A select placeholder can derive its options from a category instead of a hardcoded list:

[placeholders.allocator]
type = "select"
prompt = "Global allocator"
options.category = "allocator"
default = "jemalloc"

If the user already made a selection in the cargo bp add picker, this placeholder is pre-filled.

Managed dependencies

Use bp-managed = true in your template’s _Cargo.toml instead of hardcoding versions:

[dependencies]
clap.bp-managed = true

[build-dependencies]
cli-battery-pack.bp-managed = true

When someone generates a project, cargo bp resolves actual versions from your battery pack’s spec. You never need to update template files when you bump dependency versions.

You can override features or add keys alongside bp-managed:

# Managed version, explicit features:
clap = { bp-managed = true, features = ["derive", "env"] }

# Managed version with optional:
serde = { bp-managed = true, optional = true }

Merge-friendly templates

Templates applied to existing projects with cargo bp add <pack> -t <name> handle file conflicts by type:

  • Cargo.toml — dependencies merged (versions upgraded if behind, features unioned)
  • Other .toml — new sections/keys added, existing ones left alone
  • .yml / .yaml — top-level keys merged; jobs, on, permissions deep-merged
  • Everything else — user prompted to skip or overwrite

Tips for merge-friendly templates:

  • Keep template Cargo.toml minimal — only what the template needs
  • Use bp-managed = true so versions stay current
  • Use unique filenames for workflows (e.g., typos.yml not ci.yml)

Hints

For steps that can’t be automated, declare hints:

[[hints]]
message = "Add `mod errors;` to your lib.rs or main.rs"

[[hints]]
message = "Run `cargo install cargo-fuzz` if you haven't already"

Hints are printed after the merge summary (only for cargo bp add -t, not cargo bp new).

Including files from outside the template

[[files]]
src = "LICENSE-MIT"       # relative to crate root
dest = "LICENSE-MIT"      # relative to generated project

Validating templates

cargo bp validate generates each template into a temp directory, runs cargo check and cargo test, and reports failures. Add this to your CI:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn validate() {
        ::battery_pack::testing::validate(env!("CARGO_MANIFEST_DIR")).unwrap();
    }
}
}

Placeholders should have default values so validation can generate templates non-interactively.

Documentation and Examples

A battery pack’s documentation shows up in two places: on crates.io (from README.md) and on docs.rs (from the auto-generated lib docs). The doc generation system lets you write prose naturally while getting an auto-generated crate catalog for free.

How it works

The documentation pipeline has three pieces:

  1. README.md — your hand-written prose, displayed on crates.io
  2. docs.handlebars.md — a template that controls what appears on docs.rs
  3. build.rs — renders the template into docs.md at build time

The generated docs.md is included by lib.rs:

#![allow(unused)]
#![doc = include_str!(concat!(env!("OUT_DIR"), "/docs.md"))]
fn main() {
}

Writing your README

Write a normal README.md. It should explain what your battery pack provides, when to use which crates, and any guidance that helps users get started.

For example, error-battery-pack’s README might say:

# error-battery-pack

Error handling done well — anyhow for apps, thiserror for libraries.

## When to use what

- **anyhow** — Use in application code where you want easy error
  propagation with context. Great for `main()`, CLI handlers,
  and integration tests.

- **thiserror** — Use in library code where you want to define
  structured error types that callers can match on.

The handlebars template

The docs.handlebars.md file is a Handlebars template that controls what goes into the docs.rs documentation. The default template shipped by cargo bp new looks like:

{{readme}}

{{crate-table}}
  • {{readme}} — includes the contents of your README.md
  • {{crate-table}} — renders an auto-generated table of all curated crates

The crate table

The {{crate-table}} helper generates a table listing each crate in the battery pack with its version, description, and a link to crates.io. The descriptions are pulled automatically from crate metadata (via cargo metadata), so you don’t have to maintain them by hand.

When the battery-pack crate updates, the table’s formatting improves automatically for all battery packs that use {{crate-table}}.

Custom templates

If you want full control, replace {{crate-table}} with your own Handlebars markup. The same metadata is available in structured form:

{{readme}}

## Curated Crates

| Crate | Version | Description |
|-------|---------|-------------|
{{#each crates}}
| [{{name}}](https://crates.io/crates/{{name}}) | {{version}} | {{description}} |
{{/each}}

{{#if features}}
## Features

{{#each features}}
### `{{name}}`
{{#each crates}}
- {{this}}
{{/each}}
{{/each}}
{{/if}}

The available template variables include:

  • crates — array of { name, version, description, features, dep_kind }
  • features — array of { name, crates } from [features]
  • readme — the contents of README.md
  • package{ name, version, description, repository }

Writing examples

Examples are standard Cargo examples in the examples/ directory. They serve two purposes: showing users how to use the curated crates together, and appearing in the battery pack’s listing (in the TUI and cargo bp show).

Good examples:

  • Are self-contained and runnable
  • Show the crates working together (not just one crate in isolation)
  • Cover common use cases for the battery pack’s domain
// examples/basic.rs
use anyhow::{Context, Result};
use thiserror::Error;

#[derive(Error, Debug)]
#[error("config error: {0}")]
struct ConfigError(String);

fn main() -> Result<()> {
    let path = "config.toml";
    let _content = std::fs::read_to_string(path)
        .context("reading config file")?;
    Ok(())
}

Examples listed in the TUI link to the source on GitHub (when a repository URL is provided in Cargo.toml).

Publishing

Battery packs are published to crates.io like any other Rust crate. A few things to keep in mind to make yours discoverable and useful.

Keywords

Include battery-pack as a keyword in your Cargo.toml so cargo bp list can find it:

[package]
name = "error-battery-pack"
keywords = ["battery-pack", "error-handling", "anyhow", "thiserror"]

The battery-pack keyword is what cargo bp uses to discover packs on crates.io. Add other keywords relevant to your domain.

Naming

Battery packs conventionally end in -battery-pack:

  • error-battery-pack
  • cli-battery-pack
  • async-battery-pack
  • web-battery-pack

The cargo bp CLI resolves short names automatically — cargo bp add cli looks up cli-battery-pack. If you name your crate my-cool-battery-pack, users can just type cargo bp add my-cool.

Versioning

Follow semver, but think about what constitutes a breaking change for a battery pack:

  • Patch — updating a crate’s patch version, fixing docs or examples
  • Minor — adding new crates, adding new features (groups), adding new templates
  • Major — removing crates, bumping a crate’s major version, removing features

When you bump a curated crate’s version, users will see a warning in cargo bp status if their installed version is older. They can update with cargo bp sync.

Pre-publish checklist

  1. README.md describes the battery pack clearly
  2. Examples are runnable (cargo test --examples)
  3. Templates work (cargo bp new your-pack from a temp directory)
  4. Keywords include battery-pack
  5. License files are present (MIT and/or Apache-2.0 are conventional)
  6. Repository URL is set (for linking to examples and templates in the TUI)

Publishing

cargo publish

After publishing, your battery pack will appear in cargo bp list within a few minutes (once the crates.io index updates).

Battery pack reference

Contributing to Battery Pack

This section covers how to contribute to the cargo-bp tool itself.

If you’re looking to create your own battery pack crate, see Creating a Battery Pack instead.

Development setup

Clone the repository and build:

git clone https://github.com/battery-pack-rs/battery-pack.git
cd battery-pack
cargo build --workspace

Run the test suite:

cargo test --all --workspace

Repository structure

  • src/battery-pack/ — the cargo-bp CLI and its helper crates
  • battery-packs/ — first-party battery packs (cli, error, ci, etc.)
  • md/ — this documentation (built with mdbook)
  • md/spec/ — the formal specification
  • md/rfds/ — design documents (Requests for Discussion)

Guides

Testing ratatui apps: a comprehensive guide

Ratatui provides a surprisingly rich testing surface — from in-memory Buffer assertions and TestBackend integration to snapshot testing with insta and PTY-based end-to-end harnesses. The key insight across the ecosystem is that testability flows directly from architecture: apps that separate state from rendering, use message/action enums, and treat view functions as pure mappings become trivially testable at every layer. This report covers practical techniques, code patterns, ecosystem crates, and real-world examples drawn from ratatui’s official documentation, popular open-source projects, and community resources.

Widget unit tests work best against raw Buffer

Ratatui’s own documentation is explicit: “It is preferable to write unit tests for widgets directly against the buffer rather than using TestBackend.” The TestBackend wraps a Terminal with double-buffering and diffing overhead that unit tests don’t need. Instead, render widgets directly into a Buffer::empty() and compare with Buffer::with_lines().

#![allow(unused)]
fn main() {
#[test]
fn test_my_widget_renders_correctly() {
    let widget = MyWidget { title: "Hello", count: 42 };
    let area = Rect::new(0, 0, 30, 3);
    let mut buf = Buffer::empty(area);

    widget.render(area, &mut buf);

    let expected = Buffer::with_lines(vec![
        "╭Hello─────────────────────╮",
        "│ Count: 42                │",
        "╰──────────────────────────╯",
    ]);
    assert_eq!(buf, expected);
}
}

For style-aware assertions, construct an expected Buffer and apply styles to specific regions. This is the only way to test colors and formatting without serialization:

#![allow(unused)]
fn main() {
let mut expected = Buffer::with_lines(vec!["Value: 42"]);
expected.set_style(Rect::new(0, 0, 6, 1), Style::new().bold());
expected.set_style(Rect::new(7, 0, 2, 1), Style::new().yellow());
assert_eq!(buf, expected);
}

For stateful widgets (those implementing StatefulWidget), pass mutable state alongside the buffer:

#![allow(unused)]
fn main() {
let mut state = ListState::default().with_selected(Some(1));
let list = List::new(["Item A", "Item B", "Item C"]);
list.render(area, &mut buf, &mut state);
}

Event handler testing follows the same direct-invocation philosophy. The official Counter App tutorial demonstrates extracting handle_key_event as a method that takes a KeyEvent and mutates state — no terminal required:

#![allow(unused)]
fn main() {
#[test]
fn handle_key_event() {
    let mut app = App::default();
    app.handle_key_event(KeyCode::Right.into());
    assert_eq!(app.counter, 1);

    app.handle_key_event(KeyCode::Char('q').into());
    assert!(app.exit);
}
}

Snapshot testing with insta catches visual regressions

Ratatui’s official recipes recommend the insta crate for snapshot testing. The approach exploits TestBackend’s Display implementation, which renders the buffer as a text grid:

#![allow(unused)]
fn main() {
#[test]
fn test_app_snapshot() {
    let backend = TestBackend::new(80, 20);
    let mut terminal = Terminal::new(backend).unwrap();
    let app = App::default();

    terminal.draw(|frame| app.render(frame)).unwrap();
    insta::assert_snapshot!(terminal.backend());
}
}

On first run, insta creates a .snap file in a snapshots/ directory. Subsequent runs compare output against the stored snapshot. Use cargo insta review for interactive diff review or cargo insta accept to update. In CI, cargo test fails if snapshots diverge from committed versions.

One critical limitation: the Display implementation renders only character content, not styles or colors. GitHub issue #1402 tracks adding color-aware snapshot support, with a PR (#2266) in progress. For style-aware snapshots today, serialize the full Buffer via serde (ratatui’s Buffer implements Serialize):

#![allow(unused)]
fn main() {
// Captures every cell's symbol, fg, bg, underline_color, and modifiers
insta::assert_json_snapshot!(terminal.backend().buffer());
}

This produces verbose but complete output. Several alternative snapshot crates also work well:

  • expect-test stores expected output inline in source code, updated with UPDATE_EXPECT=1 cargo test
  • goldie compares against .golden files in a testdata/ directory, updated with GOLDIE_UPDATE=1 cargo test
  • goldenfile auto-compares on drop, updated with UPDATE_GOLDENFILES=1 cargo test

Best practice: always pin terminal dimensions (e.g., 80×20) to ensure reproducible snapshots across machines and CI environments.

TestBackend provides a full in-memory terminal

TestBackend is ratatui’s built-in backend for integration testing — it renders through the complete Terminal pipeline (double-buffering, diffing, cursor management) into an in-memory buffer. Key API surface as of v0.30.0:

#![allow(unused)]
fn main() {
// Construction
TestBackend::new(width, height)
TestBackend::with_lines(["line1", "line2"])  // pre-populated

// Buffer access
backend.buffer()      // &Buffer — the visible screen
backend.scrollback()  // &Buffer — scrollback history (v0.29+)

// Assertion methods (produce detailed diffs on failure)
backend.assert_buffer(&expected_buffer)
backend.assert_buffer_lines(["expected line 1", "expected line 2"])
backend.assert_scrollback(&expected)
backend.assert_scrollback_lines(["scrolled line"])
backend.assert_scrollback_empty()
backend.assert_cursor_position(Position { x: 5, y: 3 })
}

Notable evolution: the assert_buffer_eq! macro is deprecated — use standard assert_eq! instead. In v0.30.0, TestBackend::Error became core::convert::Infallible since in-memory operations never fail. The scrollback buffer (added in v0.29) enables testing Terminal::insert_before and scrolling behavior.

Use TestBackend for integration tests that exercise the full draw pipeline:

#![allow(unused)]
fn main() {
#[test]
fn test_full_app_renders() {
    let backend = TestBackend::new(40, 10);
    let mut terminal = Terminal::new(backend).unwrap();
    let mut app = App::new(test_data());

    terminal.draw(|frame| ui::render(frame, &mut app)).unwrap();

    terminal.backend().assert_buffer_lines([
        "╭Parameters──────────────────────|all|─╮",
        "│user.name                    system   │",
        "│vm.stat_interval             1        │",
        "╰──────────────────────────────────────╯",
    ]);
}
}

End-to-end testing spans from PTY harnesses to tmux automation

For testing beyond what TestBackend can reach — real escape sequence processing, TTY detection, terminal size negotiation, and graphics protocols — the ecosystem offers several approaches.

ratatui-testlib (by raibid-labs) is a purpose-built PTY-based integration testing framework with a five-layer architecture: PTY management (portable-pty), terminal emulation (vt100), test harness, snapshot integration, and ratatui helpers. It supports both sync and async workflows:

#![allow(unused)]
fn main() {
use terminal_testlib::{TuiTestHarness, KeyCode};

#[test]
fn test_navigation_flow() -> terminal_testlib::Result<()> {
    let mut harness = TuiTestHarness::new(80, 24)?;
    harness.spawn(CommandBuilder::new("./my-tui-app"))?;
    harness.wait_for_text("Main Menu")?;
    harness.send_key(KeyCode::Down)?;
    harness.send_key(KeyCode::Enter)?;
    harness.wait_for_text("Sub Menu")?;
    Ok(())
}
}

The crate includes a headless feature for CI environments without display servers. Note that it’s still at v0.1.0 and in early development.

For building custom harnesses, the component crates work independently:

  • portable-pty (part of WezTerm, 3M+ downloads) creates cross-platform pseudo-terminals. Spawn your TUI binary in a real PTY with configurable dimensions, then read raw output bytes from the master side.
  • vt100 parses those raw bytes into structured screen state with cell-level access including foreground/background colors, attributes, and cursor position. The screen().contents_diff(&old_screen) method enables incremental comparison.
  • tui-term bridges vt100 output into ratatui’s widget system, rendering parsed terminal state as a ratatui PseudoTerminal widget.

For scripted interaction testing, expectrl provides Rust-native expect-style automation:

#![allow(unused)]
fn main() {
let mut p = expectrl::spawn("./my-tui-app")?;
p.expect("Welcome")?;
p.send_line("q")?;
p.expect("Goodbye")?;
}

tmux-based testing works well for language-agnostic E2E tests. The Python library Hecate (by the author of Hypothesis) wraps tmux for TUI testing with await_text, press, and screenshot primitives. The Rust tmux_interface crate provides programmatic tmux control.

The ecosystem crate landscape at a glance

The Rust TUI testing ecosystem combines general-purpose terminal tooling with ratatui-specific utilities:

CratePurposeDownloadsKey testing use
instaSnapshot testingMillionsOfficial ratatui recommendation for visual regression
vt100VT100 terminal emulator~500KParse raw terminal output into structured screen state
portable-ptyCross-platform PTY~3MSpawn TUI apps in real pseudo-terminals
termwizTerminal emulation (WezTerm)~3MSurface with change tracking; ratatui has a termwiz backend
tui-termPTY widget for ratatui~500KBridge vt100 output into ratatui buffers
ratatui-testlibPTY test harnessNewPurpose-built E2E testing for ratatui apps
term-transcriptCLI snapshot testing~40KSVG-based terminal output snapshots
expectrlExpect-style automation~200KScripted interactive TUI testing
expect-testInline snapshots~1MExpected output stored in source code

termwiz deserves special attention: ratatui supports it as an optional backend (features = ["termwiz"]), rendering to termwiz’s Surface which tracks changes with richer attribute information than TestBackend. This could theoretically provide a testing path with full color/style fidelity.

Property-based and fuzz testing find edge cases in state and rendering

Property-based testing with proptest is particularly valuable for TUI apps because rendering must handle arbitrary state and terminal dimensions without panicking. Three high-value property categories:

Rendering never panics for any valid state:

#![allow(unused)]
fn main() {
proptest! {
    #[test]
    fn rendering_never_panics(
        counter in 0..=255u8,
        items in prop::collection::vec(".*", 0..100),
    ) {
        let app = App { counter, items, ..Default::default() };
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| app.draw(frame)).unwrap();
    }
}
}

Layout constraints hold across arbitrary dimensions:

#![allow(unused)]
fn main() {
proptest! {
    #[test]
    fn layout_stays_in_bounds(width in 1u16..=300, height in 1u16..=100) {
        let area = Rect::new(0, 0, width, height);
        let chunks = Layout::vertical([
            Constraint::Percentage(30),
            Constraint::Percentage(70),
        ]).split(area);
        for chunk in chunks.iter() {
            prop_assert!(chunk.right() <= area.right());
            prop_assert!(chunk.bottom() <= area.bottom());
        }
    }
}
}

Arbitrary input sequences never crash the event handler:

#![allow(unused)]
fn main() {
proptest! {
    #[test]
    fn key_sequences_never_panic(
        keys in prop::collection::vec(
            prop_oneof![
                Just(KeyCode::Left), Just(KeyCode::Right),
                Just(KeyCode::Enter), Just(KeyCode::Esc),
                (32u8..127).prop_map(|c| KeyCode::Char(c as char)),
            ], 0..200
        )
    ) {
        let mut app = App::default();
        for key in keys {
            app.handle_key_event(key.into());
        }
    }
}
}

For fuzz testing, cargo-fuzz with libFuzzer targets event processing and rendering. Define a FuzzInput struct deriving Arbitrary that contains terminal dimensions and event sequences, then exercise the full state→render pipeline. The test-fuzz crate (by Trail of Bits) can derive fuzz targets from existing unit tests automatically. For stateful property testing, proptest-stateful enables model-based testing where you define operations, preconditions, and state transitions against an abstract model.

Architecture determines testability

The most testable ratatui apps share a common foundation: strict separation of state from rendering. Three architectural patterns emerge from the ecosystem, each with distinct testing advantages.

The Elm Architecture (TEA) structures apps as three pure functions — Model (state), update(model, message) → model (transitions), and view(model) → frame (rendering). The update function is a pure function testable with simple assert_eq! on model state. The view function maps deterministically from state to UI, testable via Buffer assertions. Several crates implement TEA for ratatui: tears, ratatui-elm, and tui-realm.

The Component/Action pattern (from ratatui’s official template) introduces a Component trait with handle_key_event() → Option<Action>, update(action) → Option<Action>, and render(frame, rect). Actions are reified method calls — an enum that’s serializable, loggable, and replayable. Testing becomes: construct component, send KeyEvent, assert returned Action. Components communicate via channels rather than direct coupling.

#![allow(unused)]
fn main() {
// Testing a component in isolation
let mut comp = MyComponent::new();
let action = comp.handle_key_event(KeyCode::Char('j').into())?;
assert_eq!(action, Some(Action::SelectNext));

let action = comp.update(Action::SelectNext)?;
assert_eq!(comp.selected_index(), 1);
}

The fundamental pattern underlying all of these is a three-file split:

  • app.rs — pure state struct with methods, zero rendering imports
  • ui.rs — pure rendering functions taking &App and &mut Frame, zero state mutation
  • main.rs — event loop gluing state updates to rendering

This yields three independent test targets: state logic (unit tests with assert_eq!), rendering (buffer assertions with TestBackend), and integration (full event→update→render cycle).

gitui (~21.5k stars) recently adopted snapshot testing via insta + TestBackend in a December 2025 PR. The maintainer noted: “I found it way easier to create the test than I had anticipated, mostly because the application is already structured in a way that is very amenable to snapshot testing.” gitui’s architecture — an App struct with a Queue for inter-component message passing, a clear draw() separation from state — proved immediately testable. The git operations layer (asyncgit/) has extensive unit tests covering pure logic independently of the TUI. Events are sent programmatically in tests, initially with sleep-based timing that was later refactored to event-based waiting.

bottom (system monitor) maintains 42–54% test coverage tracked via Codecov with per-platform flags across Linux, macOS, and Windows. Tests focus heavily on data processing, configuration parsing, and conversion logic rather than UI rendering. The clean separation between data_harvester/ (collection) and widgets/ (rendering) makes the data layer independently testable.

systeroid (by ratatui maintainer orhun) demonstrates the canonical TestBackend assertion pattern — rendering to a TestBackend, then comparing against Buffer::with_lines() with styled regions. This project’s test code appears repeatedly in ratatui’s official documentation as the exemplary pattern.

spotify-tui (archived ~2022, never migrated from tui-rs) had limited test coverage focused on mocking the Spotify API client rather than testing UI rendering — a cautionary example of what happens when testing strategy isn’t established early.

Conclusion

Ratatui’s testing story is more mature than many developers realize. The Buffer-first approach for widget unit tests — rendering directly into Buffer::empty() and comparing with Buffer::with_lines() — is fast, deterministic, and style-aware. TestBackend handles integration tests through the full Terminal pipeline. Snapshot testing with insta provides effortless regression detection, though color-aware snapshots remain the most significant gap (tracked in issue #1402).

The most impactful testing decision isn’t tooling — it’s architecture. The TEA and Component/Action patterns make every layer independently testable by construction. Property-based testing with proptest catches an entire class of edge cases that handwritten tests miss, particularly around arbitrary terminal dimensions and input sequences. For the rare cases requiring real terminal behavior, the portable-pty + vt100 combination provides a robust PTY-based harness, with ratatui-testlib emerging as a dedicated framework.

A pragmatic testing pyramid for ratatui apps: heavy unit tests on state logic and individual widgets (fast, deterministic), moderate snapshot coverage of full-screen layouts (catches regressions), selective property tests on rendering and input handling (finds edge cases), and minimal PTY-based E2E tests for terminal-specific behavior (slow but realistic).

Specification

Battery Pack Format

This section specifies the structure of a battery pack crate.

Crate structure

r[format.crate.name] A battery pack crate’s name MUST end in -battery-pack (e.g., error-battery-pack, cli-battery-pack).

r[format.crate.keyword] A battery pack crate MUST include battery-pack in its keywords so that cargo bp list can discover it via the crates.io API.

r[format.crate.lib] A battery pack crate’s lib.rs SHOULD contain only a doc include directive. There is no functional code in a battery pack.

r[format.crate.no-code] A battery pack crate MUST NOT contain functional Rust code (beyond the doc include and build.rs for doc generation). It exists purely as a metadata and documentation vehicle.

r[format.crate.repository] A battery pack crate SHOULD set the repository field in its [package] section. The repository URL is used to link to examples and templates in cargo bp show and the TUI. cargo bp validate MUST warn if the repository URL is not set.

Dependencies as curation

r[format.deps.source-of-truth] The battery pack’s dependency sections ([dependencies], [dev-dependencies], [build-dependencies]) are the source of truth for which crates the battery pack curates and their recommended versions and features.

r[format.deps.kind-mapping] The dependency section a crate appears in determines the default dependency kind for users. A [dependencies] entry defaults to a regular dependency, [dev-dependencies] to a dev-dependency, and [build-dependencies] to a build-dependency.

r[format.deps.version-features] Each dependency entry specifies the recommended version and Cargo features. These are used by cargo bp when adding the crate to a user’s project.

Features

r[format.features.grouping] Cargo [features] in the battery pack define named groups of crates. Each feature lists the crate names it includes.

r[format.features.optional-required] Any dependency listed in a [features] entry MUST be declared with optional = true in its dependency section. This is a Cargo requirement: feature names that match dependency names implicitly enable that dependency, which Cargo only allows for optional deps.

r[format.features.default] The default feature determines which crates are installed when a user runs cargo bp add <pack> without additional flags. If no default feature is defined, all non-optional crates are considered part of the default set.

r[format.features.dev-build-always] [dev-dependencies] and [build-dependencies] are always included regardless of feature selection.

r[format.features.optional] Crates marked optional = true in the dependency section are not part of the default installation. They are available through named features or individual selection.

r[format.features.additive] Features are additive. Enabling a feature adds its crates on top of whatever is already enabled. Features never remove crates.

r[format.features.augment] A feature MAY augment the Cargo features of a crate that is already included via another feature or the default set. Augmentation uses Cargo’s native dep/feature syntax in [features] (e.g., tokio-full = ["tokio/full"]). No custom metadata is required. When augmenting, the specified Cargo features are unioned with the existing set.

Categories

Categories group related items (features, dependencies, or templates) so that cargo bp add can present them together, optionally as a set of mutually exclusive alternatives.

r[format.categories.definition] A [package.metadata.battery-pack.categories.<name>] table declares a category. It MAY contain a title (display name in the picker header), a description (explanatory text), and a pick field (the selection mode):

[package.metadata.battery-pack.categories.hal]
title = "Hardware Abstraction Layer"
description = "Pick the HAL for your target chip family"
pick = "at-most-one"

r[format.categories.pick] The pick field MUST be either "at-most-one" or "any". It defaults to "any" when omitted. An at-most-one category allows selecting no more than one of its members; an any category places no constraint on how many members are selected.

r[format.categories.defined] Every category name referenced in an item’s categories list MUST have a matching [package.metadata.battery-pack.categories.<name>] entry. A reference to an undefined category is an error (format.categories.defined).

r[format.categories.empty] A category that is declared but that no item references SHOULD be removed. cargo bp validate MUST warn about an empty category (format.categories.empty).

r[format.categories.pick-missing-title] A category with pick = "at-most-one" SHOULD define a title for use as the picker section header. cargo bp validate MUST warn when an at-most-one category has no title (format.categories.pick-missing-title).

Feature metadata

r[format.features.metadata] A [package.metadata.battery-pack.features.<name>] table annotates a Cargo feature. It MAY contain a description (shown next to the item in the picker) and a categories list (the categories the feature belongs to, default []). Both fields are optional, and a feature with no metadata entry behaves exactly as it does without this feature:

[package.metadata.battery-pack.features.stm32f4]
description = "STM32F4xx family"
categories = ["hal"]

r[format.features.unknown-feature] A [package.metadata.battery-pack.features.<name>] entry whose <name> is not a key in [features] is an error (format.features.unknown-feature).

r[format.features.exclusive-conflict] Two or more features that belong to the same at-most-one category MUST NOT both appear in the [features] default array. Doing so is an error (format.features.exclusive-conflict), because it would install a conflicting default set.

Dependency metadata

r[format.deps.metadata] A [package.metadata.battery-pack.dependencies.<name>] table annotates a dependency. It MAY contain a description and a categories list, with the same meaning as feature metadata:

[package.metadata.battery-pack.dependencies.embedded-hal]
description = "Trait abstractions for embedded I/O"
categories = ["portable"]

r[format.dependencies.unknown-dep] A [package.metadata.battery-pack.dependencies.<name>] entry whose <name> does not appear in any dependency section is an error (format.dependencies.unknown-dep).

Hidden dependencies

r[format.hidden.metadata] The [package.metadata.battery-pack] section MAY contain a hidden key with a list of dependency names to hide from users.

r[format.hidden.effect] Hidden dependencies do not appear in the TUI, cargo bp show, or the auto-generated crate table. They cannot be installed by users through cargo bp.

r[format.hidden.glob] Entries in the hidden list MAY use glob patterns. For example, "serde*" hides serde, serde_json, serde_derive, etc.

r[format.hidden.wildcard] The value "*" hides all dependencies. This is useful for battery packs that provide only templates and examples.

Templates

r[format.templates.directory] Templates are stored in subdirectories under templates/ in the battery pack crate.

r[format.templates.metadata] Templates MUST be registered in [package.metadata.battery.templates] with a path and description. An entry MAY also carry an optional categories list, naming the categories the template belongs to (default []):

[package.metadata.battery.templates]
default = { path = "templates/default", description = "A basic starting point" }
fuzzing = { path = "templates/fuzzing", description = "cargo-fuzz scaffold", categories = ["quality"] }

r[format.templates.engine] Templates use MiniJinja for rendering. Each template directory MAY contain a bp-template.toml to configure placeholders and ignored paths.

r[format.templates.cargo-toml] Template Cargo.toml files MUST be named _Cargo.toml. cargo package treats any subdirectory containing a Cargo.toml as a separate crate boundary and excludes it from the published tarball. The template engine automatically maps _Cargo.toml back to Cargo.toml in the generated output. cargo bp validate rejects templates containing Cargo.toml.

r[format.templates.cargo-toml-passthrough] The _Cargo.tomlCargo.toml mapping is suppressed for output paths under templates/. This allows battery packs that scaffold other battery packs (e.g. the with_template authoring template) to preserve _Cargo.toml in their generated template directories.

r[format.templates.managed-deps] Template _Cargo.toml files SHOULD use bp-managed = true on dependencies instead of hardcoding versions. This ensures generated projects always get the versions from the battery pack’s current spec. See Managed dependencies in templates for details.

r[format.templates.config-excluded] The root bp-template.toml is the engine’s configuration file and MUST NOT be included in generated output. A bp-template.toml nested inside a subdirectory (e.g. a scaffolded inner template) MUST be included in the output normally.

r[format.templates.ignore] The ignore list in bp-template.toml specifies files and folders to exclude from generated output entirely. Entries are matched by exact name against any path component, so ignore = ["hooks"] excludes a hooks/ directory at any depth. Wildcards are not supported.

r[format.templates.files] The [[files]] array in bp-template.toml copies files from outside the template directory into the generated project. Each entry has a src path (relative to the crate root) and a dest path (relative to the generated project root). Source files are rendered through the template engine. Existing files from the template directory are not overwritten.

r[format.templates.builtin-variables] The template engine provides the following built-in variables:

  • project_name — the project name passed via --name
  • crate_name — derived from project_name by replacing - with _

These are available in all template files without declaring them as placeholders.

r[format.templates.selection] If a battery pack has multiple templates, cargo bp new MUST prompt the user to select one (unless --template is specified).

r[format.templates.placeholder-defaults] Template placeholders SHOULD define a default value in bp-template.toml so that templates can be validated non-interactively by cargo bp validate.

r[format.templates.placeholder-names] Placeholder names MUST use snake_case. Names containing - are rejected because MiniJinja parses - as the minus operator, making such variables unreachable in template expressions.

Examples

r[format.examples.standard] Examples are standard Cargo examples in the examples/ directory. They follow normal Cargo conventions and are runnable with cargo run --example.

r[format.examples.browsable] Examples MUST be listed in cargo bp show output and in the TUI’s detail view for the battery pack.

Scaffolding

r[format.scaffold.template] The battery-pack crate (the CLI itself) MUST include a built-in template for authoring new battery packs. Running cargo bp new battery-pack MUST create a new battery pack project with the standard structure (Cargo.toml, README.md, docs.handlebars.md, src/lib.rs, examples/, templates/).

CLI Behavior

This section specifies the behavior of each cargo bp subcommand.

Crate sources

r[cli.source.flag] cargo bp --crate-source <path> MUST use a local workspace as the battery pack source, replacing crates.io. The <path> MUST point to a directory containing a Cargo.toml with [workspace].

r[cli.source.discover] When a crate source is specified, cargo bp MUST scan the workspace members for crates whose names end in -battery-pack and make them available as battery packs.

r[cli.source.replace] When --crate-source is specified, it MUST fully replace crates.io. No network requests to crates.io are made.

r[cli.source.multiple] The --crate-source flag MAY be specified multiple times to add multiple local workspaces.

r[cli.source.subcommands] The --crate-source flag MUST be accepted by all subcommands that resolve battery packs: add, new, show, list, status, and sync.

r[cli.source.scope] The --crate-source flag is a per-invocation option that replaces the default crates.io source with local directories. It does not persist across invocations.

Path flag

r[cli.path.flag] cargo bp --path <path> MUST read a battery pack from the given directory. Unlike --crate-source, which adds a searchable workspace, --path identifies a single battery pack directory directly.

r[cli.path.subcommands] The --path flag MUST be accepted by all subcommands that operate on a specific battery pack: add, new, show, check, validate, status, and sync.

r[cli.path.no-resolve] When --path is provided, name resolution is not needed. The battery pack is read directly from the given directory.

Non-interactive mode

r[cli.non-interactive.flag] cargo bp --non-interactive (or -N) MUST suppress interactive prompts and TUI mode. This is a global flag accepted by all subcommands.

r[cli.non-interactive.env] Setting CARGO_BP_NON_INTERACTIVE=true MUST have the same effect as passing --non-interactive. The flag and env var are combined with OR logic.

r[cli.non-interactive.tty] When stdout is not a TTY, cargo bp MUST behave as if --non-interactive were passed.

Name resolution

r[cli.name.resolve] When a battery pack name is given without the -battery-pack suffix, the CLI MUST resolve it by appending -battery-pack. For example, cli resolves to cli-battery-pack.

r[cli.name.exact] If the user provides a full crate name ending in -battery-pack, it MUST be used as-is without further modification.

cargo bp (no arguments)

r[cli.bare.tui] Running cargo bp with no subcommand and no flags MUST print the available subcommands and exit. This is the default clap behavior when a required subcommand is missing.

r[cli.bare.help] Running cargo bp --help MUST print CLI help text and exit.

cargo bp add

r[cli.add.register] cargo bp add <pack> MUST register the battery pack in the project’s metadata and add the default crates to the appropriate dependency sections.

r[cli.add.default-crates] When no -F/--features, --no-default-features, or --all-features flags are given, cargo bp add <pack> MUST add the crates from the battery pack’s default feature (or all non-optional crates if no default feature exists).

r[cli.add.features] cargo bp add <pack> -F <name> (or --features <name>) MUST add all crates from the named feature. Unless --no-default-features is specified, the default crates are also included.

r[cli.add.features-multiple] Multiple features MAY be specified as a comma-separated list (-F indicators,fancy) or by repeating the flag (-F indicators -F fancy).

r[cli.add.no-default-features] cargo bp add <pack> --no-default-features MUST add no crates by itself. Combined with -F, it adds only the named feature’s crates.

r[cli.add.all-features] cargo bp add <pack> --all-features MUST add every crate the battery pack offers, regardless of features or optional status.

r[cli.add.specific-crates] cargo bp add <pack> <crate> [<crate>...] MUST add only the named crates from the battery pack, ignoring defaults and features.

r[cli.add.dep-kind] Each crate MUST be added with the dependency kind matching its section in the battery pack’s Cargo.toml (regular, dev, or build), unless the user overrides it.

r[cli.add.unknown-crate] When specific crates are named (cargo bp add <pack> <crate>...) and a named crate does not exist in the battery pack, cargo bp MUST report an error for that crate. Other valid crates in the same command MUST still be processed.

r[cli.add.idempotent] Adding a battery pack that is already registered MUST NOT create duplicate entries. If the battery pack is already present, cargo bp add MUST update its version and sync any new crates.

Template merging

r[cli.add.template-flag] cargo bp add <pack> --template <name> (or -t <name>) MUST render the named template and merge the output into the current project directory. This does not register the battery pack or add its curated crates; it only applies the template files.

r[cli.add.template-project-name] When merging a template, cargo bp MUST infer project_name from the current Cargo.toml [package].name. If no Cargo.toml exists or it has no [package].name, the current directory name MUST be used as a fallback.

r[cli.add.template-define] cargo bp add <pack> -t <name> --define <key>=<value> (or -d) MUST set the named placeholder to the given value, skipping the prompt for that placeholder. Multiple -d flags MAY be provided.

r[cli.add.template-merge-toml] When a template produces a .toml file and the target file already exists, cargo bp MUST merge using TOML-aware logic: for Cargo.toml, dependencies are synced (version upgraded if behind, features unioned, never removed); for all .toml files, sections and keys are recursively merged (inserted if absent, left alone if present). The user’s existing formatting MUST be preserved.

r[cli.add.template-merge-yaml] When a template produces a .yml or .yaml file and the target file already exists, cargo bp MUST merge using YAML-aware logic: top-level mapping keys are merged additively. For known GitHub Actions keys (jobs, on, permissions), child maps are also merged additively. Existing keys are never removed.

r[cli.add.template-merge-plain] When a template produces any other file and the target file already exists, cargo bp MUST prompt the user to skip, overwrite, or view a diff.

r[cli.add.template-overwrite] cargo bp add <pack> -t <name> --overwrite MUST force overwrite all plain file conflicts without prompting. Structured file merges (TOML, YAML) MUST still use merge logic.

r[cli.add.template-non-interactive] In non-interactive mode, conflicts with files that are not .toml, .yml, or .yaml MUST be skipped unless --overwrite is passed. TOML and YAML merges MUST still apply.

r[cli.add.template-hints] If the template’s bp-template.toml declares [[hints]] entries, cargo bp MUST print them after the merge summary.

r[cli.add.template-git-dirty] Before applying template files, cargo bp MUST check for uncommitted git changes. In interactive mode, it MUST warn and prompt for confirmation. In non-interactive mode, it MUST refuse unless --overwrite is passed. If the directory is not a git repository, the check MUST be skipped.

r[cli.add.template-batch] When prompting for conflict resolution, cargo bp MUST offer batch options. For TOML and YAML merge prompts: “accept all” and “skip all”. For other file prompts: “overwrite all” and “skip all”. Batch options apply to all remaining conflicts without further prompting.

r[cli.add.template-edit] When prompting for structured merge conflicts (TOML, YAML), cargo bp MUST offer an “edit” option that opens the merged result in $VISUAL, $EDITOR, or vi (in that order). After editing, the updated diff MUST be shown and the user MUST be returned to the accept/skip/edit prompt.

Categories and exclusive picks

r[cli.add.exclusive-validation] In non-interactive mode, when -F/--features requests two or more features that belong to the same at-most-one category, cargo bp add MUST exit with an error naming both features and the category (e.g., features 'stm32f4' and 'nrf52840' are exclusive (category: hal)). The same check applies to templates requested with -t/--template. --all-features bypasses this validation.

r[cli.add.category-picker] In interactive mode, the selection picker MUST group items into one section per category, using the category title as the section header. Items not in any category appear in generic “Features” / “Dependencies” / “Templates” sections. See Picker categories for the section behavior.

cargo bp new

r[cli.new.template] cargo bp new <pack> MUST create a new project from the battery pack’s template using the built-in template engine.

r[cli.new.name-flag] cargo bp new <pack> --name <name> MUST pass the project name to the template engine, skipping the name prompt.

r[cli.new.name-prompt] If --name is not provided, the CLI MUST prompt the user for a project name.

r[cli.new.template-select] If the battery pack has multiple templates and --template is not provided, the CLI MUST prompt the user to select one.

r[cli.new.template-flag] cargo bp new <pack> --template <name> MUST use the specified template without prompting.

r[cli.new.define-flag] cargo bp new <pack> --define <key>=<value> (or -d) MUST set the named placeholder to the given value, skipping the prompt for that placeholder. Multiple -d flags MAY be provided.

r[cli.new.non-interactive] In non-interactive mode, cargo bp new MUST fail with an error if --name is not provided. Template placeholders without a default or --define override MUST also cause an error.

cargo bp status

r[cli.status.list] cargo bp status MUST list all installed battery packs with their registered versions.

r[cli.status.version-warn] For each installed battery pack, cargo bp status MUST display a warning for each dependency whose version is older than what the battery pack recommends. Dependencies with equal or newer versions MUST NOT produce a warning.

r[cli.status.no-project] If run outside a Rust project, cargo bp status MUST report that no project was found.

r[cli.status.json] cargo bp status --json MUST emit a machine-readable JSON document on stdout that conforms to the schema published in the cargo-bp-script crate. The document MUST contain a top-level schema_version field. With --json, no human-readable text MUST be emitted on stdout. The same set of installed packs and dependency warnings that the text mode shows MUST be represented in the JSON payload.

cargo bp sync

r[cli.sync.update-versions] cargo bp sync MUST update dependency versions that are older than what the installed battery packs recommend. Versions that are equal to or newer than recommended MUST be left unchanged.

r[cli.sync.add-features] cargo bp sync MUST add any Cargo features that the battery pack specifies but are missing from the user’s dependency entry. Existing user-added features MUST be preserved.

r[cli.sync.add-crates] cargo bp sync MUST add any crates that belong to the user’s active features but are missing from the user’s dependencies. Existing crates MUST NOT be removed.

cargo bp list

r[cli.list.query] cargo bp list MUST query crates.io for crates with the battery-pack keyword.

r[cli.list.filter] cargo bp list <filter> MUST filter results by name pattern.

r[cli.list.interactive] If running in a TTY, cargo bp list SHOULD display results in the interactive TUI.

r[cli.list.non-interactive] In non-interactive mode, cargo bp list MUST print results as plain text.

cargo bp check

r[cli.check.purpose] cargo bp check MUST validate that installed battery packs match the project’s current dependencies and warn about version drift.

r[cli.check.version-drift] cargo bp check MUST compare the user’s current dependency versions against the versions recommended by installed battery packs and warn when user versions are older than recommended versions.

r[cli.check.output] cargo bp check MUST display the status of each installed battery pack with clear indicators (✅ for up-to-date, ⚠️ for outdated versions).

r[cli.check.no-packs] If no battery packs are installed, cargo bp check MUST display “No battery packs installed.” and exit successfully.

cargo bp validate

r[cli.validate.purpose] cargo bp validate MUST check whether a battery pack crate conforms to the battery pack format specification (format.* rules).

r[cli.validate.default-path] If --path is not provided, cargo bp validate MUST validate the battery pack in the current directory.

r[cli.validate.checks] cargo bp validate MUST check all applicable format.* rules, including both data-level checks (from the parsed Cargo.toml) and filesystem-level checks (on-disk structure).

r[cli.validate.severity] Violations of MUST rules MUST be reported as errors. Violations of SHOULD rules MUST be reported as warnings.

r[cli.validate.rule-id] Each diagnostic MUST include the spec rule ID in its output (e.g., error[format.crate.name]: ...).

r[cli.validate.clean] When a battery pack passes all checks with no diagnostics, cargo bp validate MUST print <name> is valid and exit successfully.

r[cli.validate.warnings-only] When a battery pack has warnings but no errors, cargo bp validate MUST print <name> is valid (<N> warning(s)) and exit successfully.

r[cli.validate.errors] When a battery pack has one or more errors, cargo bp validate MUST exit with a non-zero status.

r[cli.validate.workspace-error] If the target Cargo.toml is a workspace manifest (contains [workspace] but no [package]), cargo bp validate MUST report a clear error directing the user to run from a battery pack crate directory or use --path.

r[cli.validate.no-package] If the target Cargo.toml has no [package] section and is not a workspace manifest, cargo bp validate MUST report a clear error indicating the file is not a battery pack crate.

r[cli.validate.templates] cargo bp validate MUST generate each declared template into a temporary directory, then run cargo check and cargo test on the result. If any template fails to compile or its tests fail, validation MUST fail.

r[cli.validate.templates.patch] When validating templates, cargo bp validate MUST patch crates-io dependencies with local workspace packages so that validation runs against the current source.

r[cli.validate.templates.cache] Compiled artifacts from template validation SHOULD be cached in <target_dir>/bp-validate/ so that subsequent runs are faster.

r[cli.validate.templates.none] If the battery pack declares no templates, template validation MUST be skipped.

cargo bp show

r[cli.show.details] cargo bp show <pack> MUST display the battery pack’s name, version, description, curated crates, features, templates, and examples.

r[cli.show.hidden] cargo bp show MUST NOT display hidden dependencies.

r[cli.show.interactive] If running in a TTY, cargo bp show SHOULD display results in the interactive TUI.

r[cli.show.non-interactive] In non-interactive mode, cargo bp show MUST print results as plain text.

r[cli.show.template-preview] cargo bp show <pack> --template <name> MUST render the named template and display the resulting files. In a TTY, the output SHOULD be shown in the interactive TUI preview screen. With --non-interactive, the rendered files MUST be printed to stdout. Placeholders without a default MUST fall back to <name> so the preview always succeeds. The project name MUST default to my-project.

r[cli.show.define-flag] cargo bp show <pack> -t <name> --define <key>=<value> (or -d) MUST set the named placeholder to the given value in the rendered preview. Multiple -d flags MAY be provided.

r[cli.show.categories] When a battery pack defines categories, cargo bp show MUST display a “Categories:” section listing each category with its member items grouped under the category title.

r[cli.show.pick-mode] In cargo bp show output, an at-most-one category MUST be annotated with “(pick at most one)”.

TUI Behavior

This section specifies the behavior of the interactive terminal interface launched by cargo bp (no arguments).

r[tui.main.always-available] The TUI MUST be launchable from any directory, whether or not a Rust project is present.

r[tui.main.sections] The TUI main screen MUST display the following sections:

  • Installed battery packs (for managing current dependencies)
  • Browse (for discovering and adding new battery packs)
  • New project (for creating projects from templates)

r[tui.main.no-project] When not inside a Rust project, the installed battery packs section MUST be visually disabled (greyed out) with a message indicating no project was found. Browse and New project MUST remain functional.

r[tui.main.context-detection] The TUI MUST detect the current project context by searching for a Cargo.toml in the current directory and walking up to find a workspace root.

Installed packs view

r[tui.installed.list-packs] The installed packs view MUST list all battery packs registered in the project’s metadata, showing their names and versions.

r[tui.installed.list-crates] For each installed battery pack, the TUI MUST display its curated crates (excluding hidden dependencies), grouped by feature.

r[tui.installed.toggle-crate] The user MUST be able to toggle individual crates on and off. Toggling a crate on adds it to the user’s dependencies; toggling it off removes it, unless the crate is required by another enabled feature (see tui.installed.features). In a radio (at-most-one) category, toggling one item on MUST deselect its siblings (see tui.picker.radio).

r[tui.installed.dep-kind] The user MUST be able to change a crate’s dependency kind (runtime, dev, build) from the TUI. The default is determined by the battery pack’s Cargo.toml.

r[tui.installed.show-state] Each crate MUST be displayed with its current state: enabled/disabled, dependency kind, and version.

r[tui.installed.features] Battery pack features MUST be displayed as toggleable groups. Enabling a feature enables all its crates; disabling it disables crates that aren’t required by another enabled feature.

r[tui.installed.hidden] Hidden dependencies MUST NOT appear in the installed packs view.

Browse view

r[tui.browse.search] The browse view MUST allow searching crates.io for battery packs by name.

r[tui.browse.list] Search results MUST display the battery pack name, version, and description.

r[tui.browse.detail] Selecting a battery pack in browse MUST show its details: curated crates (excluding hidden dependencies), features, templates, and examples.

r[tui.browse.add] The user MUST be able to add a battery pack from the browse view. When adding, the TUI MUST show a selection screen with default crates pre-checked (based on the default feature), excluding hidden dependencies.

r[tui.browse.hidden] Hidden dependencies MUST NOT appear when browsing a battery pack’s contents.

New project view

r[tui.new.template-list] The new project view MUST list available templates from installed battery packs and allow browsing templates from battery packs on crates.io.

r[tui.new.create] Selecting a template MUST prompt for a project name and directory, then create the project using the built-in template engine.

Network operations

r[tui.network.non-blocking] Network operations (fetching battery pack lists, downloading specs) MUST NOT block the TUI. The interface MUST remain responsive with a loading indicator while network requests are in progress.

r[tui.network.error] Network errors MUST be displayed to the user without crashing the TUI. The user MUST be able to retry or continue using other features.

Picker categories

When a battery pack defines categories, the selection picker used by cargo bp add renders one section per category. The pick mode of each category determines how its items behave.

r[tui.picker.radio] An at-most-one category MUST render its items as radio buttons ( selected, unselected). Selecting an item MUST deselect the others in the same section. Pressing Backspace MUST clear the section’s selection entirely. A section MAY have zero items selected.

r[tui.picker.checkbox] An any category MUST render its items as checkboxes ([x] checked, [ ] unchecked); toggling an item is independent of the others. Pressing a on the section MUST toggle all of its items (the existing section-toggle behavior). For at-most-one sections, a is a no-op.

r[tui.picker.collapse] Pressing Left on a section header MUST collapse the section, hiding its items; pressing Right MUST expand it. A collapsed section MUST render a chevron and an expanded section a chevron.

r[tui.picker.confirm-validation] When an at-most-one section has more than one item selected (for example, because conflicting crates were previously installed by hand), pressing Enter MUST be rejected with an inline error, and the picker MUST NOT confirm until the section has at most one selection.

r[tui.nav.keyboard] The TUI MUST support keyboard navigation: arrow keys or j/k for movement, Enter for selection, Space for toggling, Esc or q for back/quit, Tab for switching between sections.

r[tui.nav.exit] When the user confirms and exits the TUI (e.g., Enter on the apply prompt), all pending changes (added/removed crates, changed dependency kinds) MUST be applied to the project’s Cargo.toml files. Exits via cancel (see tui.nav.cancel) MUST NOT apply changes.

r[tui.nav.cancel] The user MUST be able to cancel without applying changes (e.g., Ctrl+C or a dedicated cancel action).

Manifest Manipulation

This section specifies how cargo bp reads and modifies Cargo.toml files.

Battery pack state (battery-pack.toml)

Note: The battery-pack.toml format is subject to change in future versions. The file includes a version field to support forward compatibility.

r[manifest.state.location] Battery pack state (installed packs, active features, managed dependencies) is stored in a battery-pack.toml file next to the crate’s Cargo.toml. Each crate in a workspace has its own battery-pack.toml.

r[manifest.state.format] The file uses the following structure:

version = 1

[[battery-pack]]
name = "cli"
features = ["default", "indicators"]

[[battery-pack.managed-deps]]
name = "clap"
version = "4.5"

[[battery-pack.managed-deps]]
name = "dialoguer"
version = "0.11"

r[manifest.state.version] The version field MUST be present and set to 1. Tools MUST reject files with a version higher than they support.

r[manifest.state.name] The name field uses the short form of the battery pack name (e.g., "cli" for cli-battery-pack).

Battery pack discovery

r[manifest.register.location] Installed battery packs are discovered by scanning [build-dependencies] in the crate’s Cargo.toml for entries whose names end in -battery-pack or equal "battery-pack".

Active features

r[manifest.features.storage] The active features for a battery pack are stored in the features array of the corresponding [[battery-pack]] entry in battery-pack.toml. When no battery-pack.toml exists or the pack is not listed, the default feature is implicitly active.

Dependency management

r[manifest.deps.add] When adding a crate, cargo bp MUST add it to the correct dependency section ([dependencies], [dev-dependencies], or [build-dependencies]) based on the battery pack’s Cargo.toml, unless overridden by the user.

r[manifest.deps.version-features] Each dependency entry MUST include the version and Cargo features as specified by the battery pack.

r[manifest.deps.workspace] In a workspace, cargo bp MUST add crate entries to [workspace.dependencies] in the workspace root and reference them as crate = { workspace = true } in the crate’s dependency section.

r[manifest.deps.no-workspace] In a non-workspace project, cargo bp MUST add crate entries directly to the crate’s dependency section with full version and features.

r[manifest.deps.existing] If a dependency already exists in the user’s Cargo.toml, cargo bp MUST NOT overwrite user customizations (additional features, version overrides). It MUST only add missing features and warn about version mismatches.

r[manifest.deps.remove] When a user disables a crate via the TUI, cargo bp MUST remove it from the appropriate dependency section. If using workspace dependencies, the workspace.dependencies entry SHOULD be preserved (other crates in the workspace may use it).

Managed dependencies in templates

r[manifest.managed.marker] A template’s Cargo.toml MAY use bp-managed = true on a dependency instead of hardcoding a version. This signals that the version should be resolved at template generation time from the battery pack’s own spec. The marker is recognized in [dependencies], [dev-dependencies], [build-dependencies], and their platform-gated [target.<cfg>.*] mirrors.

[dependencies]
clap.bp-managed = true

[build-dependencies]
cli-battery-pack.bp-managed = true

[target.'cfg(unix)'.dependencies]
nix.bp-managed = true

r[manifest.managed.conflict] The value of bp-managed MUST be the boolean true; any other value (e.g. false or a string) is an error, so drop the key to opt out. A dependency MUST NOT combine bp-managed = true with version or workspace, since each already supplies what bp-managed provides. Other keys (features, optional, default-features, package, etc.) are allowed alongside bp-managed and are preserved in the resolved output.

r[manifest.managed.resolution] When generating a project from a template, cargo bp MUST resolve each bp-managed dependency by replacing bp-managed with the version from the battery pack’s spec. If the entry has no explicit features, the spec’s features are used as the default. If explicit features are present, they override the spec’s features entirely. All other keys are preserved as-is. A dependency renamed with package = "..." is resolved by its real crate name (the package value), not the table key. Specs are discovered from the crate root’s workspace first. If a referenced battery pack is not found locally (e.g. a cross-pack reference after downloading from crates.io), cargo bp MUST fetch its spec from the registry. Battery pack crates in [build-dependencies] get the battery pack’s own version.

r[manifest.managed.no-partial] Partial overrides are not supported. A bp-managed dependency cannot selectively manage only the version or only the features. The spec controls both. To customize features or pin a specific version, use an explicit dependency entry instead of bp-managed = true. If you have a use case for partial overrides, please open an issue.

r[manifest.managed.explicit-override] A template MAY use an explicit version instead of bp-managed = true to pin a specific version or specify custom features. Explicit dependencies are left as-is and not modified during resolution.

Cross-pack merging

r[manifest.merge.version] When multiple battery packs recommend the same crate, cargo bp MUST use the newest version. This applies even across major versions — the highest version always wins.

r[manifest.merge.features] When multiple battery packs recommend the same crate with different Cargo features, cargo bp MUST union (merge) all the features.

r[manifest.merge.dep-kind] When multiple battery packs recommend the same crate with different dependency kinds, cargo bp MUST resolve as follows:

  • If any pack lists the crate in [dependencies], it MUST be added as a regular dependency (the widest scope).
  • If one pack lists it in [dev-dependencies] and another in [build-dependencies], it MUST be added to both sections.

Sync behavior

r[manifest.sync.version-bump] During sync, cargo bp MUST update a dependency’s version to the battery pack’s recommended version only when the user’s version is older. If the user’s version is equal to or newer than the recommended version, it MUST be left unchanged.

r[manifest.sync.feature-add] During sync, cargo bp MUST add any Cargo features that the battery pack specifies but that are missing from the user’s dependency entry. Existing user features MUST be preserved — sync MUST NOT remove Cargo features.

TOML formatting

r[manifest.toml.preserve] cargo bp MUST preserve existing TOML formatting, comments, and ordering when modifying Cargo.toml files.

r[manifest.toml.style] New entries added by cargo bp SHOULD follow the existing formatting style of the file (inline tables vs. multi-line, etc.).

Feature References

Feature References

This section specifies how cargo bp interprets the string inside [features] list when resolving which crates and Cargo features appear in the recommended downstream Cargo.toml .

Reference forms

r[feature-refs.forms] A feature reference is one of:

FormVariantExample
fooFeature("foo")default = ["foo"]
dep:fooDep("foo")default = ["dep:foo"]
foo/barDepFeature { dep, feature, weak: false }fancy = ["serde/derive"]
foo?/barDepFeature { dep, feature, weak: true }fancy = ["serde?/derive"]
pkg:foo/barNamespaced (reserved)default = ["pkg:foo/bar"]

r[feature-refs.parse] References are parsed at manifest load time. A parse failure surfaces as a typed error against the originating (feature_name, ref_string) pair.

r[feature-refs.weak-equivalence] Strong foo/bar and weak foo?/bar both add bar to the recommended features of foo when foo is activated. They differ only in dep activation: strong activates foo; weak does not. See [feature-refs.resolution.weak].

r[feature-refs.namespaced] Namespaced references (pkg:foo/bar, per RFC 3143) parse successfully but are skipped by resolve_crates and emit a warning.

Resolution

r[feature-refs.resolution.feature] For Feature(name), the recommender first checks whether name matches a key in [features]. If so, that feature’s reference list is expanded inline per [feature-refs.resolution.recursion]. Otherwise name is treated as a crate in [dependencies] and added to the result with the features declared on the [dependencies] row.

r[feature-refs.resolution.dep] For Dep(name), the reference always refers to a crate in [dependencies]. Resolution otherwise matches the dep-name branch of [feature-refs.resolution.feature].

r[feature-refs.resolution.dep-feature] For strong DepFeature { dep, feature, weak: false }, the recommender adds dep to the result (if not already present) with its declared row features, then unions feature into the result’s feature set.

r[feature-refs.resolution.weak] For weak DepFeature { dep, feature, weak: true }, the recommender records the (dep, feature) pair as a deferred entry. After all non-weak references in the combo have been resolved, each deferred entry is applied only if dep is already in the result map (activated by another reference). Otherwise the entry is dropped and dep is not added.

r[feature-refs.resolution.recursion] A Feature(name) whose name matches a key in [features] is expanded by recursively resolving the referenced feature’s own reference list. Crates added by inner expansion are merged into the result as if directly referenced. Cycles are rejected at validation time per [feature-refs.validation.cycles].

r[feature-refs.resolution.dev-build] Crates with dep_kind other than Normal (dev, build) are always included in the result regardless of which features are active, matching [format.features.dev-build-always].

Validation

r[feature-refs.validation.unknown] A reference whose dep part matches neither a declared dependency nor a local feature name is a validation error.

r[feature-refs.validation.cycles] A cycle through local feature references is a validation error. Example: a = ["b"], b = ["a"].

Oracle agreement

r[feature-refs.oracle] For any (pack, feature-combo) pair the recommender MUST name the same set of direct dependencies as cargo metadata --features <combo> for the same pack. An oracle test harness, gated behind the oracle cargo feature and run in CI, enforces this invariant.

r[feature-refs.oracle.scope] The oracle compares dep-membership only, not the activated feature set per dep. Cargo’s resolver activates each dep’s own transitive default features (e.g. serde’s std, alloc); the recommender emits only what the pack’s [features] ask for. Per-dep feature correctness is verified by in-process unit tests against the recommender’s own output.

r[feature-refs.cargo-upgrades] A failing oracle test after a cargo upgrade is resolved by updating the recommender to match cargo’s new behaviour.

Documentation Generation

This section specifies how battery pack documentation is automatically generated for display on docs.rs.

Build-time generation

r[docgen.build.trigger] The battery pack’s build.rs MUST generate a docs.md file in OUT_DIR during the build process.

r[docgen.build.template] The build.rs MUST read a Handlebars template file (docs.handlebars.md) from the crate root and render it with structured metadata.

r[docgen.build.lib-include] The battery pack’s lib.rs MUST include the generated documentation via #![doc = include_str!(concat!(env!("OUT_DIR"), "/docs.md"))].

Template processing

r[docgen.template.handlebars] The template format MUST be Handlebars. The template file MUST be named docs.handlebars.md.

r[docgen.template.default] The default template provided by cargo bp new MUST include the README and a crate table:

{{readme}}

{{crate-table}}

r[docgen.template.custom] Battery pack authors MAY customize the template to control the documentation layout. The same structured metadata available to built-in helpers MUST also be available as template variables for custom markup.

Built-in helpers

r[docgen.helper.readme] The {{readme}} helper MUST expand to the contents of the battery pack’s README.md.

r[docgen.helper.crate-table] The {{crate-table}} helper MUST render a table of all non-hidden curated crates, including each crate’s name (linked to crates.io), version, and description.

r[docgen.helper.crate-table-metadata] Crate descriptions in {{crate-table}} MUST be sourced from crate metadata (via cargo metadata), not manually maintained.

r[docgen.helper.crate-table-update] The {{crate-table}} implementation lives in the bphelper crate. Updating bphelper MUST automatically update the table rendering for all battery packs that use {{crate-table}}.

Template variables

r[docgen.vars.crates] The template context MUST include a crates array. Each entry MUST have: name, version, description, features (Cargo features), and dep_kind (dependencies, dev-dependencies, or build-dependencies).

r[docgen.vars.features] The template context MUST include a features array. Each entry MUST have: name and crates (list of crate names in that feature).

r[docgen.vars.readme] The template context MUST include a readme string containing the contents of the battery pack’s README.md.

r[docgen.vars.package] The template context MUST include a package object with: name, version, description, and repository.

Hidden crates

r[docgen.hidden.excluded] Crates listed in the battery pack’s hidden configuration MUST NOT appear in the crates template variable or in the output of {{crate-table}}.

RFDs

Requests for Discussion — design proposals for battery-pack features.

RFD: Categories and Exclusive Picks

Summary

Extend battery packs with metadata to express categories (groupings of related items) and exclusive picks (choose at most one from a group). This enables battery packs like embedded-battery-pack that curate alternatives — “here are 5 ways to do X, pick the one that fits your chip” — with first-class UI support in cargo bp add.

Motivation

Today, battery pack features are purely additive: every feature you enable adds crates on top of what’s already there. This works well for “kitchen sink” packs like cli-battery-pack where you want clap and indicatif and dialoguer together.

But some domains are about choosing between alternatives:

  • Embedded: you pick one HAL crate for your chip family (stm32f4xx-hal or nrf52840-hal, never both)
  • Async runtimes: you pick tokio or async-std or smol
  • TLS backends: you pick rustls or native-tls
  • Allocators: you pick jemalloc or mimalloc (already awkward in backend-service-battery-pack today)

The awesome-embedded-rust repository curates hundreds of crates organized by vendor and category. A battery pack for this domain needs a way to say “here is a category of alternatives; present them to the user and let them pick one.”

Relationship to Cargo mutually-exclusive globals

There’s an active pre-RFC for mutually-exclusive global features in Cargo itself. That proposal would give Cargo native understanding of choices that are exclusive — making it impossible to compile two conflicting values in one build graph. Cargo doesn’t have this today, so we need to build something on top. If Cargo grows first-class support, we would likely deprecate our custom metadata in favor of reading Cargo’s native declarations. But we don’t want to let perfect be the enemy of good and block on completion of the pre-RFC.

Our design should be forward-compatible: if Cargo globals land, a battery pack author could migrate from [package.metadata.battery-pack.categories] to native [globals] declarations, and cargo bp would read the Cargo-native format instead.

Design

The core model is simple:

  1. Any selectable item (feature, dependency, or template) can have metadata — including a description and zero or more categories.
  2. Categories have a title, a description, and a pick mode (at-most-one or any).

When displayed, items are grouped by category. at-most-one categories render as radio buttons. Items not in any category appear in generic sections (“Features”, “Dependencies”, “Templates”) as today.

Item metadata: [package.metadata.battery-pack.<kind>.<name>]

Any selectable item can be annotated. The <kind> is features, dependencies, or templates:

[package.metadata.battery-pack.features.stm32f4]
description = "STM32F4xx family"
categories = ["hal"]

[package.metadata.battery-pack.features.nrf52840]
description = "nRF52840 SoC"
categories = ["hal"]

[package.metadata.battery-pack.features.embassy]
description = "Embassy — async/await for embedded"
categories = ["rtos"]

[package.metadata.battery-pack.dependencies.embedded-hal]
description = "Trait abstractions for embedded I/O"
categories = ["portable"]

Fields:

  • description — shown in the picker next to the item name
  • categories — list of category names this item belongs to (default: [])

Both fields are optional. An item with no metadata entry behaves exactly as today.

Category metadata: [package.metadata.battery-pack.categories.<name>]

[package.metadata.battery-pack.categories.hal]
title = "Hardware Abstraction Layer"
description = "Pick the HAL for your target chip family"
pick = "at-most-one"

[package.metadata.battery-pack.categories.rtos]
title = "Concurrency Framework"
description = "Pick your scheduling / concurrency approach"
pick = "at-most-one"

[package.metadata.battery-pack.categories.portable]
title = "Portable Utilities"
description = "Works with any HAL"

Fields:

  • title — display name in the picker header
  • description — explanatory text (optional)
  • pick"at-most-one" or "any" (default: "any")

Categories are pack-scoped: two different battery packs can both define a category named "hal" without conflict. Cross-pack category coordination is out of scope (future work; would need a pack extension mechanism).

Category-linked template placeholders

Today, template bp-template.toml files duplicate category information in their select placeholders. For example, backend-service-battery-pack’s service template has:

[placeholders.allocator]
type = "select"
prompt = "Global allocator"
options = ["jemalloc", "mimalloc", "system"]
default = "jemalloc"

These options are manually kept in sync with the allocator category’s features. Instead of a literal options array, the placeholder can reference a category to derive its options automatically:

[placeholders.allocator]
type = "select"
prompt = "Global allocator"
options.category = "allocator"
default = "jemalloc"

The options field accepts either a literal array (options = [...]) or a category reference (options.category = "..."). In serde terms, this is an untagged enum.

Behavior:

  • The options list is automatically the set of items in the named category.
  • The template variable receives the chosen item name as its value (e.g., {{ allocator }} = "jemalloc").
  • If the user already made a selection in the cargo bp add picker, this placeholder is pre-filled and can be skipped during template generation.
  • If the category has pick = "at-most-one" and no selection was made, the template prompts as usual.

The template still uses the value the same way:

{% if allocator == "jemalloc" %}
tikv-jemallocator.bp-managed = true
{% elif allocator == "mimalloc" %}
mimalloc.bp-managed = true
{% endif %}

The source of truth is the category definition, not a duplicated options list. Adding a new feature to the category automatically makes it available as a template option.

UI: How cargo bp add embedded looks

Interactive (TUI)

The picker renders categories as sections with radio-button (●/○) or checkbox ([x]/[ ]) semantics based on the pick constraint:

  embedded-battery-pack v0.1.0
  ─────────────────────────────────────────────────

  Hardware Abstraction Layer (pick at most one):
      ○ stm32f0     STM32F0xx family
      ○ stm32f1     STM32F1xx family
      ○ stm32f3     STM32F3xx family
      ● stm32f4     STM32F4xx family            ← selected
      ○ stm32f7     STM32F7xx family
      ○ nrf52840    nRF52840 SoC
      ○ esp32       ESP32 (no_std via esp-hal)
      ○ rp2040      RP2040 (Raspberry Pi Pico)

  Concurrency Framework (pick at most one):
      ○ rtic        RTIC — interrupt-driven concurrency
      ● embassy     Embassy — async/await for embedded

  Portable Utilities:
      [x] embedded-hal    Trait abstractions for embedded I/O
      [x] defmt           Efficient logging for constrained devices
      [ ] embedded-io     Read/Write traits for embedded
      [ ] heapless        Static-friendly data structures

  ─────────────────────────────────────────────────
  Space: toggle  ←/→: collapse/expand  Enter: confirm  q: cancel  p: preview

UX details:

  • at-most-one categories use radio-button rendering (●/○). Selecting one deselects any other in the same category. Pressing Backspace clears the current selection entirely (returns to zero selections). It’s possible to arrive at a state with multiple selections (e.g., the user previously ran cargo add manually). In that case the picker shows the current state honestly — multiple items selected — with a warning banner. Selecting a new radio item clears the others; the deselected crates are removed from Cargo.toml on confirm.
  • any categories use checkboxes as today. Pressing a on a category header checks all items in that category (same as today’s section toggle). For at-most-one categories, a is a no-op (can’t select all).
  • Collapsing: pressing left-arrow on a category header collapses it; right-arrow expands it.
  • The header line says “(pick at most one)” for at-most-one categories.
  • Validation on Enter: if an at-most-one category has more than one selection, the picker shows an inline error and refuses to confirm.

Non-interactive (CLI flags)

# Pick a specific HAL and concurrency framework:
cargo bp add embedded -F stm32f4 -F embassy

# Enable a portable utility:
cargo bp add embedded -F stm32f4 -F embassy -F heapless

# Validation: this is an error (two exclusive HALs):
cargo bp add embedded -F stm32f4 -F nrf52840
# error: features `stm32f4` and `nrf52840` are exclusive (category: hal)

cargo bp show

embedded-battery-pack v0.1.0
Curated hardware ecosystem for embedded Rust

Categories:
  hal — Hardware Abstraction Layer (pick at most one)
    stm32f0, stm32f1, stm32f3, stm32f4, stm32f7, nrf52840, esp32, rp2040
  rtos — Concurrency Framework (pick at most one)
    rtic, embassy
  portable — Portable Utilities
    embedded-hal, defmt, embedded-io, heapless

Templates:
  blinky    — Minimal blinky LED example for your chosen HAL

Example: Full embedded-battery-pack Cargo.toml

[package]
name = "embedded-battery-pack"
version = "0.1.0"
edition = "2024"
description = "Curated hardware ecosystem for embedded Rust"
license = "MIT OR Apache-2.0"
keywords = ["battery-pack", "embedded", "hal", "no-std"]

# --- Category definitions ---

[package.metadata.battery-pack.categories.hal]
title = "Hardware Abstraction Layer"
description = "Pick the HAL for your target chip family"
pick = "at-most-one"

[package.metadata.battery-pack.categories.rtos]
title = "Concurrency Framework"
description = "Pick your scheduling / concurrency approach"
pick = "at-most-one"

[package.metadata.battery-pack.categories.portable]
title = "Portable Utilities"
description = "Works with any HAL"

# --- Feature metadata ---

[package.metadata.battery-pack.features.stm32f0]
description = "STM32F0xx family"
categories = ["hal"]

[package.metadata.battery-pack.features.stm32f1]
description = "STM32F1xx family"
categories = ["hal"]

[package.metadata.battery-pack.features.stm32f4]
description = "STM32F4xx family"
categories = ["hal"]

[package.metadata.battery-pack.features.nrf52840]
description = "nRF52840 SoC"
categories = ["hal"]

[package.metadata.battery-pack.features.esp32]
description = "ESP32 (no_std via esp-hal)"
categories = ["hal"]

[package.metadata.battery-pack.features.rp2040]
description = "RP2040 (Raspberry Pi Pico)"
categories = ["hal"]

[package.metadata.battery-pack.features.rtic]
description = "RTIC — interrupt-driven concurrency"
categories = ["rtos"]

[package.metadata.battery-pack.features.embassy]
description = "Embassy — async/await for embedded"
categories = ["rtos"]

# --- Dependency metadata ---

[package.metadata.battery-pack.dependencies.embedded-hal]
description = "Trait abstractions for embedded I/O"
categories = ["portable"]

[package.metadata.battery-pack.dependencies.defmt]
description = "Efficient logging for constrained devices"
categories = ["portable"]

[package.metadata.battery-pack.dependencies.embedded-io]
description = "Read/Write traits for embedded"
categories = ["portable"]

[package.metadata.battery-pack.dependencies.heapless]
description = "Static-friendly data structures"
categories = ["portable"]

# --- Hidden deps ---

[package.metadata.battery-pack]
hidden = ["battery-pack"]

# --- Dependencies ---

[dependencies]
# Portable ecosystem
embedded-hal = { version = "1", optional = true }
defmt = { version = "1", optional = true }
embedded-io = { version = "0.6", optional = true }
heapless = { version = "0.8", optional = true }
critical-section = { version = "1", optional = true }

# HALs
stm32f0xx-hal = { version = "0.18", optional = true }
stm32f1xx-hal = { version = "0.10", optional = true }
stm32f4xx-hal = { version = "0.22", features = ["rt"], optional = true }
nrf52840-hal = { version = "0.18", optional = true }
esp-hal = { version = "1", optional = true }
rp2040-hal = { version = "0.10", optional = true }

# Concurrency frameworks
rtic = { version = "2", optional = true }
embassy-executor = { version = "0.7", optional = true }
embassy-time = { version = "0.4", optional = true }

[build-dependencies]
battery-pack = { version = "0.7", features = ["build"] }

# --- Features ---

[features]
default = ["embedded-hal", "defmt", "critical-section"]

# HAL features (exclusive within category)
stm32f0 = ["stm32f0xx-hal", "embedded-hal"]
stm32f1 = ["stm32f1xx-hal", "embedded-hal"]
stm32f4 = ["stm32f4xx-hal", "embedded-hal"]
nrf52840 = ["nrf52840-hal", "embedded-hal"]
esp32 = ["esp-hal", "embedded-hal"]
rp2040 = ["rp2040-hal", "embedded-hal"]

# RTOS features (exclusive within category)
rtic = ["dep:rtic", "critical-section"]
embassy = ["embassy-executor", "embassy-time"]

Error conditions

Authoring errors (cargo bp validate)

RuleConditionMessage
format.categories.definedAn item’s categories list references an undefined categoryfeature 'stm32f4' references undefined category 'hal'
format.features.exclusive-conflictTwo or more features in the same at-most-one category are both in defaultfeatures 'jemalloc' and 'mimalloc-alloc' are both in default but belong to at-most-one category 'allocator'
format.categories.emptyA category is declared but nothing references itwarning: category 'foo' is declared but has no members
format.categories.pick-missing-titleA category has pick = "at-most-one" but no titlewarning: at-most-one category 'hal' should have a title for the picker UI
format.features.unknown-feature[package.metadata.battery-pack.features.X] where X is not in [features]feature metadata 'X' does not match any entry in [features]
format.dependencies.unknown-dep[package.metadata.battery-pack.dependencies.X] where X is not in any dependency sectiondependency metadata 'X' does not match any dependency
format.template.category-placeholder-mismatchA template placeholder uses options.category referencing an undefined categoryplaceholder 'allocator' references undefined category 'allocator'

Usage errors (cargo bp add)

ContextConditionMessage
Non-interactive -FTwo features from the same at-most-one categoryerror: features 'stm32f4' and 'nrf52840' are exclusive (category: hal)
Non-interactive -tTwo templates from the same at-most-one categoryerror: templates 'X' and 'Y' are exclusive (category: Z)
Interactive (picker)Enter with >1 selection in at-most-one categoryInline error: "category 'hal' allows at most one selection"
--all-featuresMultiple exclusive selectionsNo error — bypasses exclusive checks

Edge case: pre-existing multi-selection

If the user previously installed items via cargo add that conflict with an at-most-one constraint, the picker shows them honestly (multiple radio buttons filled) with a warning banner:

  ⚠ Multiple selections in "Global Allocator" — pick one to resolve

The user must deselect down to one (or zero) before the picker will confirm. Selecting a new item automatically deselects the others. On confirm, deselected crates are removed from Cargo.toml.

Impact on existing battery packs

backend-service-battery-pack

Today the allocator choice (jemalloc vs mimalloc-alloc) is two independent features with no expressed relationship — a user could enable both and get linker errors. The tower-http middleware layers are a flat list that would benefit from grouping.

[package.metadata.battery-pack.categories.allocator]
title = "Global Allocator"
description = "Pick a high-performance allocator (or use system default)"
pick = "at-most-one"

[package.metadata.battery-pack.categories.http-layers]
title = "HTTP Middleware Layers"
description = "Tower-HTTP middleware for your service"

[package.metadata.battery-pack.features.jemalloc]
description = "jemalloc (not available on MSVC)"
categories = ["allocator"]

[package.metadata.battery-pack.features.mimalloc-alloc]
description = "mimalloc (works everywhere including MSVC)"
categories = ["allocator"]

[package.metadata.battery-pack.features.http-trace]
description = "Request/response tracing spans"
categories = ["http-layers"]

[package.metadata.battery-pack.features.http-request-id]
description = "X-Request-Id propagation"
categories = ["http-layers"]

[package.metadata.battery-pack.features.http-timeout]
description = "Request timeout enforcement"
categories = ["http-layers"]

[package.metadata.battery-pack.features.http-catch-panic]
description = "Convert panics to 500 responses"
categories = ["http-layers"]

The picker renders allocators as radio buttons (picking one deselects the other), and middleware layers as a checkbox group under a clear heading.

The service template’s bp-template.toml also benefits — its allocator placeholder uses options.category instead of a hardcoded list:

[placeholders.allocator]
type = "select"
options.category = "allocator"
prompt = "Global allocator"
default = "jemalloc"

ci-battery-pack

This pack is template-heavy. Its 13 templates are currently a flat list. Categories provide organizational grouping:

[package.metadata.battery-pack.categories.quality]
title = "Code Quality"
description = "Static analysis and testing tools"

[package.metadata.battery-pack.categories.docs]
title = "Documentation"

Templates declare their categories:

[package.metadata.battery.templates]
fuzzing = { path = "...", description = "cargo-fuzz scaffold + CI workflows", categories = ["quality"] }
mutation-testing = { path = "...", description = "Mutation testing with cargo-mutants", categories = ["quality"] }
spellcheck = { path = "...", description = "crate-ci/typos config + CI workflow", categories = ["quality"] }
clippy-sarif = { path = "...", description = "Clippy with GitHub PR annotations", categories = ["quality"] }
security-scanning = { path = "...", description = "RustSec audit workflow", categories = ["quality"] }
mdbook = { path = "...", description = "mdBook scaffold + GitHub Pages deployment", categories = ["docs"] }

All categories here use pick = "any" (the default) — purely organizational. The picker shows items grouped under meaningful headings instead of a flat list.

cli-battery-pack

No exclusive choices — features are genuinely additive. Categories help organize the picker:

[package.metadata.battery-pack.categories.output]
title = "Terminal Output"
description = "Color, hyperlinks, and progress display"

[package.metadata.battery-pack.categories.input]
title = "User Input"
description = "Argument parsing and interactive prompts"

[package.metadata.battery-pack.features.indicators]
description = "Progress bars and spinners (indicatif + console)"
categories = ["output"]

[package.metadata.battery-pack.features.search]
description = "Regex search with .gitignore-aware file walking"

[package.metadata.battery-pack.features.config]
description = "XDG/platform config directories (etcetera)"
categories = ["input"]

logging-battery-pack / error-battery-pack

Too small to benefit. No change needed.

Interaction with existing features

  • Items without category annotations work exactly as before.
  • A single battery pack can mix categorized and uncategorized items freely.
  • cargo bp add --all-features skips validation for exclusive categories (since the user explicitly asked for everything — useful for CI builds that test all combinations).
  • battery-pack.toml records which features were chosen, unchanged from today’s format.

Future work

  • Cargo globals. If/when Cargo gets mutually-exclusive globals, we’d read Cargo’s native format and deprecate custom metadata.

  • Conditional visibility / requires. jlizen raised a use case where templates should only be visible if a certain feature is selected (e.g., GitHub-specific templates only shown when github feature is active). This is a filtering mechanism orthogonal to categories.

  • Pack extension. There’s no mechanism for one battery pack to extend another (shared categories, feature forwarding, template inheritance). A single big pack works for v1; extension is a separate RFD.

  • Shared item identity across categories. When an item belongs to multiple categories it appears as independent rows in the picker. Toggling it in one section doesn’t live-update its copy in the other. The confirmed result is correct (decoded by name into a set), but the picker visually desyncs during interaction. Fixing this means adding an Option<String> identity to SectionItem and propagating state across entries sharing an id. This also introduces edge cases — e.g., an item in both an at-most-one and an any category could be checked in the any section, putting the at-most-one section into an invalid state. Needs its own design pass.

Prior art

  • awesome-embedded-rust: flat curated list organized by vendor, no tooling support
  • Cargo pre-RFC for mutually-exclusive globals: build-system-level enforcement of exclusive choices
  • Gentoo USE flags: global configuration flags with profile defaults
  • Homebrew formulae with conflicts: conflicts_with declarations between packages
  • VS Code extension packs: curated bundles with categorized alternatives

Requirements: Categories and Exclusive Picks

Testable requirements for the Categories and Exclusive Picks RFD. Each requirement has a unique ID (r[...]) and maps to one or more tests.


Parsing

r[parse.category-definition] A [package.metadata.battery-pack.categories.<name>] table with title, description, and pick fields is parsed into a CategorySpec.

  • Test: Parse a manifest with categories.hal containing all three fields. Assert spec.categories["hal"].pick == AtMostOne, title and description match.

r[parse.category-pick-default] A category with no pick field defaults to PickMode::Any.

  • Test: Parse a manifest with categories.portable containing only title. Assert spec.categories["portable"].pick == Any.

r[parse.feature-metadata] [package.metadata.battery-pack.features.<name>] with description and categories fields is parsed into an ItemMeta stored in feature_meta.

  • Test: Parse manifest with features.stm32f4 containing categories = ["hal"] and description = "STM32F4xx". Assert correct values in spec.feature_meta["stm32f4"].

r[parse.dependency-metadata] [package.metadata.battery-pack.dependencies.<name>] with description and categories fields is parsed into an ItemMeta stored in dep_meta.

  • Test: Parse manifest with dependencies.embedded-hal containing categories = ["portable"]. Assert correct values in spec.dep_meta.

r[parse.template-categories] A template entry in [package.metadata.battery.templates] with a categories field stores the category list on TemplateSpec.

  • Test: Parse manifest with template fuzzing having categories = ["quality"]. Assert spec.templates["fuzzing"].categories.

r[parse.multiple-categories] An item’s categories field is a list; an item can belong to multiple categories.

  • Test: Parse categories = ["quality", "ci"]. Assert both stored.

r[parse.no-metadata-backward-compat] Items with no metadata entry parse identically to today — no new fields affect existing behavior.

  • Test: Parse a manifest with zero [package.metadata.battery-pack.features.*] entries. Assert feature_meta is empty and all other spec fields unchanged.

r[parse.options-category-in-template] A template placeholder with options.category = "allocator" is parsed as a category-derived options source (untagged enum: literal array vs category ref).

  • Test: Parse a bp-template.toml with options.category = "allocator". Assert the placeholder’s options source is Category("allocator").

r[parse.options-literal-array] A template placeholder with options = ["a", "b", "c"] continues to work.

  • Test: Parse options = ["jemalloc", "mimalloc", "system"]. Assert the placeholder’s options source is Literal(["jemalloc", ...]).

Validation

r[validate.categories-defined] Every category name referenced in an item’s categories list must have a corresponding [package.metadata.battery-pack.categories.<name>] entry.

  • Test (feature): Feature with categories = ["nonexistent"] → error format.categories.defined.
  • Test (dependency): Dep with categories = ["bogus"] → error format.categories.defined.
  • Test (template): Template with categories = ["bogus"] → error format.categories.defined.
  • Test (partial): categories = ["hal", "bogus"] where hal exists → error only for "bogus".

r[validate.exclusive-conflict-in-default] If two or more features belonging to the same at-most-one category are both listed in the [features] default array, emit an error.

  • Test (error): Two features in at-most-one category, both in default → error format.features.exclusive-conflict.
  • Test (ok, any): Two features in any category, both in default → no error.
  • Test (ok, one in default): Two exclusive features, only one in default → no error.

r[validate.empty-category-warns] A declared category that no item references produces a warning.

  • Test: Category foo declared, no feature/dep/template lists it → warning format.categories.empty.

r[validate.at-most-one-missing-title] A category with pick = "at-most-one" and no title produces a warning.

  • Test: Category with pick = "at-most-one", no title → warning format.categories.pick-missing-title.

r[validate.feature-metadata-unknown] [package.metadata.battery-pack.features.X] where X does not appear as a key in [features] is an error.

  • Test: Metadata for features.foo where foo not in [features] → error format.features.unknown-feature.

r[validate.dep-metadata-unknown] [package.metadata.battery-pack.dependencies.X] where X does not appear in any dependency section is an error.

  • Test: Metadata for dependencies.foo where foo is not a dependency → error format.dependencies.unknown-dep.

r[validate.template-category-placeholder] A template placeholder using options.category = "X" where X is not a declared category is an error.

  • Test: options.category = "nonexistent" → error format.template.category-placeholder-mismatch.

Picker: selection behavior

r[picker.radio-toggle-deselects-others] In a Radio section, toggling an unchecked item checks it and unchecks all other items in that section.

  • Unit test: Section with items A(checked), B, C. Toggle B → A unchecked, B checked, C unchanged.

r[picker.radio-toggle-allows-deselect] In a Radio section, toggling an already-checked item unchecks it (allows zero selections).

  • Unit test: Section with A(checked). Toggle A → nothing checked.

r[picker.radio-backspace-clears] In a Radio section, pressing Backspace clears all selections in the current category.

  • Unit test: Section with A(checked). Backspace → nothing checked.

r[picker.checkbox-toggle-independent] In a Checkbox section, toggling an item does not affect other items.

  • Unit test: Section with A(checked), B. Toggle B → A still checked, B checked.

r[picker.radio-section-toggle-noop] toggle_current_section() (a key) in a Radio section is a no-op.

  • Unit test: Radio section with A(checked). Press a → A still checked, no change.

r[picker.checkbox-section-toggle-selects-all] toggle_current_section() (a key) in a Checkbox section checks all items (existing behavior preserved).

  • Unit test: Checkbox section with A(checked), B, C. Section toggle → all checked.

r[picker.radio-pre-existing-multiple] If a Radio section is initialized with multiple items checked, the state is preserved honestly (no auto-deselection on load).

  • Unit test: Construct Radio section with A(checked), B(checked). Assert into_results() shows both checked.

r[picker.radio-pre-existing-toggle-clears-all-others] In a Radio section with multiple items pre-checked, toggling a new item clears all others.

  • Unit test: Radio section with A(checked), B(checked). Toggle C → only C checked.

r[picker.confirm-blocked-on-radio-conflict] try_confirm() returns an error when a Radio section has more than one item checked.

  • Unit test: Radio section with A(checked), B(checked). Call try_confirm()Err("...") containing the section title.

r[picker.confirm-succeeds-on-valid-state] try_confirm() succeeds when all Radio sections have 0 or 1 selection.

  • Unit test: Radio section with A(checked). Call try_confirm()Ok(results).

Picker: navigation and collapsing

r[picker.collapse-hides-from-navigation] Collapsing a section causes move_down/move_up to skip its items.

  • Unit test: Two sections. Collapse first. Cursor at top, move down → lands in second section.

r[picker.expand-restores-navigation] Expanding a collapsed section restores normal traversal through its items.

  • Unit test: Collapse then expand first section. move_down from header → lands on first item.

r[picker.collapsed-results-preserved] Collapsed sections’ checked state is included in into_results().

  • Unit test: Check item, collapse section, call into_results() → item shows as checked.

r[picker.left-arrow-collapses] Pressing Left on a section header collapses that section.

  • Integration test (key event simulation): Send Left on header → section becomes collapsed.

r[picker.right-arrow-expands] Pressing Right on a section header expands that section.

  • Integration test: Collapsed section, send Right → section expands.

Picker: rendering

r[picker.render-radio-bullets] Radio items render with (checked) and (unchecked) instead of [x]/[ ].

  • Unit test (render snapshot): Radio section with one checked item → output contains and .

r[picker.render-checkbox-squares] Checkbox items continue to render with [x]/[ ].

  • Unit test (render snapshot): Checkbox section → output contains [x] and [ ].

r[picker.render-collapsed-chevron] Collapsed section headers render with ; expanded with .

  • Unit test (render snapshot): Collapsed section → in output.

r[picker.render-at-most-one-hint] Radio section headers include the text “(pick at most one)”.

  • Unit test (render snapshot): Radio section header → output contains (pick at most one).

r[picker.render-warning-banner] When a Radio section has >1 item checked on initial render, a warning line is displayed.

  • Unit test (render snapshot): Radio section with 2 checked → output contains warning text.

r[picker.render-descriptions] When items have descriptions, they are shown alongside the item name.

  • Unit test (render snapshot): Item with description → output contains the description text.

CLI: interactive picker wiring

r[cli.picker-categories-become-sections] When a battery pack has category definitions, the picker groups items by category — one section per category, with the category title as section header.

  • Integration test: Pack with hal (at-most-one) and utils (any) → picker has sections titled “Hardware Abstraction Layer” and “Utilities”.

r[cli.picker-radio-for-at-most-one] Sections for at-most-one categories use SelectionMode::Radio.

  • Integration test: Pack with at-most-one category → section has Radio mode.

r[cli.picker-checkbox-for-any] Sections for any categories use SelectionMode::Checkbox.

  • Integration test: Pack with any category → section has Checkbox mode.

r[cli.picker-uncategorized-in-generic] Items not in any category appear in generic “Features” / “Dependencies” sections, exactly as today.

  • Integration test: Pack with some categorized and some uncategorized features → uncategorized appear in “Features:” section.

r[cli.picker-item-in-multiple-categories] An item belonging to multiple categories appears in each category’s section. Selection state is shared: toggling the item in one section updates its state in all sections where it appears.

  • Integration test: Feature with categories = ["quality", "ci"] → appears in both sections.
  • Integration test: Toggle item in “quality” section → also shown as checked in “ci” section.

r[cli.picker-category-item-order] Items within a category section appear in declaration order (the order their metadata entries appear in Cargo.toml).

  • Integration test: Features stm32f4, nrf52840, esp32 declared in that order, all in category hal → picker shows them in that order.

r[cli.picker-deselection-removes-dep] When a user deselects an item in an at-most-one category (by selecting another), the deselected crate is removed from the project’s Cargo.toml on confirm.

  • Integration test: Project has both jemalloc and mimalloc in Cargo.toml. User selects jemalloc in picker. After confirm, mimalloc is removed from Cargo.toml.

r[cli.picker-pre-existing-conflict-shown] If the project already has multiple items from an at-most-one category installed, the picker opens with all of them checked and shows a warning.

  • Integration test: Project has jemalloc + mimalloc. Open picker → both radio items shown as checked, warning banner visible.

CLI: non-interactive validation

r[cli.noninteractive-exclusive-conflict-error] When -F passes two features from the same at-most-one category, cargo bp add exits with an error naming both features and the category.

  • Integration test: cargo bp add pack -F stm32f4 -F nrf52840 → exit code 1, stderr contains “exclusive” and “hal”.

r[cli.noninteractive-any-category-ok] Two features from the same any category can be passed together without error.

  • Integration test: cargo bp add pack -F http-trace -F http-timeout → success.

r[cli.noninteractive-different-categories-ok] Features from different categories can always be combined.

  • Integration test: cargo bp add pack -F stm32f4 -F embassy → success.

r[cli.noninteractive-all-features-bypasses] --all-features bypasses exclusive constraint checking.

  • Integration test: cargo bp add pack --all-features with multiple exclusive features → success.

r[cli.noninteractive-template-exclusive-error] When -t passes two templates from the same at-most-one category, cargo bp add exits with an error.

  • Integration test: -t X -t Y where both in at-most-one category → exit code 1.

CLI: cargo bp show

r[cli.show-categories] cargo bp show displays categories with their member items grouped under the category title.

  • Integration test (snapshot): cargo bp show on a pack with categories → output contains “Categories:” section with expected structure.

r[cli.show-pick-mode-hint] At-most-one categories display “(pick at most one)” in show output.

  • Integration test (snapshot): Show output for at-most-one category → contains hint text.

r[cli.show-templates-in-categories] Templates assigned to categories appear under their category heading in cargo bp show output.

  • Integration test (snapshot): Pack with template in quality category → show output lists template under “Code Quality” heading.

CLI: cargo bp validate

r[cli.validate-clean-pack] A battery pack with correct category metadata passes validation.

  • Integration test: Run cargo bp validate on fixture with valid categories → exit code 0.

r[cli.validate-reports-errors] A battery pack with invalid category references fails validation with the appropriate rule ID in the output.

  • Integration test: Run cargo bp validate on fixture with categories = ["nonexistent"] → exit code 1, output contains format.categories.defined.

Template: category-linked placeholders

r[template.options-category-derives-list] A placeholder with options.category = "allocator" has its options list derived from the set of items in that category.

  • Unit test: Spec with category allocator containing features jemalloc and mimalloc. Resolve placeholder → options are ["jemalloc", "mimalloc"].

r[template.options-category-prefill-from-picker] If the user already selected an item from the category in the picker, the placeholder is pre-filled without prompting.

  • Unit test: Active features include jemalloc (in allocator category). Resolve placeholder → value is "jemalloc", no prompt.

r[template.options-category-prompts-if-no-selection] If no item from the category was selected in the picker, the placeholder prompts the user (interactive) or uses the default (non-interactive).

  • Unit test (non-interactive): No active feature in category, default is "jemalloc" → value resolves to "jemalloc".

r[template.options-category-unknown-error] A placeholder referencing an undefined category produces a clear error.

  • Unit test: options.category = "nonexistent" → error message.

r[template.options-category-picks-up-new-members] Adding a new feature to a category automatically includes it in the placeholder’s options without editing bp-template.toml.

  • Unit test: Add feature system to allocator category → options list now includes "system".

r[template.options-category-dep-uses-dep-name] When a category contains dependencies (not features), the option value is the dependency name (the key from [dependencies]).

  • Unit test: Category allocator contains dep tikv-jemallocator. Placeholder options include "tikv-jemallocator".

Invariants

r[invariant.pack-scoped-categories] Categories are scoped to the battery pack that defines them. Two different installed packs can both define a category with the same name without conflict — their constraints are enforced independently.

  • Integration test: Install two packs, both defining category hal (at-most-one). Select one item from each pack’s hal category → no error.

r[invariant.battery-pack-toml-unchanged] The battery-pack.toml file format (which records installed packs and active features in user projects) is unchanged by this feature. Category metadata does not appear in battery-pack.toml.

  • Integration test: Run cargo bp add pack -F stm32f4 where stm32f4 is in a category. Inspect battery-pack.toml → format matches existing schema, no category fields present.

r[invariant.noninteractive-dep-exclusive-conflict] When a dependency (not wrapped in a feature) belongs to an at-most-one category and the user requests multiple such deps non-interactively, it is an error.

  • Integration test: Pack has deps tikv-jemallocator and mimalloc both in at-most-one category allocator. Non-interactive add of both → error.