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:

  1. Downloads the battery pack's Cargo.toml from crates.io
  2. Reads its dependencies and features
  3. Adds the selected crates to your Cargo.toml as real dependencies
  4. 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

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 as crate = { 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:

  • anyhow and thiserror default to regular dependencies
  • expect-test defaults to a dev-dependency
  • cc defaults 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 from project_name with - 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:

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

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

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

Writing your README

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

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

# error-battery-pack

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

## When to use what

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

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

The handlebars template

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

{{readme}}

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

The crate table

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

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

Custom templates

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

{{readme}}

## Curated Crates

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

{{#if features}}
## Features

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

The available template variables include:

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

Writing examples

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

Good examples:

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

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

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

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

Publishing

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

Keywords

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

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

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

Naming

Battery packs conventionally end in -battery-pack:

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

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

Versioning

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

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

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

Pre-publish checklist

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

Publishing

cargo publish

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

Battery Pack 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 --name
  • crate_name — derived from project_name by replacing - with _

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

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

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

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

Examples

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

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

Scaffolding

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

CLI Behavior

This section specifies the behavior of each cargo bp subcommand.

Crate sources

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

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

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

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

r[cli.source.subcommands] The --crate-source flag MUST be accepted by all subcommands that resolve battery packs: add, new, show, list, status, and sync, 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).

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.

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

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

TestBackend provides a full in-memory terminal

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

For building custom harnesses, the component crates work independently:

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

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

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

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

The ecosystem crate landscape at a glance

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

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

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

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

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

Rendering never panics for any valid state:

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

Layout constraints hold across arbitrary dimensions:

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

Arbitrary input sequences never crash the event handler:

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

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

Architecture determines testability

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

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

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

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

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

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

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

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

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

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

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

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

Conclusion

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

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

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