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 cliputsclapanddialoguerin yourCargo.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 bpto 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 and use your first battery pack
- Templates — scaffold projects and add CI workflows
- Our Battery Packs — what’s available today
- Create Your Own — publish a battery pack for your domain
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:
.tomlfiles are merged: new deps and sections are added, existing ones are left alone..yml/.yamlfiles 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
-Nmode, this is an error (since you can’t be prompted to confirm) unless--overwriteis 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
uto 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:
| Pack | Description |
|---|---|
| backend-service | Opinionated battery pack for resilient async backend services in Rust |
| ci | Battery pack for CI/CD workflows in Rust projects |
| cli | Battery pack for building CLI applications in Rust |
| embedded | Opinionated battery pack for embedded Rust — curates HALs, drivers, RTOSes, and no_std utilities from the awesome-embedded-rust ecosystem |
| error | Error handling done well — anyhow for apps, thiserror for libraries |
| logging | Battery 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)
| Name | Description |
|---|---|
jemalloc | jemalloc (not available on MSVC) |
mimalloc-alloc | mimalloc (works everywhere including MSVC) |
HTTP Middleware Layers
Tower-HTTP middleware for your service
| Name | Description |
|---|---|
http-catch-panic | Convert panics to 500 responses |
Crates: tower, tower-http | |
http-on-early-drop | Handle early client disconnects |
Crates: tower, tower-http | |
http-request-id | X-Request-Id propagation |
Crates: tower, tower-http | |
http-timeout | Request timeout enforcement |
Crates: tower, tower-http | |
http-trace | Request/response tracing spans |
Crates: tower, tower-http |
Dependencies
| Name | Description |
|---|---|
anyhow | Flexible concrete Error type built on std::error::Error |
axum | HTTP routing and request handling library that focuses on ergonomics and modularity |
clap | A simple to use, efficient, and full-featured Command Line Argument Parser |
criterion | |
dial9-tokio-telemetry | |
failsafe | |
http | A set of types for representing HTTP requests and responses. |
metrique | Library for generating wide event metrics |
metrique-util | Additional utilities for metrique |
moka | |
reqwest | higher level HTTP client library |
serde | A generic serialization/deserialization framework |
serde_json | A JSON serialization file format |
thiserror | derive(Error) |
tokio | An event-driven, non-blocking I/O platform for writing asynchronous I/O backed applications. |
tower_governor | |
tracing | Application-level tracing for Rust. |
tracing-appender | Provides utilities for file appenders and making non-blocking writers. |
tracing-subscriber | Utilities for implementing and composing tracing subscribers. |
Templates
| Name | Description |
|---|---|
service | Resilient 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
| Flag | Default | What it adds | Curated deps |
|---|---|---|---|
trusted_publishing | true | release-plz with OIDC trusted publishing | |
dependency_policy | true | cargo-deny license, bans, and source policy | |
binary_release | false | Cross-platform binary builds for GitHub Releases + cargo-binstall | |
benchmarks | false | Criterion bench scaffold + Bencher regression detection | criterion |
fuzzing | false | cargo-fuzz scaffold + PR smoke test + nightly extended run | libfuzzer-sys, arbitrary |
stress_tests | false | nextest stress test workflow | |
mdbook | false | mdBook scaffold + GitHub Pages deployment | |
spellcheck | false | typos config + workflow | |
xtask | false | cargo-xtask scaffold with codegen --check | xshell, xflags |
mutation_testing | false | cargo-mutants mutation testing | |
cross_platform | false | Test suite on macOS and Windows | |
clippy_sarif | false | Clippy 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
- Configure trusted publishing on crates.io
- 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:
- Create a fine-grained PAT with
contents: writeandpull-requests: writefor your repo - Add it as a
RELEASE_PLZ_TOKENrepo 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)
- Create a project on Bencher
- Add
BENCHER_API_TOKENas a repo secret - Add your project slug as a
BENCHER_PROJECTrepo 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
| Name | Description |
|---|---|
mdbook | mdBook scaffold + GitHub Pages deployment |
Code Quality
Static analysis and testing tools
| Name | Description |
|---|---|
benchmarks | Criterion bench scaffold + Bencher CI |
clippy-sarif | Clippy with GitHub PR annotations via SARIF |
dependency-policy | cargo-deny license, ban, and source checks |
fuzzing | cargo-fuzz scaffold + CI workflows |
Crates: arbitrary, libfuzzer-sys | |
mutation-testing | Mutation testing with cargo-mutants |
security-scanning | RustSec audit workflow |
spellcheck | crate-ci/typos config + CI workflow |
stress-test | nextest stress test workflow |
Dependencies
Templates
| Name | Description |
|---|---|
benchmarks | Criterion bench scaffold + Bencher CI |
binary-release | Cross-platform binary builds for GitHub Releases + cargo-binstall |
clippy-sarif | Clippy with GitHub PR annotations via SARIF |
dependency-policy | cargo-deny license, ban, and source checks |
full | Full CI setup with dependency policy and 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 |
security-scanning | RustSec audit workflow |
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 |
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
| Name | Description |
|---|---|
config | XDG/platform config directories (etcetera) |
Terminal Output
Color, hyperlinks, and progress display
| Name | Description |
|---|---|
indicators | Progress bars and spinners (indicatif + console) |
Crates: console, indicatif |
Dependencies
| Name | Description |
|---|---|
anstream | IO stream adapters for writing colored text that will gracefully degrade according to your terminal’s capabilities. |
anstyle | ANSI text styling |
anstyle-hyperlink | ANSI escape code hyperlinks (OSC 8) |
anyhow | Flexible concrete Error type built on std::error::Error |
clap | A simple to use, efficient, and full-featured Command Line Argument Parser |
colorchoice-clap | Clap mixin to override console colors |
dialoguer | A command line prompting library. |
human-panic | Panic messages for humans |
ignore | |
regex | An implementation of regular expressions for Rust. This implementation uses finite automata and guarantees linear time matching on all inputs. |
supports-hyperlinks | Detects whether a terminal supports rendering hyperlinks. |
wild | Glob (wildcard) expanded command-line arguments on Windows |
Dev dependencies
| Name | Description |
|---|---|
snapbox | Snapshot testing toolbox |
Templates
| Name | Description |
|---|---|
simple | Minimal CLI with argument parsing |
subcmds | CLI 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)
| Name | Description |
|---|---|
embassy | Embassy — async/await runtime for embedded |
Crates: embassy-executor, embassy-sync, embassy-time | |
rtic | RTIC — interrupt-driven real-time concurrency |
Crates: cortex-m, cortex-m-rt, critical-section, rtic |
Display & Graphics
Screens, LEDs, and 2D drawing
| Name | Description |
|---|---|
display-ssd1306 | SSD1306 OLED display (I2C/SPI, 128x64) |
Crates: embedded-graphics, ssd1306 | |
display-st7789 | ST7789 color LCD (SPI, used in PineTime) |
Crates: embedded-graphics, st7789 | |
embedded-graphics | 2D drawing library for any embedded display |
Popular Drivers
Platform-agnostic peripheral/sensor drivers via embedded-hal
| Name | Description |
|---|---|
display-ssd1306 | SSD1306 OLED display (I2C/SPI, 128x64) |
Crates: embedded-graphics, ssd1306 | |
display-st7789 | ST7789 color LCD (SPI, used in PineTime) |
Crates: embedded-graphics, st7789 | |
sensor-bme280 | BME280 temperature/humidity/pressure (I2C/SPI) |
sensor-lis3dh | LIS3DH 3-axis accelerometer (I2C/SPI) |
usb-device | USB 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)
| Name | Description |
|---|---|
atsamd | Microchip SAMD (Cortex-M0+/M4, Adafruit boards) |
Crates: atsamd-hal, cortex-m, cortex-m-rt, critical-section, embedded-hal | |
esp32 | ESP32 (Xtensa, WiFi + BT, via esp-hal no_std) |
Crates: embedded-hal, esp-hal | |
esp32c3 | ESP32-C3 (RISC-V, WiFi + BLE, via esp-hal no_std) |
Crates: embedded-hal, esp-hal | |
esp32s3 | ESP32-S3 (Xtensa, WiFi + BLE, via esp-hal no_std) |
Crates: embedded-hal, esp-hal | |
nrf52832 | Nordic nRF52832 (Cortex-M4F, BLE) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, nrf52832-hal | |
nrf52840 | Nordic nRF52840 (Cortex-M4F, BLE + USB) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, nrf52840-hal | |
nrf9160 | Nordic nRF9160 (Cortex-M33, LTE-M/NB-IoT) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, nrf9160-hal | |
rp2040 | RP2040 (Dual Cortex-M0+, Raspberry Pi Pico) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, rp2040-hal | |
stm32f0 | STM32F0xx family (Cortex-M0) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f0xx-hal | |
stm32f1 | STM32F1xx family (Cortex-M3, e.g. Blue Pill) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f1xx-hal | |
stm32f3 | STM32F3xx family (Cortex-M4F) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f3xx-hal | |
stm32f4 | STM32F4xx family (Cortex-M4F, e.g. F4 Discovery) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f4xx-hal | |
stm32f7 | STM32F7xx family (Cortex-M7) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32f7xx-hal | |
stm32h7 | STM32H7xx family (Cortex-M7, high-performance) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32h7xx-hal | |
stm32l0 | STM32L0xx family (ultra-low-power Cortex-M0+) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32l0xx-hal | |
stm32l4 | STM32L4xx family (low-power Cortex-M4F) |
Crates: cortex-m, cortex-m-rt, critical-section, embedded-hal, stm32l4xx-hal |
Logging & Debugging
Device logging and diagnostic tools
| Name | Description |
|---|---|
defmt-logging | defmt — efficient deferred formatting for constrained devices |
Crates: defmt, defmt-rtt | |
rtt-target | RTT (Real-Time Transfer) output channel |
Networking
no_std networking stacks and protocols
| Name | Description |
|---|---|
smoltcp-stack | smoltcp — no_std TCP/IP stack |
Panic Handler (pick at most one)
Choose how panics are surfaced on the device (mutually exclusive)
| Name | Description |
|---|---|
panic-halt | Halt the processor on panic |
Crates: defmt, defmt-rtt, panic-halt | |
panic-probe | Log panic via probe-rs debugger |
Crates: defmt, defmt-rtt, panic-probe | |
panic-rtt | Log panic via RTT (SEGGER/probe-rs) |
Crates: defmt, defmt-rtt, panic-rtt-target | |
panic-semihosting | Print panic via semihosting (Cortex-M only) |
Portable Ecosystem
Trait abstractions and utilities that work across any HAL
| Name | Description |
|---|---|
critical-section-impl | critical-section — cross-platform mutex primitive |
heapless-alloc | Static-friendly Vec, String, and data structures (no heap) |
embedded-hal | Trait abstractions for embedded I/O (v1.0) |
embedded-io | Read/Write traits for embedded byte streams |
embedded-storage | Traits for NOR flash and other storage |
Storage & Memory
Flash, EEPROM, and filesystem support
| Name | Description |
|---|---|
embedded-sdmmc | SD/MMC card with FAT16/FAT32 filesystem |
spi-flash | Generic SPI NOR flash driver |
Dev Tools
Testing, flashing, and development utilities
| Name | Description |
|---|---|
embedded-hal-mock | Mock embedded-hal traits for host-side testing |
embedded-test | On-device test harness (unit + integration tests) |
Dependencies
| Name | Description |
|---|---|
embedded-hal-mock | |
embedded-sdmmc | |
embedded-test | |
rtt-target |
Templates
| Name | Description |
|---|---|
blinky | Minimal 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
| Name | Description |
|---|---|
anyhow | Flexible concrete Error type built on std::error::Error |
thiserror | derive(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
| Name | Description |
|---|---|
tracing | Application-level tracing for Rust. |
tracing-subscriber | Utilities 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 true — bp-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-packitself) 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 docsdescription(optional) — explanatory text shown under the titlepick—"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.tomlfiles must be named_Cargo.toml.cargo packagetreats any subdirectory containing aCargo.tomlas a separate crate and excludes it. The template engine maps_Cargo.tomlback toCargo.tomlin 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 fromproject_namewith-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,permissionsdeep-merged- Everything else — user prompted to skip or overwrite
Tips for merge-friendly templates:
- Keep template
Cargo.tomlminimal — only what the template needs - Use
bp-managed = trueso versions stay current - Use unique filenames for workflows (e.g.,
typos.ymlnotci.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:
- README.md — your hand-written prose, displayed on crates.io
- docs.handlebars.md — a template that controls what appears on docs.rs
- build.rs — renders the template into
docs.mdat 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.mdpackage—{ 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-packcli-battery-packasync-battery-packweb-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
- README.md describes the battery pack clearly
- Examples are runnable (
cargo test --examples) - Templates work (
cargo bp new your-packfrom a temp directory) - Keywords include
battery-pack - License files are present (MIT and/or Apache-2.0 are conventional)
- 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/— thecargo-bpCLI and its helper cratesbattery-packs/— first-party battery packs (cli, error, ci, etc.)md/— this documentation (built with mdbook)md/spec/— the formal specificationmd/rfds/— design documents (Requests for Discussion)
Guides
- Ratatui testing guide — how to write snapshot tests for TUI components
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-teststores expected output inline in source code, updated withUPDATE_EXPECT=1 cargo testgoldiecompares against.goldenfiles in atestdata/directory, updated withGOLDIE_UPDATE=1 cargo testgoldenfileauto-compares on drop, updated withUPDATE_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.vt100parses those raw bytes into structured screen state with cell-level access including foreground/background colors, attributes, and cursor position. Thescreen().contents_diff(&old_screen)method enables incremental comparison.tui-termbridgesvt100output into ratatui’s widget system, rendering parsed terminal state as a ratatuiPseudoTerminalwidget.
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:
| Crate | Purpose | Downloads | Key testing use |
|---|---|---|---|
insta | Snapshot testing | Millions | Official ratatui recommendation for visual regression |
vt100 | VT100 terminal emulator | ~500K | Parse raw terminal output into structured screen state |
portable-pty | Cross-platform PTY | ~3M | Spawn TUI apps in real pseudo-terminals |
termwiz | Terminal emulation (WezTerm) | ~3M | Surface with change tracking; ratatui has a termwiz backend |
tui-term | PTY widget for ratatui | ~500K | Bridge vt100 output into ratatui buffers |
ratatui-testlib | PTY test harness | New | Purpose-built E2E testing for ratatui apps |
term-transcript | CLI snapshot testing | ~40K | SVG-based terminal output snapshots |
expectrl | Expect-style automation | ~200K | Scripted interactive TUI testing |
expect-test | Inline snapshots | ~1M | Expected 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 importsui.rs— pure rendering functions taking&Appand&mut Frame, zero state mutationmain.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).
How popular projects actually test their TUIs
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.toml → Cargo.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--namecrate_name— derived fromproject_nameby 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).
Main menu
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.
Navigation
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.tomlformat is subject to change in future versions. The file includes aversionfield 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:
| Form | Variant | Example |
|---|---|---|
foo | Feature("foo") | default = ["foo"] |
dep:foo | Dep("foo") | default = ["dep:foo"] |
foo/bar | DepFeature { dep, feature, weak: false } | fancy = ["serde/derive"] |
foo?/bar | DepFeature { dep, feature, weak: true } | fancy = ["serde?/derive"] |
pkg:foo/bar | Namespaced (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-packtoday)
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:
- Any selectable item (feature, dependency, or template) can have
metadata — including a
descriptionand zero or morecategories. - Categories have a title, a description, and a
pickmode (at-most-oneorany).
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 namecategories— 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 headerdescription— 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 addpicker, 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-onecategories 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 rancargo addmanually). 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 fromCargo.tomlon confirm.anycategories use checkboxes as today. Pressingaon a category header checks all items in that category (same as today’s section toggle). Forat-most-onecategories,ais 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-onecategories. - Validation on Enter: if an
at-most-onecategory 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)
| Rule | Condition | Message |
|---|---|---|
format.categories.defined | An item’s categories list references an undefined category | feature 'stm32f4' references undefined category 'hal' |
format.features.exclusive-conflict | Two or more features in the same at-most-one category are both in default | features 'jemalloc' and 'mimalloc-alloc' are both in default but belong to at-most-one category 'allocator' |
format.categories.empty | A category is declared but nothing references it | warning: category 'foo' is declared but has no members |
format.categories.pick-missing-title | A category has pick = "at-most-one" but no title | warning: 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 section | dependency metadata 'X' does not match any dependency |
format.template.category-placeholder-mismatch | A template placeholder uses options.category referencing an undefined category | placeholder 'allocator' references undefined category 'allocator' |
Usage errors (cargo bp add)
| Context | Condition | Message |
|---|---|---|
Non-interactive -F | Two features from the same at-most-one category | error: features 'stm32f4' and 'nrf52840' are exclusive (category: hal) |
Non-interactive -t | Two templates from the same at-most-one category | error: templates 'X' and 'Y' are exclusive (category: Z) |
| Interactive (picker) | Enter with >1 selection in at-most-one category | Inline error: "category 'hal' allows at most one selection" |
--all-features | Multiple exclusive selections | No 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-featuresskips validation for exclusive categories (since the user explicitly asked for everything — useful for CI builds that test all combinations).battery-pack.tomlrecords 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 whengithubfeature 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 toSectionItemand propagating state across entries sharing an id. This also introduces edge cases — e.g., an item in both anat-most-oneand ananycategory could be checked in theanysection, putting theat-most-onesection 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_withdeclarations 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.halcontaining all three fields. Assertspec.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.portablecontaining onlytitle. Assertspec.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.stm32f4containingcategories = ["hal"]anddescription = "STM32F4xx". Assert correct values inspec.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-halcontainingcategories = ["portable"]. Assert correct values inspec.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
fuzzinghavingcategories = ["quality"]. Assertspec.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. Assertfeature_metais 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.tomlwithoptions.category = "allocator". Assert the placeholder’s options source isCategory("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 isLiteral(["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"]→ errorformat.categories.defined. - Test (dependency): Dep with
categories = ["bogus"]→ errorformat.categories.defined. - Test (template): Template with
categories = ["bogus"]→ errorformat.categories.defined. - Test (partial):
categories = ["hal", "bogus"]wherehalexists → 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-onecategory, both in default → errorformat.features.exclusive-conflict. - Test (ok, any): Two features in
anycategory, 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
foodeclared, no feature/dep/template lists it → warningformat.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 → warningformat.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.foowherefoonot in[features]→ errorformat.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.foowherefoois not a dependency → errorformat.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"→ errorformat.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) andutils(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
anycategory → 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,esp32declared in that order, all in categoryhal→ 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
jemallocandmimallocin Cargo.toml. User selectsjemallocin picker. After confirm,mimallocis 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-featureswith 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 Ywhere 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 showon 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
qualitycategory → 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 validateon 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 validateon fixture withcategories = ["nonexistent"]→ exit code 1, output containsformat.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
allocatorcontaining featuresjemallocandmimalloc. 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(inallocatorcategory). 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
systemtoallocatorcategory → 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
allocatorcontains deptikv-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’shalcategory → 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 stm32f4wherestm32f4is in a category. Inspectbattery-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-jemallocatorandmimallocboth inat-most-onecategoryallocator. Non-interactive add of both → error.