What is a Battery Pack?
Rust is famously "batteries not included" — the standard library is small, and for most real-world tasks you need to pull in crates from the ecosystem. But which crates? With what features? In what combination?
A battery pack answers those questions. It's a curated collection of crates for a specific domain — error handling, CLI tooling, async programming, web services — assembled by someone who's thought carefully about what works well together.
Curation, not abstraction
Battery packs don't wrap or re-export crates. You use the real crates directly,
with their real APIs, their real docs, their real proc macros. A battery pack
just tells cargo bp which crates to install and how to configure them.
Think of a battery pack like a shopping list written by an expert. You don't have to use everything on the list, and you can always add your own items. But the list gives you a solid starting point.
A quick example
Say you're building a CLI tool and you want the cli-battery-pack:
cargo bp add cli
This adds clap and dialoguer to your [dependencies] — the battery pack's defaults.
Your code uses them directly:
#![allow(unused)] fn main() { use clap::Parser; use dialoguer::Input; #[derive(Parser)] struct Cli { #[arg(short, long)] name: String, } }
Want progress bars too? The cli-battery-pack has an indicators feature:
cargo bp add cli -F indicators
Now you also have indicatif and console in your dependencies.
How it works
Under the hood, a battery pack is a crate published on crates.io. It has no real code — just a Cargo.toml listing curated dependencies, documentation, examples, and optionally templates for bootstrapping new projects.
When you run cargo bp add, the CLI:
- Downloads the battery pack's Cargo.toml from crates.io
- Reads its dependencies and features
- Adds the selected crates to your Cargo.toml as real dependencies
- Records which battery pack they came from (in
[package.metadata])
The battery pack itself is never compiled as part of your project.
It's purely a source of truth for cargo bp to read.
The TUI
Running cargo bp with no arguments opens an interactive terminal interface
where you can:
- Browse and search available battery packs
- Add or remove battery packs from your project
- Toggle individual crates on and off
- Choose whether each crate is a runtime, dev, or build dependency
- Create new projects from battery pack templates
The subcommands (cargo bp add, cargo bp status, etc.) are there for
scripting and quick one-off operations, but the TUI is the primary experience.
What's next
- Getting Started walks you through installing the CLI and using your first battery pack
- Using Battery Packs covers the full range of
cargo bpfunctionality - If you want to create your own battery pack, see the Author's Guide
Getting Started
Install the CLI
cargo install battery-pack
This gives you the cargo bp command.
Create a new project from a template
Battery packs can include project templates. To start a new CLI application
using the cli-battery-pack 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 already in your Cargo.toml.
If a battery pack offers multiple templates, you can pick one:
cargo bp new cli --template simple
cargo bp new cli --template subcmds
To set placeholder values non-interactively (e.g. in CI), use -d:
cargo bp new cli --name my-app -d description="My CLI tool"
To preview what a template will generate without writing any files:
cargo bp new cli --preview
Add a battery pack to an existing project
If you already have a Rust project, you can add a battery pack to it:
cargo bp add error
This resolves error to error-battery-pack, downloads it from crates.io,
and adds its default crates to your project. For error-battery-pack,
that means anyhow and thiserror.
What changed in your Cargo.toml
Before:
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
After cargo bp add error:
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[package.metadata.battery-pack]
error-battery-pack = "0.4.0"
[dependencies]
anyhow = "1"
thiserror = "2"
The [package.metadata.battery-pack] section records which battery packs
you've installed and their versions. The actual crates — anyhow and thiserror —
are real entries in [dependencies] that you use directly.
Use the crates
There's nothing special about how you use the crates. They're real dependencies:
use anyhow::{Context, Result}; use thiserror::Error; #[derive(Error, Debug)] enum MyError { #[error("not found: {0}")] NotFound(String), } fn main() -> Result<()> { let config = std::fs::read_to_string("config.toml") .context("failed to read config")?; Ok(()) }
Proc macros, derive macros, attribute macros — everything works exactly as if you'd added the crates by hand. Because you did, with help.
Launch the TUI
For a richer experience, just run:
cargo bp
This opens an interactive terminal interface where you can browse available battery packs, toggle individual crates, and manage your dependencies visually. See Using Battery Packs for the full tour.
Check your battery pack status
To see which battery packs you have installed and whether anything is out of date:
cargo bp status
This shows your installed packs, their versions, and warnings if any of your dependency versions are older than what the battery pack recommends.
Using Battery Packs
The TUI
The primary way to interact with battery packs is the terminal UI.
Run cargo bp with no arguments:
cargo bp
The TUI is context-dependent. If you're inside a Rust project, you'll see:
- Your installed battery packs — toggle individual crates on and off, change dependency kinds, enable features
- Browse — search and add new battery packs from crates.io
- New project — create a project from a battery pack template
If you're not in a Rust project, the installed-packs section is greyed out, but you can still browse and create new projects.
Browsing available packs
From the TUI
The Browse tab in the TUI lets you search crates.io for battery packs. Select one to see its contents — which crates it includes, what features it offers, and what templates are available.
From the command line
cargo bp list # list all battery packs
cargo bp list cli # filter by name
cargo bp show cli # detailed view of cli-battery-pack
Adding a battery pack
Basic add
cargo bp add cli
This adds the battery pack's default crates to your project. Which crates are "default" is determined by the battery pack author (see Features below).
Adding with features
cargo bp add cli -F indicators
This adds the default crates plus the crates from the indicators feature.
You can also write --features indicators, or enable multiple features
with -F indicators,fancy or repeated -F indicators -F fancy.
Adding with no defaults
cargo bp add cli --no-default-features -F indicators
This adds only the indicators feature's crates, skipping the defaults.
Adding everything
cargo bp add cli --all-features
This adds every crate the battery pack offers, regardless of defaults or features.
Adding specific crates
cargo bp add cli clap indicatif
This adds just the named crates from the battery pack.
Features
Battery packs use Cargo's [features] to group related crates.
For example, cli-battery-pack might define:
[features]
default = ["clap", "dialoguer"]
indicators = ["indicatif", "console"]
fancy = ["clap", "indicatif", "console"]
- default — the crates you get with a plain
cargo bp add cli - indicators — progress bars and console styling
- fancy — argument parsing with color support, plus indicators
Features are additive. Enabling indicators on top of default gives you
all four crates. A feature can also augment the Cargo features of a crate
that's already included (e.g., adding the color feature to clap).
In the TUI, features appear as toggleable groups alongside individual crate toggles.
Dependency kinds
By default, each crate is added with the same dependency kind it has in the battery pack's Cargo.toml:
- A crate listed in the battery pack's
[dev-dependencies]becomes a[dev-dependencies]entry in your project - A crate in
[dependencies]becomes a regular dependency - A crate in
[build-dependencies]becomes a build dependency
You can override this in the TUI — for instance, promoting a dev-dependency to a regular dependency, or vice versa.
Keeping in sync
Checking status
cargo bp status
This shows your installed battery packs and highlights any mismatches.
If a battery pack recommends clap 4.5 but you have clap 4.3, you'll
see a warning. Having a newer version than recommended is fine.
Syncing
cargo bp sync
This updates your dependencies to match the installed battery packs:
- Bumps versions that are older than what the battery pack recommends
- Adds features the battery pack has added since your last sync
- Adds new crates if they've been added to your active features
Sync is non-destructive — it only adds and upgrades, never removes.
Workspaces
When your crate is part of a Cargo workspace, cargo bp is workspace-aware:
- Battery pack registrations go in
[workspace.metadata.battery-pack]by default (you can toggle this in the TUI to use per-crate metadata instead) - Dependencies are added to
[workspace.dependencies]and referenced ascrate = { workspace = true }in the crate's[dependencies]
This keeps versions centralized and consistent across workspace members.
For per-crate battery packs (where only one workspace member uses a pack), you can store the registration and dependencies at the crate level instead.
Local sources
You can point cargo bp at a local workspace containing battery packs
instead of (or in addition to) crates.io. This is useful for:
- Testing — validate your battery pack before publishing
- Organizations — maintain internal battery packs in a monorepo
- Development — iterate on a battery pack alongside the project using it
cargo bp --source ../my-battery-packs add cli
cargo bp --source ../my-battery-packs
The --source flag takes a path to a Cargo workspace. cargo bp
discovers all *-battery-pack crates within it automatically.
Local sources take precedence over crates.io, so if both have
cli-battery-pack, the local one wins.
You can combine multiple sources:
cargo bp --source ../team-packs --source ../my-packs list
For a single battery pack directory (not a workspace), use --path:
cargo bp add my-pack --path ../my-battery-pack
Multiple battery packs
A project can use multiple battery packs:
[package.metadata.battery-pack]
error-battery-pack = "0.4.0"
cli-battery-pack = "0.3.0"
async-battery-pack = "0.2.0"
[dependencies]
anyhow = "1"
thiserror = "2"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
Each battery pack tracks its own metadata. If two battery packs recommend the same crate with different features, the features are merged (unioned) — this is always safe.
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, examples, and optionally templates.
Scaffolding
The fastest way to start is:
cargo bp new battery-pack --name my-battery-pack
This creates a new battery pack project from the built-in template, complete with the right structure, a starter README, and license files.
Anatomy of a battery pack
Here's what a battery pack looks like:
my-battery-pack/
├── Cargo.toml
├── README.md
├── docs.handlebars.md
├── src/
│ └── lib.rs
├── examples/
│ ├── basic.rs
│ └── advanced.rs
└── templates/
└── default/
├── bp-template.toml
├── Cargo.toml
└── src/
└── main.rs
The important parts:
- Cargo.toml — defines the curated crates as dependencies
- README.md — your prose documentation
- docs.handlebars.md — template for auto-generated docs on docs.rs
- src/lib.rs — just a doc include (no real code)
- examples/ — runnable examples showing the crates in action
- templates/ — project templates for
cargo bp new
Defining crates
Your curated crates are just regular Cargo dependencies. The dependency section they live in determines the default dependency kind for users:
[dependencies]
anyhow = "1"
thiserror = "2"
[dev-dependencies]
expect-test = "1.5"
[build-dependencies]
cc = "1"
When a user installs your battery pack:
anyhowandthiserrordefault to regular dependenciesexpect-testdefaults to a dev-dependencyccdefaults to a build-dependency
Users can override these in the TUI.
Features for grouping
Use Cargo's [features] to organize crates into groups:
[dev-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"]
fancy = ["clap", "indicatif", "console"]
The default feature
The default feature determines which crates a user gets with a plain
cargo bp add. Crates not in default are available but not installed
unless the user explicitly enables 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.
Optional crates
Mark crates as optional = true if they shouldn't be part of the default
installation. These crates are available through named features or
individual selection in the TUI.
Feature augmentation
A feature can add Cargo features to a crate, not just toggle it on.
This uses Cargo's native dep/feature syntax:
[dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
[features]
default = ["tokio"]
tokio-full = ["tokio/full"]
Enabling tokio-full keeps tokio but adds the full feature on top
of macros and rt. Feature merging is always additive.
Hidden dependencies
If your battery pack has dependencies that are internal tooling — not
something users would want to install — mark them as hidden. Every
battery pack should hide the battery-pack build dependency (used for
doc generation), along with any other internal crates:
[package.metadata.battery-pack]
hidden = ["battery-pack", "handlebars", "cargo-metadata"]
Hidden crates don't appear in the TUI or in cargo bp show output.
You can use globs:
[package.metadata.battery-pack]
hidden = ["serde*"]
Or hide everything (useful if your battery pack is purely templates and examples):
[package.metadata.battery-pack]
hidden = ["*"]
The lib.rs
A battery pack's lib.rs is minimal — it just includes auto-generated documentation:
#![allow(unused)] #![doc = include_str!(concat!(env!("OUT_DIR"), "/docs.md"))] fn main() { }
This makes the battery pack's documentation visible on docs.rs, including an auto-generated table of all curated crates. See Documentation and Examples for details on how the doc generation works.
Templates
Templates let users bootstrap new projects with cargo bp new.
They use MiniJinja templates
with a bp-template.toml configuration file.
A template lives in a subdirectory under templates/:
templates/
└── default/
├── bp-template.toml
├── Cargo.toml
└── src/
└── main.rs
The bp-template.toml configures template variables:
ignore = ["hooks"]
[placeholders.description]
type = "string"
prompt = "What does this project do?"
default = "A new project"
Placeholders should have default values so that cargo bp validate
can generate and check templates non-interactively. Placeholder names
must use snake_case (description, not my-description) because
MiniJinja treats - as the minus operator.
The template engine also provides built-in variables (no declaration needed):
{{ project_name }}— the project name passed via--name{{ crate_name }}— derived fromproject_namewith-replaced by_
To include files from outside the template directory (e.g. shared
license files), use [[files]]:
[[files]]
src = "LICENSE-MIT" # relative to crate root
dest = "LICENSE-MIT" # relative to generated project
Register templates in your Cargo.toml metadata:
[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 can choose:
cargo bp new my-pack --template subcmds
Managed dependencies
Use bp-managed = true on dependencies in your template's Cargo.toml
instead of hardcoding versions. When someone generates a project from
your template, cargo bp resolves the actual versions from your
battery pack's spec:
[dependencies]
clap.bp-managed = true
[build-dependencies]
cli-battery-pack.bp-managed = true
[package.metadata.battery-pack]
cli-battery-pack = { features = ["default"] }
This way you don't need to update template files when you bump dependency versions. The template always picks up the current spec.
bp-managed = true replaces the entire dependency entry with the
version and features from the spec. If you need to pin a specific
version or customize features for a dependency, use an explicit
entry instead:
# Managed: version and features come from the spec:
anyhow.bp-managed = true
# Explicit: left as-is during resolution:
clap = { version = "4", features = ["derive", "color"] }
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.
To run template validation in your CI tests, add a test in your src/lib.rs:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[test] fn validate_templates() { battery_pack::testing::validate_templates(env!("CARGO_MANIFEST_DIR")).unwrap(); } } }
The built-in scaffolding template includes this test by default.
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 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.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.
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:
[package.metadata.battery.templates]
default = { path = "templates/default", description = "A basic starting point" }
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.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, as well as the bare cargo bp TUI.
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,
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.
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 launch
the interactive TUI.
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.target]
cargo bp add <pack> --target <level> controls where the battery
pack registration is stored. The <level> MUST be one of:
workspace— register in[workspace.metadata.battery-pack]package— register in[package.metadata.battery-pack]default— use workspace if a workspace root exists, otherwise package
If --target is not specified, the default behavior MUST be used.
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.
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.preview]
cargo bp new <pack> --preview MUST render the template and print
the resulting files to stdout without writing anything to disk.
Placeholders without a default MUST fall back to <name> so the
preview always succeeds. If --name is not provided, the preview
MUST use my-project as the project name.
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.
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]
cargo bp list --non-interactive MUST print results as plain text.
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]
cargo bp show --non-interactive MUST print results as plain text.
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).
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.
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 registration
r[manifest.register.location]
Battery pack registrations are stored in a [*.metadata.battery-pack]
table, where * is either package or workspace.
r[manifest.register.format] Each registration is a key-value pair where the key is the battery pack crate name and the value is the version string:
[package.metadata.battery-pack]
error-battery-pack = "0.4.0"
cli-battery-pack = "0.3.0"
r[manifest.register.workspace-default]
When a workspace root exists, battery pack registrations MUST default
to [workspace.metadata.battery-pack] in the workspace root Cargo.toml.
r[manifest.register.package-level]
The user MAY choose to register a battery pack at the package level
using [package.metadata.battery-pack] in the crate's own Cargo.toml.
This is for per-crate battery packs in a workspace.
r[manifest.register.both-levels]
cargo bp MUST support reading registrations from both workspace
and package metadata. When both exist, package-level registrations
take precedence for that crate.
Active features
r[manifest.features.storage] The active features for a battery pack MUST be stored alongside the registration in one of two forms:
- Full form:
cli-battery-pack = { features = ["default", "indicators"] } - Short form (when only the default feature is active):
cli-battery-pack = "0.3.0"
The short form is equivalent to { features = ["default"] }.
When the features key is absent, 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.
[dependencies]
clap.bp-managed = true
[build-dependencies]
cli-battery-pack.bp-managed = true
r[manifest.managed.conflict]
A dependency MUST NOT have both bp-managed = true and any other keys
(version, features, default-features, etc.). cargo bp MUST
error if bp-managed is combined with other dependency keys.
r[manifest.managed.resolution]
When generating a project from a template, cargo bp MUST resolve
each bp-managed dependency by replacing the entire entry with the
version and Cargo features from the battery pack's spec. 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.).
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}}.
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).