Standout
Test your data. Render your view.
Standout is a CLI framework for Rust that enforces separation between logic and presentation. Your handlers return structs, not strings—making CLI logic as testable as any other code.
The Problem
CLI code that mixes logic with println! statements is impossible to unit test:
#![allow(unused)] fn main() { // You can't unit test this—it writes directly to stdout fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); for todo in todos.iter() { if show_all || todo.status == Status::Pending { println!(" {} {}", if todo.done { "[x]" } else { "[ ]" }, todo.title); } } } }
The only way to test this is regex on captured stdout. That's fragile, verbose, and couples your tests to presentation details.
The Solution
With Standout, handlers return data. The framework handles rendering:
#![allow(unused)] fn main() { // This is unit-testable—it's a pure function that returns data fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let show_all = matches.get_flag("all"); let todos = storage::list()? .into_iter() .filter(|t| show_all || t.status == Status::Pending) .collect(); Ok(Output::Render(TodoResult { todos })) } #[test] fn test_list_filters_completed() { let matches = /* mock ArgMatches with all=false */; let result = list_handler(&matches, &ctx).unwrap(); assert!(result.todos.iter().all(|t| t.status == Status::Pending)); } }
Because your logic returns a struct, you test the struct. No stdout capture, no regex, no brittleness.
Standing Out
What Standout provides:
- Enforced architecture splitting data and presentation
- Logic is testable as any Rust code — and full CLI invocations are testable in-process via the
standout-testharness, without subprocess spawning or stdout parsing - Boilerplateless: declaratively link your handlers to command names and templates, Standout handles the rest
- Autodispatch: save keystrokes with auto dispatch from the known command tree
- Free output handling: rich terminal with graceful degradation, plus structured data (JSON, YAML, CSV)
- Finely crafted output:
- File-based templates for content and CSS for styling
- Rich styling with adaptive properties (light/dark modes), inheritance, and full theming
- Powerful templating through MiniJinja, including partials (reusable, smaller templates for models displayed in multiple places)
- Hot reload: changes to templates and styles don't require compiling
- Declarative layout support for tabular data
Quick Start
1. Define Your Commands and Handlers
Use the Dispatch derive macro to connect commands to handlers. Handlers receive parsed arguments and return serializable data.
#![allow(unused)] fn main() { use standout::cli::{Dispatch, CommandContext, HandlerResult, Output}; use clap::{ArgMatches, Subcommand}; use serde::Serialize; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] // handlers are in the `handlers` module pub enum Commands { List, Add { title: String }, } #[derive(Serialize)] struct TodoResult { todos: Vec<Todo>, } mod handlers { use super::*; // HandlerResult<T> wraps your data; Output::Render tells Standout to render it pub fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let todos = storage::list()?; Ok(Output::Render(TodoResult { todos })) } pub fn add(m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let title: &String = m.get_one("title").unwrap(); let todo = storage::add(title)?; Ok(Output::Render(TodoResult { todos: vec![todo] })) } } }
2. Define Your Presentation
Templates use MiniJinja with semantic style tags. Styles are defined separately in CSS.
{# list.jinja #}
[title]My Todos[/title]
{% for todo in todos %}
- {{ todo.title }} ([status]{{ todo.status }}[/status])
{% endfor %}
/* styles/default.css */
.title { color: cyan; font-weight: bold; }
.status { color: yellow; }
3. Wire It Up
use standout::cli::App; use standout::{embed_templates, embed_styles}; fn main() -> Result<(), Box<dyn std::error::Error>> { let app = App::builder() .commands(Commands::dispatch_config()) // Register handlers from derive macro .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .build()?; app.run(Cli::command(), std::env::args()); Ok(()) }
Run it:
myapp list # Rich terminal output with colors
myapp list --output json # JSON for scripting
myapp list --output yaml # YAML for config files
myapp list --output text # Plain text, no ANSI codes
Features
Architecture
- Logic/presentation separation enforced by design
- Handlers return data; framework handles rendering
- Unit-testable CLI logic without stdout capture
Output Modes
- Rich terminal output with colors and styles
- Automatic JSON, YAML, CSV serialization from the same handler
- Graceful degradation when terminal lacks capabilities
Rendering
- MiniJinja templates with semantic style tags
- CSS stylesheets with light/dark mode support
- Hot reload during development—edit templates without recompiling
- Tabular layouts with alignment, truncation, and Unicode support
Integration
- Clap integration with automatic dispatch
- Declarative command registration via derive macros
Installation
cargo add standout
Migrating an Existing CLI
Already have a CLI? Standout supports incremental adoption. Standout handles matched commands automatically; unmatched commands return ArgMatches for your existing dispatch:
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy your_existing_dispatch(matches); } }
See the Partial Adoption Guide for the full migration path.
Next Steps
- Introduction to Standout — Adopting Standout in a working CLI. Start here.
- Introduction to Testing — Why Standout CLIs are testable by design, and how the
standout-testharness replaces slow, brittle subprocess tests with fast in-process ones. - Introduction to Rendering — Creating polished terminal output
- Introduction to Tabular — Building aligned, readable tabular layouts
- All Topics — In-depth documentation for specific systems
Complete Working Example
A self-contained project you can copy, build, and run. This creates a simple todo list CLI with styled terminal output.
File Structure
my-todo/
├── Cargo.toml
├── src/
│ ├── main.rs
│ ├── templates/
│ │ └── list.jinja
│ └── styles/
│ └── default.css
Cargo.toml
[package]
name = "my-todo"
version = "0.1.0"
edition = "2021"
[dependencies]
standout = "7"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
src/main.rs
use clap::{ArgMatches, Parser, Subcommand}; use serde::Serialize; use standout::cli::{App, CommandContext, Dispatch, HandlerResult, Output}; use standout::{embed_styles, embed_templates}; #[derive(Parser)] #[command(name = "my-todo")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] enum Commands { /// List all todos List, } #[derive(Serialize)] struct TodoResult { todos: Vec<Todo>, } #[derive(Serialize)] struct Todo { title: String, status: String, } mod handlers { use super::*; pub fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { // Your real logic goes here — database queries, API calls, etc. let todos = vec![ Todo { title: "Write documentation".into(), status: "done".into() }, Todo { title: "Ship v1.0".into(), status: "pending".into() }, Todo { title: "Add tests".into(), status: "pending".into() }, ]; Ok(Output::Render(TodoResult { todos })) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .commands(Commands::dispatch_config()) .build()?; app.run(Cli::command(), std::env::args()); Ok(()) }
src/templates/list.jinja
[title]My Todos[/title]
{% for todo in todos %}
[index]{{ loop.index }}.[/index] [{{ todo.status }}]{{ todo.title }}[/{{ todo.status }}]
{% endfor %}
src/styles/default.css
.title {
color: cyan;
font-weight: bold;
}
.index {
color: yellow;
}
.done {
text-decoration: line-through;
color: gray;
}
.pending {
font-weight: bold;
color: white;
}
/* Adaptive: adjust for light terminals */
@media (prefers-color-scheme: light) {
.pending { color: black; }
}
Run It
cargo run -- list # Rich terminal output with colors
cargo run -- list --output json # JSON for scripting
cargo run -- list --output text # Plain text, no ANSI codes
What You Get
- Testable logic:
handlers::listis a pure function — test it by asserting on the returnedTodoResult - Free output modes: JSON, YAML, CSV, and plain text output from the same handler
- Hot reload: Edit
list.jinjaordefault.cssduring development — changes apply without recompiling (debug builds) - Adaptive styles: The
@mediaquery adjusts colors for light/dark terminals automatically
Next Steps
- Introduction to Standout — Full walkthrough with incremental steps
- Styling System — All CSS properties and adaptive styles
- Tabular Layout — Column alignment for table output
Fast Paced intro to your First Standout Based Command
This is a terse and direct how to for more experienced developers or at least the ones in a hurry. It skimps rationale, design and other useful bits you can read from the longer form version
Prerequisites
A cli app, that uses clap for arg parsing. A command function that is pure logic, that is, returns the result, and does not print to stdout or format output.
For this guide's purpose we'll use a fictitious "list" command of our todo list manager
The Core: A pure function logic handler
The logic handler: receives parsed cli args, and returns a serializable data structure:
#![allow(unused)] fn main() { pub fn list(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> {} }
Making it outstanding
1. The File System
Create a templates/list.jinja and styles/default.css:
src/
├── handlers.rs # where list it
├── templates/ # standout will match templates name against rel. paths from temp root, here
├── list.jinja # the template to render list, name matched against the command name
├── styles/ # likewise for themes, this sets a theme called "default"
├── default.css # the default style for the command, filename will be the theme name
2. Define your styles
.done {
text-decoration: line-through;
color: gray;
}
.pending {
font-weight: bold;
color: white;
}
.index {
color: yellow;
}
3. Write your template
{% if message %}
[message]{{ message }} [/message]
{% endif %}
{% for todo in todos %}
[index]{{ loop.index }}.[/index] [{{ todo.status }}]{{ todo.title }}[/{{ todo.status }}]
{% endfor %}
4. Putting it all together
Configure the app:
#![allow(unused)] fn main() { let app = App::builder() .app_state(Database::connect()?) // Optional: shared state for handlers .templates(embed_templates!("src/templates")) // Sets the root template path .styles(embed_styles!("src/styles")) // Likewise the styles root .default_theme("default") // Use styles/default.css .commands(Commands::dispatch_config()) // Register handlers from derive macro .build()?; }
Handlers access shared state via
ctx.app_state.get_required::<Database>()?. See App State and Extensions for details.
Connect your logic to a command name and template :
#![allow(unused)] fn main() { #[dispatch(handlers = handlers)] pub enum Commands { ... list, } }
And finally, run in main, the autodispatcher:
#![allow(unused)] fn main() { match app.run(Cli::command(), std::env::args()) { // If you've got other commands on vanilla manual dispatch, call it for unported commands RunResult::NoMatch(matches) => legacy_dispatch(matches), // Your existing handler } }
Standout How To
This is a small, focused guide for adopting Standout in a working shell application. Each step is self-sufficient, takes a positive step towards a sane CLI design, and can be incrementally merged. This can be done for one command (probably a good idea), then replicated to as many as you'd like.
Note that only 2 out of 8 steps are Standout related. The others are generally good practices and clear designs for maintainable shell programs. This is not an accident, as Standout's goal is to allow your app to keep a great structure effortlessly, while providing testability, rich and fast output design, and more.
For explanation's sake, we will show a hypothetical list command for tdoo, a todo list manager.
See Also:
- Handler Contract - detailed handler API
- App State and Extensions - dependency injection patterns
- Styling System - themes and styles in depth
- Output Modes - all output format options
- Partial Adoption - migrating incrementally
- Input Collection - declarative input from multiple sources
1. Start: The Argument Parsing
Arg parsing is insanely intricate and deceptively simple. In case you are not already: define your application's interface with clap. Nothing else is worth doing until you have a sane starting point.
If you don't have clap set up yet, here's a minimal starting point:
use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "tdoo")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// List all todos List { #[arg(short, long)] all: bool, }, /// Add a new todo Add { title: String, }, } fn main() { let cli = Cli::parse(); match cli.command { Commands::List { all } => list_command(all), Commands::Add { title } => add_command(&title), } }
(If you are using a non-clap-compatible crate, for now, you'd have to write an adapter for clap.)
Verify: Run
cargo build- it should compile without errors.
2. Hard Split Logic and Formatting
Now, your command should be split into two functions: the logic handler and its rendering. Don't worry about the specifics, do the straightest path from your current code.
This is the one key step, the key design rule. And that's not because Standout requires it, rather the other way around: Standout is designed on top of it, and keeping it separate and easy to iterate on both logic and presentation under this design is Standout's key value.
If your CLI is in good shape this will be a small task, otherwise you may find yourself patching together print statements everywhere, tidying up the data model and centralizing the processing. The silver lining here being: if it takes considerable work, there will be considerable gain in doing so.
Before (tangled logic and output):
#![allow(unused)] fn main() { fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); println!("-----------"); for (i, todo) in todos.iter().enumerate() { if show_all || todo.status == Status::Pending { let marker = if todo.status == Status::Done { "[x]" } else { "[ ]" }; println!("{}. {} {}", i + 1, marker, todo.title); } } if todos.is_empty() { println!("No todos yet!"); } } }
After (clean separation):
#![allow(unused)] fn main() { use clap::ArgMatches; // Data types for your domain #[derive(Clone)] pub enum Status { Pending, Done } #[derive(Clone)] pub struct Todo { pub title: String, pub status: Status, } pub struct TodoResult { pub message: Option<String>, pub todos: Vec<Todo>, } // This is your core logic handler, receiving parsed clap args // and returning a pure Rust data type. // // Note: This example uses immutable references. If your handler needs // mutable state (&mut self), see the "Mutable Handlers" section below. pub fn list(matches: &ArgMatches) -> TodoResult { let show_done = matches.get_flag("all"); let todos = storage::list().unwrap(); let filtered: Vec<Todo> = if show_done { todos } else { todos.into_iter() .filter(|t| matches!(t.status, Status::Pending)) .collect() }; TodoResult { message: None, todos: filtered, } } // This will take the Rust data type and print the result to stdout pub fn render_list(result: TodoResult) { if let Some(msg) = result.message { println!("{}", msg); } for (i, todo) in result.todos.iter().enumerate() { let status = match todo.status { Status::Done => "[x]", Status::Pending => "[ ]", }; println!("{}. {} {}", i + 1, status, todo.title); } } // And the orchestrator: pub fn list_command(matches: &ArgMatches) { render_list(list(matches)) } }
Verify: Run
cargo buildand thentdoo list- output should look identical to before.
Intermezzo A: Milestone - Logic and Presentation Split
What you achieved: Your command logic is now a pure function that returns data. What's now possible:
- All of your app's logic can be unit tested as any code, from the logic inwards.
- You can test by feeding input strings and verifying your logic handler gets called with the right parameters.
- The rendering can also be tested by feeding data inputs and matching outputs (though this is brittle).
What's next: Making the return type serializable for automatic JSON/YAML output. Your files now:
src/
├── main.rs # clap setup + orchestrators
├── handlers.rs # list(), add() - pure logic
└── render.rs # render_list(), render_add() - output formatting
3. Fine Tune the Logic Handler's Return Type
While any data type works, Standout's renderer takes a generic type that must implement Serialize. This enables automatic JSON/YAML output modes and template rendering through MiniJinja's context system. This is likely a small change, and beneficial as a baseline for logic results that will simplify writing renderers later.
Add serde to your Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
Update your types:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] pub struct Todo { pub title: String, pub status: Status, } #[derive(Serialize)] pub struct TodoResult { pub message: Option<String>, pub todos: Vec<Todo>, } }
Verify: Run
cargo build- it should compile without errors.
4. Replace Imperative Print Statements With a Template
Reading a template of an output next to the substituting variables is much easier to reason about than scattered prints, string concats and the like.
This step is optional - if your current output is simple, you can skip to step 5. If you want an intermediate checkpoint, use Rust's format strings:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output = format!( "{header}\n{todos}", header = result.message.unwrap_or_default(), todos = result.todos.iter().enumerate() .map(|(i, t)| format!("{}. [{}] {}", i + 1, t.status, t.title)) .collect::<Vec<_>>() .join("\n") ); println!("{}", output); } }
Verify: Run
tdoo list- output should still work.
5. Use a MiniJinja Template String
Rewrite your std::fmt or imperative prints into a MiniJinja template string, and add minijinja to your crate. If you're not familiar with it, it's a Rust implementation of Jinja, pretty much a de-facto standard for more complex templates.
Resources:
Add minijinja to your Cargo.toml:
[dependencies]
minijinja = "2"
And then you call render in MiniJinja, passing the template string and the data to use. So now your rendering function looks like this:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output_tmpl = r#" {% if message %} {{ message }} {% endif %} {% for todo in todos %} {{ loop.index }}. [{{ todo.status }}] {{ todo.title }} {% endfor %} "#; let env = minijinja::Environment::new(); let tmpl = env.template_from_str(output_tmpl).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should match (formatting may differ slightly).
6. Use a Dedicated Template File
Now, move the template content into a file (say src/templates/list.jinja), and load it in the rendering module. Dedicated files have several advantages: triggering editor/IDE support for the file type, more descriptive diffs, less risk of breaking the code/build and, in the event that you have less technical people helping out with the UI, a much cleaner and simpler way for them to contribute.
Create src/templates/list.jinja:
{% if message %}{{ message }} {% endif %}
{% for todo in todos %}
{{ loop.index }}. [{{ todo.status }}] {{ todo.title }}
{% endfor %}
Update your render function to load from file:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let template_content = include_str!("templates/list.jinja"); let env = minijinja::Environment::new(); let tmpl = env.template_from_str(template_content).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should be identical.
Intermezzo B: Declarative Output Definition
What you achieved: Output is now defined declaratively in a template file, separate from Rust code. What's now possible:
- Edit templates without recompiling (with minor changes to loading)
- Non-Rust developers can contribute to UI
- Clear separation in code reviews: "is this a logic change or display change?"
- Use partials, filters, and macros for complex outputs (see Templating)
What's next: Hooking up Standout for automatic dispatch and rich output. Also, notice we've yet to do anything Standout-specific. This is not a coincidence—the framework is designed around this pattern, making testability, fast iteration, and rich features natural outcomes of the architecture. Your files now:
src/
├── main.rs
├── handlers.rs
├── render.rs
└── templates/
└── list.jinja
7. Standout: Offload the Handler Orchestration
And now the Standout-specific bits finally show up.
7.1 Add Standout to your Cargo.toml
[dependencies]
standout = "2"
Verify: Run
cargo build- dependencies should download and compile.
7.2 Create Handlers with the #[handler] Macro
The #[handler] macro transforms pure Rust functions into Standout-compatible handlers. Write your logic as a simple function, annotate parameters, and the macro generates the wrapper that extracts CLI arguments.
See Handler Contract for full handler API details.
#![allow(unused)] fn main() { use standout_macros::handler; mod handlers { use super::*; // Pure function - easy to test, no boilerplate #[handler] pub fn list(#[flag] all: bool) -> Result<TodoResult, anyhow::Error> { let todos = storage::list()?; let filtered = if all { todos } else { todos.into_iter().filter(|t| !t.done).collect() }; Ok(TodoResult { message: None, todos: filtered }) } #[handler] pub fn add(#[arg] title: String) -> Result<TodoResult, anyhow::Error> { let todo = storage::add(&title)?; Ok(TodoResult { message: Some(format!("Added: {}", title)), todos: vec![todo], }) } } }
The #[handler] macro:
- Extracts CLI arguments automatically from
ArgMatches - Converts
#[flag]toboolflags,#[arg]to required/optional args - Auto-wraps
Result<T, E>inOutput::Renderfor you
Parameter Annotations:
| Annotation | Type | What it extracts |
|---|---|---|
#[flag] | bool | Boolean flag (--verbose) |
#[arg] | T | Required argument |
#[arg] | Option<T> | Optional argument |
#[arg] | Vec<T> | Multiple values |
#[ctx] | &CommandContext | Access to context (when needed) |
7.2.1 Connect Commands to Handlers
#![allow(unused)] fn main() { use clap::Subcommand; use standout::cli::Dispatch; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { List, Add, } }
The derive macro matches command variants to handler functions by name (List → handlers::list).
Verify: Run
cargo build- it should compile without errors.
7.2.2 Accessing App State (Optional)
When your handler needs shared resources like databases, use the #[ctx] annotation:
#![allow(unused)] fn main() { #[handler] pub fn list(#[flag] all: bool, #[ctx] ctx: &CommandContext) -> Result<TodoResult, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(TodoResult { message: None, todos }) } }
Note: For the full handler signature (without macros) and advanced patterns, see Handler Contract.
7.3 Configure AppBuilder
Use AppBuilder to configure your app. Instantiate the builder, add the path for your templates. See App Configuration for all configuration options.
#![allow(unused)] fn main() { use standout::cli::App; use standout::{embed_templates, embed_styles}; let app = App::builder() .templates(embed_templates!("src/templates")) // Embeds all .jinja/.j2 files .commands(Commands::dispatch_config()) // Register handlers from derive macro .build()?; }
Verify: Run
cargo build- it should compile without errors.
7.3.1 Injecting Shared State (Optional)
If your handlers need access to shared resources like database connections or configuration, use app_state:
#![allow(unused)] fn main() { let app = App::builder() .app_state(Database::connect()?) // Shared across all handlers .app_state(Config::load()?) .templates(embed_templates!("src/templates")) .commands(Commands::dispatch_config()) .build()?; }
Handlers retrieve app state via #[ctx]:
#![allow(unused)] fn main() { #[handler] pub fn list(#[flag] all: bool, #[ctx] ctx: &CommandContext) -> Result<TodoResult, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(TodoResult { message: None, todos }) } }
Note: For per-request state (user sessions, request IDs), use pre-dispatch hooks with
ctx.extensions. See App State and Extensions for the full story.
7.4 Wire up main()
The final bit: handling the dispatching off to Standout:
use standout::cli::App; use standout::embed_templates; fn main() -> anyhow::Result<()> { let app = App::builder() .templates(embed_templates!("src/templates")) .commands(Commands::dispatch_config()) .build()?; // Run with auto dispatch - handles parsing and execution app.run(Cli::command(), std::env::args()); Ok(()) }
If your app has other clap commands that are not managed by Standout, check for unhandled commands. See Partial Adoption for details on incremental migration.
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy legacy_dispatch(matches); } }
Verify: Run
tdoo list- it should work as before. Verify: Runtdoo list --output json- you should get JSON output for free!
And now you can remove the boilerplate: the orchestrator (list_command) and the rendering (render_list). You're pretty much at global optima: a single line of derive macro links your app logic to a command name, a few lines configure Standout, and auto dispatch handles all the boilerplate.
For the next commands you'd wish to migrate, this is even simpler. Say you have a "create" logic handler: add a "create.jinja" to that template dir, add the derive macro for the create function and that is it. By default the macro will match the command's name to the handlers and to the template files, but you can change these and map explicitly to your heart's content.
Intermezzo C: Welcome to Standout
What you achieved: Full dispatch pipeline with zero boilerplate.
What's now possible:
- Alter the template and re-run your CLI, without compilation, and the new template will be used
- Your CLI just got multiple output modes via
--output(see Output Modes):- term: rich shell formatting (more about this on the next step)
- term-debug: print formatting info for testing/debugging
- text: plain text, no styling
- auto: the default, rich term that degrades gracefully
- json, csv, yaml: automatic serialization of your data
- Pipe output to external commands (jq, clipboard, tee) via
pipe_through,pipe_to, orpipe_to_clipboard
What's next: Adding rich styling to make the output beautiful.
Your files now:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() with #[handler] - pure functions returning Result<T, E>
└── templates/
├── list.jinja
└── add.jinja
8. Make the Output Awesome
Let's transform that mono-typed, monochrome string into a richer and more useful UI. Borrowing from web apps setup, we keep the content in a template file, and we define styles in a stylesheet file.
See Styling System for full styling documentation.
8.1 Create the stylesheet
Create src/styles/default.css:
/* Styles for completed todos */
.done {
text-decoration: line-through;
color: gray;
}
/* Style for todo index numbers */
.index {
color: yellow;
}
/* Style for pending todos */
.pending {
font-weight: bold;
color: white;
}
/* Adaptive style for messages */
.message {
color: cyan;
}
@media (prefers-color-scheme: light) {
.pending { color: black; }
}
@media (prefers-color-scheme: dark) {
.pending { color: white; }
}
Verify: The file exists at
src/styles/default.css.
8.2 Add style tags to your template
Update src/templates/list.jinja with style tags:
{% if message %}[message]{{ message }}[/message]
{% endif %}
{% for todo in todos %}
[index]{{ loop.index }}.[/index] [{{ todo.status }}]{{ todo.title }}[/{{ todo.status }}]
{% endfor %}
The style tags use BBCode-like syntax: [style-name]content[/style-name]
Notice how we use [{{ todo.status }}] dynamically - if todo.status is "done", it applies the .done style; if it's "pending", it applies the .pending style.
Verify: The template file is updated.
8.3 Wire up styles in AppBuilder
Add the styles to your app builder:
#![allow(unused)] fn main() { let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) // Load stylesheets .default_theme("default") // Use styles/default.css .commands(Commands::dispatch_config()) .build()?; }
Verify: Run
cargo build- it should compile without errors. Verify: Runtdoo list- you should see colored, styled output! Verify: Runtdoo list --output text- plain text, no colors.
Now you're leveraging the core rendering design of Standout:
- File-based templates for content, and stylesheets for styles
- Custom template syntax with BBCode for markup styles
[style][/style] - Live reload: iterate through content and styling without recompiling
Intermezzo D: The Full Setup Is Done
What you achieved: A fully styled, testable, multi-format CLI.
What's now possible:
- Rich terminal output with colors, bold, strikethrough
- Automatic light/dark mode adaptation
- JSON/YAML/CSV output for scripting and testing
- Hot reload of templates and styles during development
- Unit testable logic handlers
Your final files:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() with #[handler] - pure functions
├── templates/
│ ├── list.jinja # with [style] tags
│ └── add.jinja
└── styles/
└── default.css
For brevity's sake, we've ignored a bunch of finer and relevant points:
- The derive macros can set name mapping explicitly:
#[dispatch(handler = custom_fn, template = "custom.jinja")] - There are pre-dispatch, post-dispatch and post-render hooks (see Execution Model)
- Output piping to external commands like
jq,tee, or clipboard (see below and Output Piping) - Standout exposes its primitives as standalone crates (see standout-render, standout-dispatch)
- Powerful tabular layouts via the
colfilter (see Tabular Layout) - A help topics system for rich documentation (see Topics System)
Bonus: Pipe Output to External Commands
Need to filter output through jq, log to a file with tee, or copy to clipboard? Standout supports piping rendered output to external commands:
#![allow(unused)] fn main() { #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { /// List todos, extracting just titles with jq #[dispatch(pipe_through = "jq '.todos[].title'")] List, /// Export todos to clipboard #[dispatch(pipe_to_clipboard)] Export, } }
Or via the builder API:
#![allow(unused)] fn main() { let app = App::builder() .commands(|g| { g.command_with("list", handlers::list, |cfg| { cfg.template("list.jinja") .pipe_through("jq '.todos'") // Filter JSON output }) .command_with("export", handlers::export, |cfg| { cfg.template("export.jinja") .pipe_to("tee /tmp/export.log") // Log while displaying .pipe_to_clipboard() // Then copy to clipboard }) }) .build()?; }
Three piping modes:
pipe_through("cmd"): Use command's stdout as new output (filters like jq, sort)pipe_to("cmd"): Run command but keep original output (side effects like tee)pipe_to_clipboard(): Send to system clipboard (pbcopy on macOS, xclip on Linux)
See Output Piping for the full API.
Bonus: Declarative Input Collection
Need to accept input from CLI arguments, piped stdin, environment variables, or interactive prompts? standout-input provides declarative input chains, and the App builder integrates them as a first-class part of command configuration:
#![allow(unused)] fn main() { use standout::cli::{App, CommandContextInput, Output}; use standout::input::{ArgSource, EditorSource, EnvSource, InputChain, StdinSource}; App::builder() .command_with("create", create, |cfg| { cfg.template("create.jinja") .input("body", InputChain::<String>::new() .try_source(ArgSource::new("body")) // 1. --body argument .try_source(StdinSource::new()) // 2. Piped stdin .try_source(EnvSource::new("PR_BODY")) // 3. Environment variable .try_source(EditorSource::new().extension(".md")) // 4. Open editor .validate(|s| !s.is_empty(), "Body cannot be empty")) })? .build()?; fn create(_m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Value> { // The chain has already been resolved before the handler runs. let body: &String = ctx.input("body")?; /* business logic ... */ } }
Features:
- Declarative priority: Source order is explicit in the chain
- Framework-integrated:
.input(name, chain)registers the chain alongsidetemplate,hooks, andpipe_*; resolution happens in pre-dispatch - Testable: All sources accept mocks for CI-safe testing (the
TestHarnessfromstandout-testwires them automatically) - Validated: Chain-level validation with retry support for interactive sources
- Feature-gated: Control dependencies (editor, prompts, inquire TUI)
The chain still works standalone via chain.resolve(&matches)? for cases where input shape depends on already-resolved values.
See Introduction to Input and Framework Integration for the full guide.
Aside from exposing the library primitives, Standout leverages best-in-breed crates like MiniJinja and console::Style under the hood. The lock-in is really negligible: you can use Standout's BB parser or swap it, manually dispatch handlers, and use the renderers directly in your clap dispatch.
Mutable Handlers
App supports FnMut closures and mutable handler state directly—no wrappers needed. This is common with database connections, file caches, or in-memory indices:
use standout::cli::{App, Output}; use standout::embed_templates; struct PadStore { index: HashMap<Uuid, Metadata>, } impl PadStore { fn complete(&mut self, id: Uuid) -> Result<()> { // This needs &mut self self.index.get_mut(&id).unwrap().completed = true; Ok(()) } } fn main() -> anyhow::Result<()> { let mut store = PadStore::load()?; App::builder() .app_state(Config::load()?) .templates(embed_templates!("src/templates")) .command("complete", |m, ctx| { let id = m.get_one::<Uuid>("id").unwrap(); store.complete(*id)?; // &mut store works! Ok(Output::Silent) }, "")? .command("list", |m, ctx| { Ok(Output::Render(store.list())) }, "{{ items }}")? .build()? .run(Cli::command(), std::env::args()); Ok(()) }
Handler capabilities:
App::builder()acceptsFnMutclosures- Handlers can capture
&mutreferences to state - The
Handlertrait uses&mut selffor struct-based handlers - No
Send + Syncrequirements—CLI apps are single-threaded
Appendix: Common Errors and Troubleshooting
- Template not found
- Error:
template 'list' not found - Cause: The template path in
embed_templates!doesn't match your file structure. - Fix: Ensure the path is relative to your
Cargo.toml, e.g.,embed_templates!("src/templates")and that the file is namedlist.jinja,list.j2, orlist.txt.
- Error:
- Style not applied
- Symptom: Text appears but without colors/formatting.
- Cause: Style name in template doesn't match stylesheet.
- Fix: Check that
[mystyle]in your template matches.mystylein CSS ormystyle:in YAML. Run with--output term-debugto see style tag names.
- Handler not called
- Symptom: Command runs but nothing happens or wrong handler runs.
- Cause: Command name mismatch between clap enum variant and handler function.
- Fix: Ensure enum variant
Listmaps to functionhandlers::list(snake_case conversion). Or use explicit mapping:#[dispatch(handler = my_custom_handler)]
- JSON output is empty or wrong
- Symptom:
--output jsonproduces unexpected results. - Cause:
Serializederive is missing or field names don't match template expectations. - Fix: Ensure all types in your result implement
Serialize. Use#[serde(rename_all = "lowercase")]for consistent naming.
- Symptom:
- Styles not loading
- Error:
theme not found: default - Cause: Stylesheet file missing or wrong path.
- Fix: Ensure
src/styles/default.cssexists. Checkembed_styles!path matches your file structure.
- Error:
Testing Standout CLIs
This is the guide for testing CLIs built with Standout. It starts from a claim most people nod at but few act on — "shell apps should be easy to test" — and shows how Standout's architecture, combined with the standout-test crate, actually makes that true.
See also:
- Handler Contract
- Testing (Topic) — reference for the
standout-testAPI surface - Output Modes
1. The claim no one keeps
"Shell applications should be easy to test. Just keep logic separate from output."
Sure. And yet look at any CLI in the wild and count the tests that:
- Spawn the compiled binary as a subprocess
- Pipe some argv in
- Capture stdout
- Regex-match the output
That's not testing behavior. That's reverse-engineering the user interface on every run. When you test a function via its rendered output, every trivial copy change breaks the test. Every color tweak breaks the test. Every time you add an emoji, every time the column widths shift, every time a locale flips — broken tests.
The honest answer is that most CLI codebases don't keep logic and output cleanly separated, because there's no discipline enforcing it. println! is always one line away. The tests you end up writing reflect that: they're shell-out + regex, because the production code is too tangled to test any other way.
2. The free win: architecture
Standout's first contribution to testability has nothing to do with testing tools. It's the architecture itself.
A handler is a pure function:
#![allow(unused)] fn main() { pub fn list(m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<TodoResult> { let show_all = m.get_flag("all"); let todos = storage::list()? .into_iter() .filter(|t| show_all || matches!(t.status, Status::Pending)) .collect(); Ok(Output::Render(TodoResult { todos })) } }
Output is data. Rendering lives somewhere else — a template, a stylesheet. The handler never touches stdout.
This means you can test the handler the way you test any other Rust function:
#![allow(unused)] fn main() { #[test] fn list_filters_completed_by_default() { let matches = build_matches(&["list"]); let ctx = CommandContext::default(); let Output::Render(result) = list(&matches, &ctx).unwrap() else { panic!("expected Render"); }; assert!(result.todos.iter().all(|t| matches!(t.status, Status::Pending))); } #[test] fn list_with_all_returns_everything() { let matches = build_matches(&["list", "--all"]); let ctx = CommandContext::default(); let Output::Render(result) = list(&matches, &ctx).unwrap() else { panic!() }; assert_eq!(result.todos.len(), storage::list().unwrap().len()); } }
No stdout capture. No regex. No subprocess. Just a function call and a struct assertion. The test reads like the behavior it describes.
This covers the majority of real logic — filtering, aggregation, validation, business rules. Standout didn't invent the idea of testing a pure function; it made sure the surrounding framework doesn't tempt you away from it.
Verify: Pick a handler in your app. Write a test that calls it directly and asserts on the returned data. If you can't, the handler has logic tangled with side effects — that's the real bug.
Intermezzo A: What the architecture already bought you
What you got for free:
- Handlers are pure functions; logic tests are straightforward
fn() -> Result<T, E>tests. - Output data is a
Serializestruct. You can also assert on it as JSON (useful for cross-language consumers). - Argument parsing is clap's problem. Clap has its own extensive test suite — you don't need to re-test it.
- Template rendering is
standout-render's problem. Its test suite covers MiniJinja syntax, tag parsing, style resolution, output modes.
What's left:
- Integration — does the full pipeline (argv → dispatch → handler → render → stdout) actually work for this command?
- Environment-dependent behavior — does this command react correctly to piped stdin, a missing env var, a narrow terminal, no color support?
- Filesystem-dependent behavior — does the command find, read, and write files in the right places?
These three are where CLIs traditionally fall back to subprocess-based e2e tests. That's what the rest of this guide is about.
3. The remaining gap
Let's be precise about what the architecture doesn't solve and why subprocess tests are tempting.
Integration. Even with clean handlers, a bug can live at the seam: an argument you thought was global isn't, a hook mutates state the handler doesn't see, a template references a field that doesn't exist. You want to assert on the rendered output of a full invocation, not just on the handler's return value.
The environment. CLIs read from the environment in a dozen places: $EDITOR, $HOME, piped stdin, the clipboard, the terminal width, whether stdout is a TTY, whether the terminal supports color, the current working directory, files at specific paths. Any of these can change behavior. None of them are the handler's "input" in the argv sense.
Filesystem state. Your command may need to read a config file at ~/.myapp/config.toml, write a lockfile, list entries in a working directory. Testing this with real paths pollutes the developer's machine; testing it by hand-rolling temp dirs in every test file duplicates code.
The default answer is:
#![allow(unused)] fn main() { #[test] fn list_shows_todos() { let output = Command::cargo_bin("myapp") .unwrap() .args(["list"]) .assert() .success() .get_output() .stdout .clone(); let text = String::from_utf8(output).unwrap(); assert!(text.contains("buy milk")); } }
This works. It's also:
- Slow. Spawning your binary is tens to hundreds of milliseconds, not microseconds.
- Opaque. If it fails, you get the stdout blob and a non-zero exit. You can't step into it, you can't inspect intermediate state.
- Brittle. The assertion is on rendered text; any presentation change breaks it.
- Hostile to invariants. Want to assert "the command set no env var as a side effect"? "The JSON payload had exactly these keys"? "A specific template was selected"? Good luck.
Subprocess tests have a place — and section 6 below names it — but they shouldn't be the default.
4. The standout-test harness
standout-test gives you a fluent builder that runs your app in-process with full control over the environment, then hands back a TestResult with typed accessors and assertion helpers.
# Cargo.toml
[dev-dependencies]
standout-test = "7.5"
The smallest possible test:
#![allow(unused)] fn main() { use serial_test::serial; use standout_test::TestHarness; #[test] #[serial] fn list_runs() { let app = build_app(); // your normal App::builder().build()? let cmd = build_cli_command(); // your clap Command let result = TestHarness::new().run(&app, cmd, ["myapp", "list"]); result.assert_success(); result.assert_stdout_contains("buy milk"); } }
That's it. run() drives the same dispatch path as production — same clap parsing, same handler lookup, same render pipeline — and returns the rendered text. No subprocess, no stdout capture gymnastics.
Why
#[serial]? The harness mutates process-global state (env vars, cwd, terminal detectors, default input readers). Tests that useTestHarnessmust run serially. Theserial_test::serialattribute is re-exported fromstandout_testfor convenience:use standout_test::serial;.
Verify: Add a
TestHarness::new().run(...)test to your app. It should run in under 10ms, not 100ms.
4.1 Env vars
Your command reads $EDITOR? Set it:
#![allow(unused)] fn main() { #[test] #[serial] fn respects_editor_env() { let result = TestHarness::new() .env("EDITOR", "vim") .run(&app, cmd, ["myapp", "note", "new"]); result.assert_stdout_contains("opening vim"); } }
Need to remove an env var that exists on your dev machine?
#![allow(unused)] fn main() { .env_remove("HOME") }
Both are backed by real std::env::set_var / remove_var. The originals are captured before the run and restored when the TestResult drops — including on panic unwind, so a failing assertion never leaks state into the next test.
4.2 Fixtures and working directory
For commands that read or write files:
#![allow(unused)] fn main() { #[test] #[serial] fn reads_config() { let result = TestHarness::new() .fixture("config.toml", r#"format = "short""#) .fixture("todos/today.md", "- buy milk\n- write tests\n") .run(&app, cmd, ["myapp", "show"]); result.assert_stdout_contains("buy milk"); } }
Each .fixture() call writes a file into a freshly created tempfile::TempDir. The first fixture call also sets that tempdir as the working directory for the run, so handlers using relative paths just work.
You can access the tempdir directly if you need absolute paths as handler arguments:
#![allow(unused)] fn main() { let harness = TestHarness::new().fixture("input.txt", "hello\n"); let path = harness.tempdir().unwrap().join("input.txt"); let result = harness.run(&app, cmd, ["myapp", "cat", path.to_str().unwrap()]); }
Fixture paths must be relative and stay inside the tempdir — absolute paths and .. components are rejected so a stray fixture can't clobber your real home directory.
4.3 Piped stdin
Want to test the "CLI piped as input" path?
#![allow(unused)] fn main() { #[test] #[serial] fn reads_from_stdin() { let result = TestHarness::new() .piped_stdin("draft text\n") .run(&app, cmd, ["myapp", "publish"]); result.assert_stdout_contains("draft text"); } }
Any handler built on standout-input::StdinSource::new() — or on standout_input::read_if_piped() — transparently sees the mock. It reports is_terminal() == false and reads the content you supplied.
The counterpart:
#![allow(unused)] fn main() { .interactive_stdin() // StdinSource::new().is_terminal() reports true; nothing to read }
4.4 Clipboard
Same story for the system clipboard:
#![allow(unused)] fn main() { .clipboard("https://example.com/pasted-url") }
ClipboardSource::new() returns the mock content; no shelling out to pbpaste / xclip.
4.5 Interactive prompts (wizards)
Apps that drive their own interactive shell — wizards, setup helpers, REPLs — call InquireText::new(...).prompt(), InquireSelect::new(...).prompt(), etc. Without a seam those calls need a real TTY and become level-3 territory. With .prompts(...), the harness intercepts every prompt at the boundary so a wizard handler is fully testable in process:
#![allow(unused)] fn main() { use standout_input::{PromptResponse, ScriptedResponder}; use std::sync::Arc; #[test] #[serial] fn setup_wizard_completes_with_scripted_answers() { let result = TestHarness::new() .prompts(Arc::new(ScriptedResponder::new([ PromptResponse::text("foo"), // pack name PromptResponse::Bool(true), // confirm PromptResponse::Choice(2), // env -> options[2] ]))) .run(&app, cmd, ["mycli", "setup"]); result.assert_stdout_contains("created pack `foo`"); } }
Open prompts (Text/Password/Editor) take PromptResponse::Text(...); finite-choice prompts (Confirm/Select/MultiSelect) take a Bool/Choice(usize)/Choices(Vec<usize>). Position-based responses make tests resilient to copy changes: Choice(2) keeps working when "Production" is renamed to "Live". ScriptedResponder panics on kind mismatch, so a wizard-step reorder fails loudly. See Interactive Flows → Testing Wizards for the full pattern.
4.6 Terminal state
Three orthogonal knobs, all routed through Phase 1's environment detectors:
#![allow(unused)] fn main() { .terminal_width(80) // forces a fixed width for tabular layouts .no_color() // forces OutputMode::Auto to behave like Text .with_color() // forces Auto to behave like Term even when piped .no_tty() // stdout reports as not-a-TTY .is_tty() // stdout reports as a TTY }
Useful for snapshot testing: pin the width, turn off color, and the rendered string is deterministic across developer machines and CI.
4.7 Forcing an output mode
Sometimes you want to assert on structured output regardless of what the user's --output flag would have chosen. Instead of manually appending --output=json to argv:
#![allow(unused)] fn main() { #[test] #[serial] fn list_as_json_has_expected_shape() { let result = TestHarness::new() .output_mode(OutputMode::Json) .run(&app, cmd, ["myapp", "list"]); let value: serde_json::Value = serde_json::from_str(result.stdout()).unwrap(); assert!(value["todos"].is_array()); assert_eq!(value["todos"].as_array().unwrap().len(), 3); } }
If your app renamed the flag via AppBuilder::output_flag(Some("format")), tell the harness:
#![allow(unused)] fn main() { .output_flag_name("format") }
Intermezzo B: A full-pipeline test, in-process
What you achieved: Your integration tests run in the same process, in microseconds, with complete environment control.
What's now possible:
- Assert on both the rendered output and the handler's return data in the same test (via
result.outcome()). - Test env-dependent branches without touching
std::envfrom your test code directly. - Pin terminal width and color for snapshot tests.
- Replace a subprocess-based integration suite with a harness-based one; watch the run time drop by an order of magnitude.
What's next: A worked example, and the boundaries — what the harness still can't do.
5. A worked example
Let's test a todo CLI end-to-end. The app reads todos from $TODO_FILE (or todos.txt in the cwd), supports adding via argument or piped stdin, and renders either as a styled list or as JSON.
#![allow(unused)] fn main() { use clap::Command; use serial_test::serial; use standout_test::TestHarness; use standout_render::OutputMode; fn app() -> standout::cli::App { // your real App::builder() -> build() todo!() } fn command() -> Command { // your real clap Command definition todo!() } #[test] #[serial] fn list_shows_todos_from_cwd_file() { let result = TestHarness::new() .fixture("todos.txt", "buy milk\nwrite tests\n") .run(&app(), command(), ["todo", "list"]); result.assert_success(); result.assert_stdout_contains("buy milk"); result.assert_stdout_contains("write tests"); } #[test] #[serial] fn list_prefers_env_var_over_cwd_file() { let result = TestHarness::new() .fixture("todos.txt", "from-cwd\n") .fixture("other.txt", "from-env\n") .env("TODO_FILE", "other.txt") .run(&app(), command(), ["todo", "list"]); result.assert_stdout_contains("from-env"); assert!(!result.stdout().contains("from-cwd")); } #[test] #[serial] fn add_reads_from_piped_stdin_when_no_arg() { // Capture the fixture tempdir path *before* .run() consumes the // builder, so we can read files back after the handler has written // to them. The tempdir itself lives inside the returned TestResult // and stays alive until that result drops at end of scope. let harness = TestHarness::new() .fixture("todos.txt", "") .piped_stdin("buy milk"); let todos_path = harness.tempdir().unwrap().join("todos.txt"); let result = harness.run(&app(), command(), ["todo", "add"]); result.assert_success(); let contents = std::fs::read_to_string(todos_path).unwrap(); assert!(contents.contains("buy milk")); } #[test] #[serial] fn list_as_json_is_valid_and_shaped() { let result = TestHarness::new() .fixture("todos.txt", "a\nb\nc\n") .output_mode(OutputMode::Json) .run(&app(), command(), ["todo", "list"]); let v: serde_json::Value = serde_json::from_str(result.stdout()).unwrap(); let items = v["todos"].as_array().unwrap(); assert_eq!(items.len(), 3); assert_eq!(items[0]["title"], "a"); } #[test] #[serial] fn list_without_color_strips_ansi() { let result = TestHarness::new() .fixture("todos.txt", "one\n") .no_color() .run(&app(), command(), ["todo", "list"]); assert!( !result.stdout().contains('\x1b'), "expected no ANSI escapes in output, got: {:?}", result.stdout() ); } }
Every test reads like a statement of behavior. Nothing runs in a subprocess. Nothing depends on the developer's real home directory or clipboard. Every test restores the environment on drop.
Intermezzo C: Integration tests that don't suck
What you achieved: A full integration test suite that runs in under a second, covers env-dependent branches, and breaks only when the behavior actually changes — not when someone tweaks a template.
What you traded: Your tests are #[serial] (they mutate process globals). For a CLI binary that isn't a library dependency of a massive workspace, this is almost never a problem — CLI test suites are small enough that serial execution is fine.
6. What the harness still can't do
Be honest about the boundaries. There are things you shouldn't try to test in-process:
Real PTY behavior. If your CLI drives progress bars, raw-mode TUIs, or prompts that sniff isatty() on a PTY (not just on the StdinReader abstraction), the harness can't simulate that. Use rexpect or expectrl with a spawned subprocess.
Signals. SIGINT / SIGTERM handling only makes sense against a real process.
Subprocess fan-out from your app. If your handler shells out to git, rg, $EDITOR, or any other external program, the harness can't intercept that call. This is the focus of Phase 3 of the test-tooling work — a ProcessRunner abstraction that routes through CommandContext, with a mock variant for tests. It's not yet shipped; until it is, shell-outs remain a boundary. In the meantime, structure handlers so the shell-out is a trait you can swap for a mock in the handler's tests directly.
Binary-level concerns. If you're testing that the compiled binary has the right linkage, exits with the right code, or handles --version through a specific path — that's genuinely integration-of-the-build, and a small assert_cmd suite is the right tool.
The goal isn't to replace subprocess tests entirely. It's to reduce them to the small set of cases where they're actually earning their keep.
7. Cheat sheet
#![allow(unused)] fn main() { TestHarness::new() // environment variables (real OS env, restored on drop) .env("KEY", "value") .env_remove("KEY") // working directory and fixture files .cwd("/some/path") // explicit cwd .fixture("notes/todo.txt", "content") // writes file, sets cwd to tempdir .fixture_bytes("data.bin", vec![1,2,3]) // terminal detectors (see standout-render::environment) .terminal_width(80) .no_terminal_width() .is_tty() // or .no_tty() .with_color() // or .no_color() // forced output mode (injects --output=<mode> into argv) .output_mode(OutputMode::Json) .text_output() // shortcut for OutputMode::Text .output_flag_name("format") // if AppBuilder::output_flag was renamed // stdin (routed through standout-input's default reader) .piped_stdin("content") .interactive_stdin() // clipboard (same) .clipboard("content") // interactive prompts (routed through standout-input's PromptResponder) .prompts(Arc::new(ScriptedResponder::new([ PromptResponse::text("answer"), PromptResponse::Bool(true), PromptResponse::Choice(2), // -> options[2] ]))) // execute .run(&app, cmd, ["binname", "subcommand", "--flag"]) // TestResult result.assert_success(); // Handled / Silent / Binary result.assert_no_match(); // clap didn't match any subcommand result.assert_stdout_contains("hi"); result.assert_stdout_eq("hi\n"); result.stdout(); // &str result.outcome(); // &RunResult, for bespoke assertions result.binary(); // Option<(&[u8], &str)> for Binary }
Appendix: common pitfalls
- Tests leak state into each other. Every test that uses
TestHarnessmust be#[serial]. Parallel execution mixed with process-global mutations is unsupported. - A
TestHarness::new()without.run(...)does nothing. The harness is#[must_use]— inert until you call.run. output_mode(...)injects--output=<mode>into argv. If your app uses a different flag name (viaAppBuilder::output_flag(Some("format"))), set.output_flag_name("format").- Detectors reset to library defaults, not to prior overrides. Don't mix a
TestHarnesswith a manually installedset_*_detectoron the same thread; the harness'sDropwill wipe your override. - Handlers that bypass
standout-input. If a handler reads stdin directly viastd::io::stdin()instead ofStdinSource::new()orread_if_piped(), the harness's.piped_stdin()won't reach it. Prefer the abstractions.
Introduction to Rendering
Terminal outputs have significant limitations: single font, single size, no graphics. But modern terminals provide many facilities like true colors, light/dark mode support, adaptive sizing, and more. Rich, helpful, and clear outputs are within reach.
The development reality explains why such output remains rare. From a primitive syntax born in the 1970s to the scattered ecosystem support, it's been a major effort to craft great outputs—and logically, it rarely makes sense to invest that time.
standout-render is designed to make crafting polished outputs a breeze by leveraging ideas, tools, and workflows from web applications—a domain in which rich interface authoring has evolved into the best model we've got. (But none of the JavaScript ecosystem chaos, rest assured.)
In this guide, we'll explore what makes great outputs and how standout-render helps you get there.
See Also:
- Styling System - themes, adaptive attributes, CSS syntax
- Templating - MiniJinja, style tags, processing modes
- Introduction to Tabular - column layouts and tables
What Polished Output Entails
If you're building your CLI in Rust, chances are it's not a throwaway grep-formatting script—if that were the case, nothing beats shells. More likely, your program deals with complex data, logic, and computation, and the full power of Rust matters. In the same way, clear, well-presented, and designed outputs improve your users' experience when parsing that information.
Creating good results depends on discipline, consistency, and above all, experimentation—from exploring options to fine-tuning small details. Unlike code, good layout is experimental and takes many iterations: change, view result, change again, judge the new change, and so on.
The classical setup for shell UIs is anything but conducive to this. All presentation is mixed with code, often with complicated logic, if not coupled to it. Additionally, from escape codes to whitespace handling to spreading visual information across many lines of code, it becomes hard to visualize and change things.
The edit-code-compile-run cycle makes small tweaks take minutes. Sometimes a full hour for a minor change. In that scenario, it's no surprise that people don't bother.
Our Example: A Report Generator
We'll use a simple report generator to demonstrate the rendering layer. Here's our data:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] pub struct Task { pub title: String, pub status: Status, } #[derive(Serialize)] pub struct Report { pub message: Option<String>, pub tasks: Vec<Task>, } }
Our goal: transform this raw data into polished, readable output that adapts to the terminal, respects user preferences, and takes minutes to iterate on—not hours.
The Separation Principle
standout-render is designed around a strict separation of data and presentation. This isn't just architectural nicety—it unlocks a fundamentally better workflow.
Without Separation
Here's the typical approach, tangling logic and output:
#![allow(unused)] fn main() { fn print_report(tasks: &[Task]) { println!("\x1b[1;36mYour Tasks\x1b[0m"); println!("──────────"); for (i, task) in tasks.iter().enumerate() { let marker = if matches!(task.status, Status::Done) { "[x]" } else { "[ ]" }; println!("{}. {} {}", i + 1, marker, task.title); } println!("\n{} tasks total", tasks.len()); } }
Problems:
- Escape codes are cryptic and error-prone
- Changes require recompilation
- Logic and presentation are intertwined
- Testing is brittle
- No easy way to support multiple output formats
With Separation
The same output, properly separated:
#![allow(unused)] fn main() { use standout_render::{render, Theme}; use console::Style; // Data preparation (your logic layer) let report = Report { message: Some(format!("{} tasks total", tasks.len())), tasks, }; // Theme definition (can be in a separate CSS/YAML file) let theme = Theme::new() .add("title", Style::new().cyan().bold()) .add("done", Style::new().green()) .add("pending", Style::new().yellow()) .add("muted", Style::new().dim()); // Template (can be in a separate .jinja file) let template = r#" [title]Your Tasks[/title] ────────── {% for task in tasks %} [{{ task.status }}]{{ task.status }}[/{{ task.status }}] {{ task.title }} {% endfor %} {% if message %}[muted]{{ message }}[/muted]{% endif %} "#; let output = render(template, &report, &theme)?; print!("{}", output); }
Now:
- Logic is testable without output concerns
- Presentation is declarative and readable
- Styles are centralized and named semantically
- Changes to appearance don't require recompilation (with file-based templates)
Quick Iteration and Workflow
The separation principle enables a radically better workflow. Here's what standout-render provides:
1. File-Based Flow
Dedicated files for templates and styles:
- Lower risk of breaking code—especially relevant for non-developer types like technical designers
- Simpler diffs and easier navigation
- Trivial to experiment with variations (duplicate files, swap names)
Directory structure:
src/
├── main.rs
└── templates/
└── report.jinja
styles/
└── default.css
2. Hot Live Reload
During development, you edit the template or styles and re-run. No compilation. No long turnaround.
This changes the entire experience. You can make and verify small adjustments in seconds. You can extensively fine-tune output quickly, then polish the full app in a focused session. Time efficiency aside, the quick iterative cycles encourage caring about smaller details, consistency—the things you forgo when iteration is painful.
(When released, files can be compiled into the binary using embedded macros, costing no performance or path-handling headaches in distribution.)
See File System Resources for details on how hot reload works.
Best-of-Breed Specialized Formats
Templates: MiniJinja (Default)
standout-render uses MiniJinja templates by default—a Rust implementation of Jinja2, a de facto standard for rich and powerful templating. The simple syntax and powerful features let you map template text to actual output much easier than println! spreads.
Alternative engines available: For simpler templates or smaller binaries, see Template Engines for lightweight alternatives like
SimpleEngine.
{% if message %}[accent]{{ message }}[/accent]{% endif %}
{% for task in tasks %}
[{{ task.status }}]{{ task.status | upper }}[/{{ task.status }}] {{ task.title }}
{% endfor %}
Benefits:
- Simple, readable syntax
- Powerful control flow (loops, conditionals, filters)
- Partials support: templates can include other templates, enabling reuse
- Custom filters: for complex presentation needs, write small bits of code and keep templates clean
See Templating for template filters and advanced usage.
Styles: CSS Themes
The styling layer uses CSS files with the familiar syntax you already know, but with simpler semantics tailored for terminals:
.title {
color: cyan;
font-weight: bold;
}
.done { color: green; }
.blocked { color: red; }
.pending { color: yellow; }
/* Adaptive for light/dark mode */
@media (prefers-color-scheme: light) {
.panel { color: black; }
}
@media (prefers-color-scheme: dark) {
.panel { color: white; }
}
Features:
- Adaptive attributes: a style can render different values for light and dark modes
- Theming support: swap the entire visual appearance at once
- True color: RGB values for precise colors (
#ff6b35or[255, 107, 53]) - Aliases: semantic names resolve to visual styles (
commit-message: title)
See Styling System for complete style options.
Theme-Relative Colors
Standard color definitions (named colors, hex, 256-palette) are absolute — they look the same regardless of the user's terminal theme. This can clash with carefully chosen base16 palettes.
cube(r%, g%, b%) colors solve this by specifying a position in a color cube whose corners are the theme's 8 base ANSI colors:
.warm-accent { color: cube(60%, 20%, 0%); } /* 60% toward red, 20% toward green */
.cool-accent { color: cube(0%, 0%, 80%); } /* 80% toward blue */
.neutral { color: cube(50%, 50%, 50%); } /* center of the cube */
The same coordinate produces different RGB values depending on the active theme — a Gruvbox theme produces earthy tones, Catppuccin produces pastels, and Solarized produces muted variants. The designer's intent ("warm accent") is preserved across all themes.
The interpolation happens in CIE LAB space, ensuring perceptually uniform gradients with no muddy midpoints.
To attach a palette to a theme:
#![allow(unused)] fn main() { use standout_render::Theme; use standout_render::colorspace::{ThemePalette, Rgb}; let palette = ThemePalette::new([ Rgb(40, 40, 40), Rgb(204, 36, 29), Rgb(152, 151, 26), Rgb(215, 153, 33), Rgb(69, 133, 136), Rgb(177, 98, 134), Rgb(104, 157, 106), Rgb(168, 153, 132), ]); let theme = Theme::from_yaml("...")? .with_palette(palette); }
Template Integration with Styling
Styles are applied with BBCode-like syntax: [style]content[/style]. A familiar, simple, and accessible form.
[title]Your Tasks[/title]
{% for task in tasks %}
[{{ task.status }}]{{ task.title }}[/{{ task.status }}]
{% endfor %}
Style tags:
- Nest properly:
[outer][inner]text[/inner][/outer] - Can span multiple lines
- Can contain template logic:
[title]{% if x %}{{ x }}{% endif %}[/title]
Output Modes: Rich, Plain, and Debug
standout-render processes style tags differently based on the output mode:
#![allow(unused)] fn main() { use standout_render::{render_with_output, OutputMode}; // Rich terminal output (ANSI codes) let rich = render_with_output(template, &data, &theme, OutputMode::Term)?; // Plain text (strips style tags) let plain = render_with_output(template, &data, &theme, OutputMode::Text)?; // Debug mode (keeps tags visible) let debug = render_with_output(template, &data, &theme, OutputMode::TermDebug)?; }
Single template for rich and plain text. The same template serves both—no duplication needed.
#![allow(unused)] fn main() { // Auto-detect based on terminal capabilities let output = render_with_output(template, &data, &theme, OutputMode::Auto)?; }
In auto mode:
- TTY with color support → rich output
- Pipe or redirect → plain text
For standout framework users: The framework's
--outputflag automatically sets the output mode. See the standout documentation for CLI integration.
Debug Mode
Use OutputMode::TermDebug for debugging:
[title]Your Tasks[/title]
[pending]pending[/pending] Implement auth
[done]done[/done] Fix tests
Style tags remain visible, making it easy to verify correct placement. Useful for testing and automation tools.
Tabular Layout
Many outputs are lists of things—log entries, servers, tasks. These benefit from vertically aligned layouts. Aligning fields seems simple at first, but when you factor in ANSI awareness, flexible size ranges, wrapping behavior, truncation, justification, and expanding cells, it becomes really hard.
Tabular gives you a declarative API, both in Rust and in templates, that handles all of this:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
{% for task in tasks %}
{{ t.row([loop.index, task.status | style_as(task.status), task.title]) }}
{% endfor %}
Output adapts to terminal width:
1. pending Implement user authentication
2. done Review pull request #142
3. pending Update dependencies
Features:
- Fixed, range, fill, and fractional widths
- Truncation (start, middle, end) with custom ellipsis
- Word wrapping for long content
- Per-column styling
- Automatic field extraction from structs
See Introduction to Tabular for a comprehensive walkthrough.
Structured Output
Beyond textual output, standout-render supports structured formats:
#![allow(unused)] fn main() { use standout_render::{render_auto, OutputMode}; // For Term/Text: renders template // For Json/Yaml/etc: serializes data directly let json_output = render_auto(template, &data, &theme, OutputMode::Json)?; let yaml_output = render_auto(template, &data, &theme, OutputMode::Yaml)?; }
Structured output for free. Because your data is Serialize-able, JSON/YAML outputs work automatically. Automation (tests, scripts, other programs) no longer needs to reverse-engineer data from formatted output.
Same data types—different output format. This enables API-like behavior from CLI apps without writing separate code paths.
Putting It All Together
Here's a complete example:
use standout_render::{render, Theme}; use console::Style; use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] pub struct Task { pub title: String, pub status: Status, } #[derive(Serialize)] pub struct Report { pub message: Option<String>, pub tasks: Vec<Task>, } fn main() -> Result<(), Box<dyn std::error::Error>> { let theme = Theme::from_css(r#" .title { color: cyan; font-weight: bold; } .done { color: green; } .pending { color: yellow; } .muted { opacity: 0.5; } "#)?; let tasks = vec![ Task { title: "Implement user authentication".into(), status: Status::Pending }, Task { title: "Review pull request #142".into(), status: Status::Done }, Task { title: "Update dependencies".into(), status: Status::Pending }, ]; let pending_count = tasks.iter() .filter(|t| matches!(t.status, Status::Pending)) .count(); let report = Report { message: Some(format!("{} pending", pending_count)), tasks, }; let template = r#" [title]My Tasks[/title] {% for task in tasks %} {{ loop.index }}. [{{ task.status }}]{{ task.status }}[/{{ task.status }}] {{ task.title }} {% endfor %} {% if message %}[muted]{{ message }}[/muted]{% endif %} "#; let output = render(template, &report, &theme)?; print!("{}", output); Ok(()) }
Output (terminal):
My Tasks
1. pending Implement user authentication
2. done Review pull request #142
3. pending Update dependencies
2 pending
With colors, "pending" appears yellow, "done" appears green.
Summary
standout-render transforms CLI output from a chore into a pleasure:
-
Separation of concerns: Data stays separate from templates. Templates define structure. Styles control appearance.
-
Fast iteration: Hot reload means edit-and-see in seconds, not minutes. This changes what's practical.
-
Familiar tools: MiniJinja for templates (Jinja2 syntax), CSS or YAML for styles. No new languages to learn.
-
Graceful degradation: One template serves rich terminals, plain pipes, and everything in between.
-
Structured output for free: JSON, YAML outputs work automatically from your serializable types.
-
Tabular layouts: Declarative column definitions handle alignment, wrapping, truncation, and ANSI-awareness.
The rendering system makes it practical to care about details. When iteration is fast and changes are safe, polish becomes achievable—not aspirational.
For complete API details, see the API documentation.
Introduction to Tabular
Polished terminal output requires two things: good formatting (see Rendering Introduction) and good layouts. For text-only, non-interactive output, layout mostly means aligning things vertically and controlling how multiple pieces of information are presented together.
Tabular provides a declarative column system with powerful primitives for sizing (fixed, range, fill, fractions), positioning (anchor to right), overflow handling (clip, wrap, truncate), cell alignment, and automated per-column styling.
Tabular is not only about tables. Any listing where items have multiple fields that benefit from vertical alignment is a good candidate—log entries with authors, timestamps, and messages; file listings with names, sizes, and dates; task lists with IDs, titles, and statuses. Add headers, separators, and borders to a tabular layout, and you have a table.
Key capabilities:
- Flexible sizing: Fixed widths, min/max ranges, fill remaining space, fractional proportions
- Smart truncation: Truncate at start, middle, or end with custom ellipsis
- Word wrapping: Wrap long content across multiple lines with proper alignment
- Unicode-aware: CJK characters, combining marks, and ANSI codes handled correctly
- Dynamic styling: Style columns or individual values based on content
In this guide, we will walk from a simple listing to a polished table, exploring the available features.
See Also:
- Introduction to Rendering - templates and styles overview
- Styling System - themes and adaptive styles
Our Example: Task List
We'll build the output for a task list. This is a perfect Tabular use case: each task has an index, title, and status. We want them aligned, readable, and visually clear at a glance.
Here's our data:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] struct Task { title: String, status: Status, } let tasks = vec![ Task { title: "Implement user authentication".into(), status: Status::Pending }, Task { title: "Fix payment gateway timeout".into(), status: Status::Pending }, Task { title: "Update documentation for API v2".into(), status: Status::Done }, Task { title: "Review pull request #142".into(), status: Status::Pending }, ]; }
Let's progressively build this from raw output to a polished, professional listing.
Step 1: The Problem with Plain Output
Without any formatting, a naive approach might look like this:
{% for task in tasks %}
{{ loop.index }}. {{ task.title }} {{ task.status }}
{% endfor %}
Output:
1. Implement user authentication pending
2. Fix payment gateway timeout pending
3. Update documentation for API v2 done
4. Review pull request #142 pending
This is barely readable. Fields run together, nothing aligns, and scanning the list requires mental parsing of each line. Let's fix that.
Step 2: Basic Column Alignment with col
The simplest improvement is the col filter. It pads (or truncates) each value to a fixed width:
{% for task in tasks %}
{{ loop.index | col(4) }} {{ task.status | col(10) }} {{ task.title | col(40) }}
{% endfor %}
Output:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
Already much better. Each column aligns vertically, making it easy to scan. But we've hardcoded widths, and if a title is too long, it gets truncated with ....
Key insight: The
colfilter handles Unicode correctly. CJK characters count as 2 columns, combining marks don't add width, and ANSI escape codes are preserved but not counted.
Step 3: Structured Layout with tabular()
For more control, use the tabular() function. This creates a formatter that you configure once and use for all rows:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": 40}
], separator=" ") %}
{% for task in tasks %}
{{ t.row([loop.index, task.status, task.title]) }}
{% endfor %}
The output looks the same, but now the column definitions are centralized. This becomes powerful when we start adding features.
Step 4: Flexible Widths
Hardcoded widths are fragile. What if the terminal is wider or narrower? Tabular offers flexible width strategies:
| Width | Meaning |
|---|---|
8 | Exactly 8 columns (fixed) |
{"min": 10} | At least 10, grows to fit content |
{"min": 10, "max": 30} | Between 10 and 30 |
"fill" | Takes all remaining space |
"2fr" | 2 parts of remaining (proportional) |
Let's make the title column expand to fill available space:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Now on an 80-column terminal:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
On a 120-column terminal, the title column automatically expands to use the extra space.
The layout adapts to the available space.
Step 5: Right-Align Numbers
Numbers and indices look better right-aligned. Use the align option:
{% set t = tabular([
{"name": "index", "width": 4, "align": "right"},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Output:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
The indices now align on the right edge of their column.
Step 6: Anchoring Columns
Sometimes you want a column pinned to the terminal's right edge, regardless of how other columns resize. Use anchor:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "title", "width": "fill"},
{"name": "status", "width": 10, "anchor": "right"}
], separator=" ") %}
Now the status column is always at the right edge. If the terminal is 100 columns or 200, the status stays anchored. The fill column absorbs the extra space between fixed columns and anchored columns.
Step 7: Sub-Columns (Distributing Space Within a Column)
Sometimes a column contains multiple logical parts with different sizing needs. A common example: a task list where the middle column has a variable-length title and an optional tag, separated by flexible spacing.
Without sub-columns, you can't compose title + padding + tag because the caller doesn't know the resolved column width. Sub-columns solve this by letting you define inner structure that is resolved per-row within the parent column's width.
The Problem
Consider this layout with three columns: index (fixed), content (fill), and duration (fixed right-aligned). The content column should contain a title that grows and an optional tag that's right-aligned:
1. Gallery Navigation [feature] 4d
2. Bug : Static Analysis 8h
3. Fixing Layout of Image Nav [bug] 2d
The tag [feature] must be right-aligned within the content column, with the title filling the remaining space. This is impossible with flat columns because the content column's resolved width isn't known to the template.
The Solution
Define sub_columns on the parent column. Exactly one sub-column must be "fill" (the grower); the rest are Fixed or Bounded:
{% set t = tabular([
{"width": 4},
{"width": "fill", "sub_columns": {
"columns": [
{"width": "fill"},
{"width": {"min": 0, "max": 30}, "align": "right"}
],
"separator": " "
}},
{"width": 4, "align": "right"}
], separator=" ", width=60) %}
Now pass nested arrays for the sub-column cells:
{% for task in tasks %}
{{ t.row([loop.index ~ ".", [task.title, task.tag], task.duration]) }}
{% endfor %}
Each row resolves sub-column widths independently. If the tag is empty (Bounded with min=0), it takes zero width and the title fills the entire column. If the tag is present, it gets its content width (up to max=30) and the title gets the rest.
Sub-Column Options
Sub-columns support the same formatting options as regular columns:
| Option | Meaning |
|---|---|
width | "fill", number (fixed), or {"min": n, "max": m} (bounded) |
align | "left" (default), "right", or "center" |
overflow | "truncate", "clip", "wrap", or object form |
style | Style name to wrap sub-cell content |
Rust API
From Rust, use CellValue::Sub for sub-column cells:
#![allow(unused)] fn main() { use standout_render::tabular::{ TabularSpec, Col, SubCol, SubColumns, TabularFormatter, CellValue, }; let spec = TabularSpec::builder() .column(Col::fixed(4)) .column(Col::fill().sub_columns( SubColumns::new( vec![SubCol::fill(), SubCol::bounded(0, 30).right()], " ", ).unwrap(), )) .column(Col::fixed(4).align(standout_render::tabular::Align::Right)) .separator(" ") .build(); let formatter = TabularFormatter::new(&spec, 60); let row = formatter.format_row_cells(&[ CellValue::Single("1."), CellValue::Sub(vec!["Gallery Navigation", "[feature]"]), CellValue::Single("4d"), ]); }
Design Constraints
- One level only: Sub-columns cannot be nested recursively.
- Exactly one Fill: One sub-column must be
"fill"(the grower). The rest must be Fixed or Bounded. - Per-row resolution: Sub-column widths are computed independently for each row, based on actual content.
- Width invariant: The formatted sub-cell output is always exactly the parent column's width.
Step 8: Handling Long Content
What happens when a title is longer than its column? By default, Tabular truncates at the end with .... But you have options:
Truncate at Different Positions
{"name": "title", "width": 30, "overflow": "truncate"} {# "Very long title th..." #}
{"name": "title", "width": 30, "overflow": {"truncate": {"at": "start"}}} {# "...itle that is long" #}
{"name": "title", "width": 30, "overflow": {"truncate": {"at": "middle"}}} {# "Very long...is long" #}
Middle truncation is perfect for file paths where both the start and end matter: /home/user/.../important.txt
Wrap to Multiple Lines
For descriptions or messages, wrapping is often better than truncating:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "title", "width": 40, "overflow": "wrap"},
{"name": "status", "width": 10}
], separator=" ") %}
If a title exceeds 40 columns, it wraps:
1. Implement comprehensive error handling pending
for all API endpoints with proper
logging and user feedback
2. Quick fix done
The wrapped lines are indented to align with the column.
Step 9: Dynamic Styling Based on Values
Here's where Tabular shines for task lists. We want status colors: green for done, yellow for pending.
First, define styles in your theme:
/* styles/default.css */
.done { color: green; }
.pending { color: yellow; }
Then use the style_as filter to apply styles based on the value itself:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
{% for task in tasks %}
{{ t.row([loop.index, task.status | style_as(task.status), task.title]) }}
{% endfor %}
The style_as filter wraps the value in style tags: [done]done[/done]. The rendering system then applies the green color.
Output (with colors):
1. [yellow]pending[/yellow] Implement user authentication
2. [yellow]pending[/yellow] Fix payment gateway timeout
3. [green]done[/green] Update documentation for API v2
4. [yellow]pending[/yellow] Review pull request #142
In the terminal, statuses appear in their respective colors, making it instantly clear which tasks need attention.
Step 10: Column-Level Styles
Instead of styling individual values, you can style entire columns. This is useful for de-emphasizing certain information:
{% set t = tabular([
{"name": "index", "width": 4, "style": "muted"},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Now indices appear in a muted style (typically gray), while titles and statuses remain prominent. This creates visual hierarchy.
Step 11: Automatic Field Extraction
Tired of manually listing [task.title, task.status, ...]? If your column names match your struct fields, use row_from():
{% set t = tabular([
{"name": "title", "width": "fill"},
{"name": "status", "width": 10}
]) %}
{% for task in tasks %}
{{ t.row_from(task) }}
{% endfor %}
Tabular extracts task.title, task.status, etc. automatically. For nested fields, use key:
{"name": "Author", "key": "author.name", "width": 20}
{"name": "Email", "key": "author.email", "width": 30}
Step 12: Adding Headers and Borders
For a proper table with headers, switch from tabular() to table():
{% set t = table([
{"name": "#", "width": 4},
{"name": "Status", "width": 10},
{"name": "Title", "width": "fill"}
], border="rounded", header_style="bold") %}
{{ t.header_row() }}
{{ t.separator_row() }}
{% for task in tasks %}
{{ t.row([loop.index, task.status, task.title]) }}
{% endfor %}
{{ t.bottom_border() }}
Output:
╭──────┬────────────┬────────────────────────────────────────╮
│ # │ Status │ Title │
├──────┼────────────┼────────────────────────────────────────┤
│ 1 │ pending │ Implement user authentication │
│ 2 │ pending │ Fix payment gateway timeout │
│ 3 │ done │ Update documentation for API v2 │
│ 4 │ pending │ Review pull request #142 │
╰──────┴────────────┴────────────────────────────────────────╯
Border Styles
Choose from six border styles:
| Style | Look |
|---|---|
"none" | No borders |
"ascii" | +--+--+ (ASCII compatible) |
"light" | ┌──┬──┐ |
"heavy" | ┏━━┳━━┓ |
"double" | ╔══╦══╗ |
"rounded" | ╭──┬──╮ |
Row Separators
For dense data, add lines between rows:
{% set t = table(columns, border="light", row_separator=true) %}
┌──────┬────────────────────────────────────╮
│ # │ Title │
├──────┼────────────────────────────────────┤
│ 1 │ Implement user authentication │
├──────┼────────────────────────────────────┤
│ 2 │ Fix payment gateway timeout │
└──────┴────────────────────────────────────┘
Alternating Row Styles
For long tables, alternating background colors on even/odd rows improves readability (sometimes called "zebra striping"). Pass row_styles to the table() function:
{# Default gray tint — subtle dark/light gray alternation #}
{% set t = table(columns, header_style="bold", row_styles=true) %}
{# Named tint — blue, red, green, or purple #}
{% set t = table(columns, header_style="bold", row_styles="blue") %}
{# Fully custom style names #}
{% set t = table(columns, row_styles=["my_even", "my_odd"]) %}
The default theme includes five adaptive tints that automatically adjust to the user's light/dark terminal setting:
| Tint | Usage | Dark mode | Light mode |
|---|---|---|---|
| gray | row_styles=true | dark gray bg | light gray bg |
| blue | row_styles="blue" | dark navy bg | lavender bg |
| red | row_styles="red" | dark crimson bg | blush bg |
| green | row_styles="green" | dark forest bg | mint bg |
| purple | row_styles="purple" | dark plum bg | lilac bg |
The Rust API equivalent is Table::row_styles("table_row_even", "table_row_odd").
Step 13: The Complete Example
Putting it all together, here's a polished task list:
{% set t = table([
{"name": "#", "width": 4, "style": "muted"},
{"name": "Status", "width": 10},
{"name": "Title", "width": "fill", "overflow": {"truncate": {"at": "middle"}}}
], border="rounded", header_style="bold", separator=" | ") %}
{{ t.header_row() }}
{{ t.separator_row() }}
{% for task in tasks %}
{{ t.row([loop.index, task.status | style_as(task.status), task.title]) }}
{% endfor %}
{{ t.bottom_border() }}
Output (80 columns, with styling):
╭──────┬────────────┬───────────────────────────────────────────────────────╮
│ # │ Status │ Title │
├──────┼────────────┼───────────────────────────────────────────────────────┤
│ 1 │ pending │ Implement user authentication │
│ 2 │ pending │ Fix payment gateway timeout │
│ 3 │ done │ Update documentation for API v2 │
│ 4 │ pending │ Review pull request #142 │
╰──────┴────────────┴───────────────────────────────────────────────────────╯
Features in use:
- Rounded borders for a modern look
- Muted styling on index column for visual hierarchy
- Fill width on title to use available space
- Middle truncation for titles that exceed the column
- Dynamic status colors via
style_as
Using Tabular from Rust
Everything shown in templates is also available in Rust:
#![allow(unused)] fn main() { use standout_render::tabular::{TabularFormatter, ColumnSpec, Overflow, Alignment}; let columns = vec![ ColumnSpec::fixed(4).header("#").style("muted"), ColumnSpec::fixed(10).header("Status"), ColumnSpec::fill().header("Title").overflow(Overflow::truncate_middle()), ]; let formatter = TabularFormatter::new(columns) .separator(" | ") .terminal_width(80); // Format individual rows for (i, task) in tasks.iter().enumerate() { let row = formatter.format_row(&[ &(i + 1).to_string(), &task.status.to_string(), &task.title, ]); println!("{}", row); } }
Summary
Tabular transforms raw data into polished, scannable output with minimal effort:
- Start simple - use
colfilter for quick alignment - Structure with
tabular()- centralize column definitions - Flex with widths - use
fill, bounded ranges, and fractions - Align content - right-align numbers and dates
- Anchor columns - pin important data to edges
- Handle overflow - truncate intelligently or wrap
- Add visual hierarchy - style columns and values dynamically
- Extract automatically - let
row_from()pull fields from structs - Decorate as tables - add borders, headers, and separators
The declarative approach means your layout adapts to terminal width, handles Unicode correctly, and remains maintainable as your data evolves.
For complete API details, see the API documentation.
The Styling System
standout-render uses a theme-based styling system where named styles are applied to content through bracket notation tags. Instead of embedding ANSI codes in your templates, you define semantic style names (error, title, muted) and let the theme decide the visual representation.
This separation provides several benefits:
- Readability: Templates use meaningful names, not escape codes
- Maintainability: Change colors in one place, update everywhere
- Adaptability: Themes can respond to light/dark mode automatically
- Consistency: Enforce visual hierarchy across your application
Themes
A Theme is a named collection of styles. Each style maps a name (like title or error) to visual attributes (bold cyan, dim red, etc.).
CSS Themes
Define styles in standard CSS syntax — a subset of CSS Level 3 tailored for terminals:
/* theme.css */
.title {
color: cyan;
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
.muted {
opacity: 0.5; /* maps to dim */
}
.success {
color: green;
}
/* Shorthand works too */
.warning { color: yellow; }
Load CSS themes:
#![allow(unused)] fn main() { use standout_render::Theme; let theme = Theme::from_css(css_content)?; let theme = Theme::from_css_file("styles/theme.css")?; }
CSS gives you syntax highlighting in editors, linting tools, and familiarity for web developers.
Programmatic Themes
Build themes in code using the builder pattern:
#![allow(unused)] fn main() { use standout_render::Theme; use console::Style; let theme = Theme::new() .add("title", Style::new().bold().cyan()) .add("error", Style::new().red().bold()) .add("muted", Style::new().dim()) .add("success", Style::new().green()); }
Legacy format: YAML themes are still supported via
Theme::from_yaml()andTheme::from_yaml_file(). CSS is the recommended format for all new projects.
Supported Attributes
Colors
| Attribute | CSS Property | Description |
|---|---|---|
fg | color | Foreground (text) color |
bg | background | Background color |
Color Formats
/* Named colors (16 ANSI colors) */
.example { color: red; }
.example { color: green; }
.example { color: cyan; }
.example { color: magenta; }
.example { color: yellow; }
.example { color: white; }
.example { color: black; }
/* Bright variants */
.example { color: bright_red; }
.example { color: bright_green; }
/* 256-color palette (0-255) */
.example { color: 208; }
/* RGB hex */
.example { color: #ff6b35; }
.example { color: #f63; } /* shorthand */
/* Theme-relative cube colors */
.example { color: cube(60%, 20%, 0%); }
Cube colors express a position in a color cube whose 8 corners are the base ANSI
colors of the user's terminal theme. The same cube(60%, 20%, 0%) produces earthy
tones in Gruvbox, pastels in Catppuccin, and muted shades in Solarized.
Interpolation is done in CIE LAB space for perceptually uniform gradients.
Attach a palette to a theme with Theme::with_palette().
Text Attributes
| CSS Property | Effect |
|---|---|
font-weight: bold | Bold text |
opacity: 0.5 | Dimmed/faint text |
font-style: italic | Italic text |
text-decoration: underline | Underlined text |
text-decoration: blink | Blinking text |
text-decoration: line-through | Strikethrough |
visibility: hidden | Hidden text |
Adaptive Styles (Light/Dark Mode)
Terminal applications run in both light and dark environments. A color that looks great on a dark background may be illegible on a light one. standout-render solves this with adaptive styles.
How It Works
Instead of defining separate "light theme" and "dark theme" files, you define mode-specific overrides at the style level:
.panel {
font-weight: bold;
color: gray; /* Default/fallback */
}
@media (prefers-color-scheme: light) {
.panel { color: black; } /* Override for light mode */
}
@media (prefers-color-scheme: dark) {
.panel { color: white; } /* Override for dark mode */
}
When resolving panel in dark mode:
- Start with base attributes (
bold,gray) - Merge dark overrides (
whitereplacesgray) - Result: bold white text
This is efficient: most styles (bold, italic, semantic colors like green/red) look fine in both modes. Only a handful need adjustment—typically foreground colors for contrast.
Programmatic API
#![allow(unused)] fn main() { use standout_render::Theme; use console::{Style, Color}; let theme = Theme::new() .add_adaptive( "panel", Style::new().bold(), // Base (shared) Some(Style::new().fg(Color::Black)), // Light mode Some(Style::new().fg(Color::White)), // Dark mode ); }
Color Mode Detection
standout-render auto-detects the OS color scheme:
#![allow(unused)] fn main() { use standout_render::{detect_color_mode, ColorMode}; let mode = detect_color_mode(); match mode { ColorMode::Light => println!("Light mode"), ColorMode::Dark => println!("Dark mode"), } }
Override for testing:
#![allow(unused)] fn main() { use standout_render::set_theme_detector; set_theme_detector(|| ColorMode::Dark); // Force dark mode }
Style Aliasing
Aliases let semantic names resolve to visual styles. This is useful when multiple concepts share the same appearance:
#![allow(unused)] fn main() { let theme = Theme::new() // Define the visual style once .add("title", Style::new().bold().cyan()) // Aliases — pass a string to reference another style by name .add("commit-message", "title") .add("section-header", "title") .add("heading", "title"); }
Now [commit-message], [section-header], and [heading] all render identically to [title].
Benefits:
- Templates use meaningful, context-specific names
- Visual changes propagate automatically
- Refactoring visual design doesn't touch templates
Aliases can chain: a → b → c → concrete style. Cycles are detected and rejected at load time.
Unknown Style Tags
When a template references a style not defined in the theme, standout-render handles it gracefully:
| Output Mode | Behavior |
|---|---|
Term | Unknown tags get a ? marker: [unknown?]text[/unknown?] |
Text | Tags stripped (plain text) |
TermDebug | Tags preserved as-is |
The ? marker helps catch typos during development without crashing production apps.
Validation
For strict checking at startup:
#![allow(unused)] fn main() { use standout_render::validate_template; let errors = validate_template(template, &sample_data, &theme); if !errors.is_empty() { for error in errors { eprintln!("Unknown style: {}", error.tag_name); } std::process::exit(1); } }
Built-in Styles
Theme::default() includes adaptive styles for alternating table row backgrounds. These are used automatically when you pass row_styles=true (or a tint name) to the table() template function.
| Style name | Purpose |
|---|---|
table_row_even | Even rows — no background (transparent) |
table_row_odd | Odd rows — subtle gray background shift |
table_row_even_gray | Alias for table_row_even |
table_row_odd_gray | Alias for table_row_odd |
table_row_even_blue | Even rows for blue tint |
table_row_odd_blue | Odd rows — dark navy / lavender bg |
table_row_even_red | Even rows for red tint |
table_row_odd_red | Odd rows — dark crimson / blush bg |
table_row_even_green | Even rows for green tint |
table_row_odd_green | Odd rows — dark forest / mint bg |
table_row_even_purple | Even rows for purple tint |
table_row_odd_purple | Odd rows — dark plum / lilac bg |
All odd-row styles are adaptive: they resolve to a dark variant when the terminal is in dark mode, and a light variant in light mode. You can override any of these by defining the same style name in your theme.
Best Practices
Semantic, Presentation, and Visual Layers
Organize your styles in three conceptual layers:
1. Visual primitives (low-level appearance):
._cyan-bold { color: cyan; font-weight: bold; }
._dim { opacity: 0.5; }
._red-bold { color: red; font-weight: bold; }
2. Presentation roles (UI concepts — use aliases in code):
#![allow(unused)] fn main() { theme.add("heading", "_cyan-bold") .add("secondary", "_dim") .add("danger", "_red-bold"); }
3. Semantic names (domain concepts — aliases to presentation):
#![allow(unused)] fn main() { // In templates, use these theme.add("task-title", "heading") .add("task-status-done", "success") .add("task-status-pending", "warning") .add("error-message", "danger"); }
Templates use semantic names (task-title), which resolve to presentation roles (heading), which resolve to visual primitives (_cyan-bold).
This layering lets you:
- Refactor visuals without touching templates
- Maintain consistency across domains
- Document the purpose of each style
Naming Conventions
/* Good: descriptive, semantic */
.error-message { ... }
.file-path { ... }
.command-name { ... }
/* Avoid: visual descriptions */
.red-text { ... }
.bold-cyan { ... }
Keep Themes Focused
One theme per "look". Don't mix concerns:
styles/
├── default.css # your app's default look
├── colorblind.css # accessibility variant
└── monochrome.css # for piped output
API Reference
Theme Creation
#![allow(unused)] fn main() { // From CSS string let theme = Theme::from_css(css_str)?; // From CSS file (hot reload in debug) let theme = Theme::from_css_file(path)?; // Empty theme (for programmatic building) let theme = Theme::new(); // Legacy: YAML is still supported let theme = Theme::from_yaml(yaml_str)?; let theme = Theme::from_yaml_file(path)?; }
Adding Styles
#![allow(unused)] fn main() { // Static style theme.add("name", Style::new().bold()); // Adaptive style theme.add_adaptive("name", base_style, light_override, dark_override); // Alias theme.add("alias", "target_style"); }
Resolving Styles
#![allow(unused)] fn main() { // Get resolved style for current color mode let style: Option<Style> = theme.get("title"); // Get style for specific mode let style = theme.get_for_mode("panel", ColorMode::Dark); }
Color Mode
#![allow(unused)] fn main() { use standout_render::{detect_color_mode, set_theme_detector, ColorMode}; // Auto-detect let mode = detect_color_mode(); // Override (for testing) set_theme_detector(|| ColorMode::Light); }
Templating
standout-render uses a two-pass templating system that combines a template engine for logic and data binding with a custom BBCode-like syntax for styling. This separation keeps templates readable while providing full control over both content and presentation.
The default engine is MiniJinja (Jinja2-compatible), but alternative engines are available. See Template Engines for options including a lightweight SimpleEngine for reduced binary size.
Two-Pass Rendering Pipeline
Templates are processed in two distinct passes:
Template + Data → [Pass 1: MiniJinja] → Text with style tags → [Pass 2: BBParser] → Final output
Pass 1 - MiniJinja: Standard template processing. Variables are substituted, control flow executes, filters apply.
Pass 2 - BBParser: Style tag processing. Bracket-notation tags are converted to ANSI escape codes (or stripped, depending on output mode).
Example
Template: [title]{{ name }}[/title] has {{ count }} items
Data: { name: "Report", count: 42 }
After Pass 1: [title]Report[/title] has 42 items
After Pass 2: \x1b[1;36mReport\x1b[0m has 42 items (or plain: "Report has 42 items")
This separation means:
- Template logic (loops, conditionals) is handled by MiniJinja—a mature, well-documented engine
- Style application is a simple, predictable transformation
- You can debug each pass independently
MiniJinja Basics
MiniJinja implements Jinja2 syntax, a widely-used templating language. Here's a quick overview:
Variables
{{ variable }}
{{ object.field }}
{{ list[0] }}
Control Flow
{% if condition %}
Show this
{% elif other_condition %}
Show that
{% else %}
Default
{% endif %}
{% for item in items %}
{{ loop.index }}. {{ item.name }}
{% endfor %}
Filters
{{ name | upper }}
{{ list | length }}
{{ value | default("N/A") }}
{{ text | truncate(20) }}
Comments
{# This is a comment and won't appear in output #}
For comprehensive MiniJinja documentation, see the MiniJinja documentation.
Style Tags
Style tags use BBCode-like bracket notation to apply named styles from your theme:
[style-name]content to style[/style-name]
Basic Usage
[title]Report Summary[/title]
[error]Something went wrong![/error]
[muted]Last updated: {{ timestamp }}[/muted]
Nesting
Tags can nest properly:
[outer][inner]nested content[/inner][/outer]
Spanning Lines
Tags can span multiple lines:
[panel]
This is a multi-line
block of styled content
[/panel]
With Template Logic
Style tags and MiniJinja work together seamlessly:
[title]{% if custom_title %}{{ custom_title }}{% else %}Default Title{% endif %}[/title]
{% for task in tasks %}
[{{ task.status }}]{{ task.title }}[/{{ task.status }}]
{% endfor %}
The second example shows dynamic style names—the style applied depends on the value of task.status.
Processing Modes
Pass 2 (BBParser) processes style tags differently based on the output mode:
| Mode | Behavior | Use Case |
|---|---|---|
Term | Replace tags with ANSI escape codes | Rich terminal output |
Text | Strip tags completely | Plain text, pipes, files |
TermDebug | Keep tags as literal text | Debugging, testing |
Example
Template: [title]Hello[/title]
- Term:
\x1b[1;36mHello\x1b[0m(rendered as cyan bold) - Text:
Hello - TermDebug:
[title]Hello[/title]
Setting the Mode
#![allow(unused)] fn main() { use standout_render::{render_with_output, OutputMode}; // Rich terminal let output = render_with_output(template, &data, &theme, OutputMode::Term)?; // Plain text let output = render_with_output(template, &data, &theme, OutputMode::Text)?; // Debug (tags visible) let output = render_with_output(template, &data, &theme, OutputMode::TermDebug)?; // Auto-detect based on TTY let output = render_with_output(template, &data, &theme, OutputMode::Auto)?; }
Auto Mode
OutputMode::Auto detects the appropriate mode:
- If stdout is a TTY with color support →
Term - If stdout is a pipe or redirect →
Text
For standout framework users: The framework's
--outputCLI flag automatically sets the output mode. See standout documentation for details.
Built-in Filters
Beyond MiniJinja's standard filters, standout-render provides formatting filters:
Column Formatting
{{ value | col(10) }} {# pad/truncate to 10 chars #}
{{ value | col(20, align="right") }} {# right-align in 20 chars #}
{{ value | col(15, truncate="middle") }} {# truncate in middle #}
{{ value | col(15, truncate="start", ellipsis="...") }}
Padding
{{ "42" | pad_left(8) }} {# " 42" #}
{{ "hi" | pad_right(8) }} {# "hi " #}
{{ "hi" | pad_center(8) }} {# " hi " #}
Truncation
{{ long_text | truncate_at(20) }} {# "Very long text th..." #}
{{ path | truncate_at(30, "middle", "...") }} {# "/home/.../file.txt" #}
{{ text | truncate_at(20, "start") }} {# "...end of the text" #}
Display Width
{% if value | display_width > 20 %}
{{ value | truncate_at(20) }}
{% else %}
{{ value }}
{% endif %}
Returns visual width (handles Unicode—CJK characters count as 2).
Style Application
{{ value | style_as("error") }} {# wraps in [error]...[/error] #}
{{ task.status | style_as(task.status) }} {# dynamic: [pending]pending[/pending] #}
Template Registry
When using the Renderer struct, templates are resolved by name through a registry:
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; // Add inline template renderer.add_template("greeting", "Hello, [name]{{ name }}[/name]!")?; // Add directory of templates renderer.add_template_dir("./templates")?; // Render by name let output = renderer.render("greeting", &data)?; }
Resolution Priority
- Inline templates (added via
add_template()) - Directory templates (from
add_template_dir())
File Extensions
Supported extensions (in priority order): .jinja, .jinja2, .j2, .stpl, .txt
When you request "report", the registry checks:
- Inline template named
"report" report.jinjain registered directoriesreport.jinja2,report.j2,report.stpl,report.txt(lower priority)
The .stpl extension is for SimpleEngine templates. See Template Engines for details.
Template Names
Template names are derived from relative paths:
templates/
├── greeting.jinja → "greeting"
├── reports/
│ └── summary.jinja → "reports/summary"
└── errors/
└── 404.jinja → "errors/404"
Including Templates
Templates can include other templates using MiniJinja's include syntax:
{# main.jinja #}
[title]{{ title }}[/title]
{% include "partials/header.jinja" %}
{% for item in items %}
{% include "partials/item.jinja" %}
{% endfor %}
{% include "partials/footer.jinja" %}
This enables reusable components across your application.
Context Variables
Beyond your data, you can inject additional context into templates:
#![allow(unused)] fn main() { use standout_render::{render_with_vars, OutputMode}; use std::collections::HashMap; let mut vars = HashMap::new(); vars.insert("version", "1.0.0"); vars.insert("app_name", "MyApp"); let output = render_with_vars( "{{ app_name }} v{{ version }}: {{ message }}", &data, &theme, OutputMode::Term, vars, )?; }
When handler data and context variables have the same key, handler data wins. Context is supplementary.
Structured Output
For machine-readable output (JSON, YAML, CSV), templates are bypassed entirely:
#![allow(unused)] fn main() { use standout_render::{render_auto, OutputMode}; // Template is used for Term/Text modes // Data is serialized directly for Json/Yaml/Csv let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
| Mode | Behavior |
|---|---|
Term | Render template, apply styles |
Text | Render template, strip styles |
TermDebug | Render template, keep style tags |
Json | serde_json::to_string_pretty(data) |
Yaml | serde_yaml::to_string(data) |
Csv | Flatten and format as CSV |
This means your serializable data types automatically support structured output without additional code.
Validation
Check templates for unknown style tags before deploying:
#![allow(unused)] fn main() { use standout_render::validate_template; let errors = validate_template(template, &sample_data, &theme); if !errors.is_empty() { for error in &errors { eprintln!("Unknown style tag: [{}]", error.tag_name); } } }
Validation catches:
- Misspelled style names
- References to undefined styles
- Mismatched opening/closing tags
API Reference
Render Functions
#![allow(unused)] fn main() { use standout_render::{ render, // Basic: template + data + theme render_with_output, // With explicit output mode render_with_mode, // With output mode + color mode render_with_vars, // With extra context variables render_auto, // Auto-dispatch template vs serialize render_auto_with_context, }; // Basic let output = render(template, &data, &theme)?; // With output mode let output = render_with_output(template, &data, &theme, OutputMode::Term)?; // With color mode override (for testing) let output = render_with_mode(template, &data, &theme, OutputMode::Term, ColorMode::Dark)?; // Auto (template for text modes, serialize for structured) let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
Renderer Struct
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; renderer.add_template("name", "content")?; renderer.add_template_dir("./templates")?; let output = renderer.render("name", &data)?; let output = renderer.render_with_mode("name", &data, OutputMode::Text)?; }
File System Resources
standout-render supports file-based templates and stylesheets that can be hot-reloaded during development and embedded into release binaries. This workflow combines the rapid iteration of interpreted languages with the distribution simplicity of compiled binaries.
The Development Workflow
During development, you want to:
- Edit a template or stylesheet
- Re-run your program
- See changes immediately
During release, you want:
- A single binary with no external dependencies
- No file paths to manage
- No risk of missing assets
standout-render supports both modes with the same code.
Hot Reload
In debug builds (debug_assertions enabled), file-based resources are re-read from disk on each render. This means:
- Edit
templates/report.jinja→ re-run → see changes - Edit
styles/theme.css→ re-run → see new styles - No recompilation needed
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; renderer.add_template_dir("./templates")?; // In debug: reads from disk each time // In release: uses cached content let output = renderer.render("report", &data)?; }
How It Works
The Renderer tracks the source of each template:
- Inline: Content provided as a string (always cached)
- File-based: Path recorded, content read on demand
In debug builds, file-based templates are re-read before each render. In release builds, content is cached after first load.
File Registries
Both templates and stylesheets use a registry pattern: a map from names to content.
Template Registry
#![allow(unused)] fn main() { use standout_render::TemplateRegistry; let mut registry = TemplateRegistry::new(); // Add from directory registry.add_dir("./templates")?; // Add inline registry.add("greeting", "Hello, {{ name }}!")?; // Resolve by name let content = registry.get("report")?; }
Name resolution from paths:
./templates/
├── greeting.jinja → "greeting"
├── reports/
│ ├── summary.jinja → "reports/summary"
│ └── detail.jinja → "reports/detail"
└── partials/
└── header.jinja → "partials/header"
Names are relative paths without extensions.
Stylesheet Registry
#![allow(unused)] fn main() { use standout_render::StylesheetRegistry; let mut registry = StylesheetRegistry::new(); registry.add_dir("./styles")?; let theme = registry.get("default")?; // loads default.css or default.yaml }
Supported Extensions
Templates
| Extension | Priority |
|---|---|
.jinja | 1 (highest) |
.jinja2 | 2 |
.j2 | 3 |
.txt | 4 (lowest) |
If both report.jinja and report.txt exist, report.jinja is used.
Stylesheets
| Extension | Format |
|---|---|
.css | CSS syntax |
.yaml | YAML syntax |
.yml | YAML syntax |
Embedding Resources
For release builds, embed resources directly into the binary using the provided macros:
Embedding Templates
#![allow(unused)] fn main() { use standout_render::{embed_templates, EmbeddedTemplates}; // Embed all .jinja files from a directory let templates: EmbeddedTemplates = embed_templates!("src/templates"); // Use with Renderer let mut renderer = Renderer::new(theme)?; renderer.add_embedded_templates(templates)?; }
Embedding Stylesheets
#![allow(unused)] fn main() { use standout_render::{embed_styles, EmbeddedStyles}; // Embed all .css/.yaml files from a directory let styles: EmbeddedStyles = embed_styles!("src/styles"); // Load a specific theme let theme = styles.get("default")?; }
Hybrid Approach
Combine embedded defaults with optional file overrides:
#![allow(unused)] fn main() { use standout_render::{Renderer, embed_templates}; let embedded = embed_templates!("src/templates"); let mut renderer = Renderer::new(theme)?; // Add embedded first (lower priority) renderer.add_embedded_templates(embedded)?; // Add file directory (higher priority, overrides embedded) if Path::new("./templates").exists() { renderer.add_template_dir("./templates")?; } }
This pattern lets users customize templates without modifying the binary.
Resolution Priority
When resolving a template or stylesheet name, sources are checked in priority order:
- Inline (added via
add()oradd_template()) - File-based directories (in order added, later = higher priority)
- Embedded (lowest priority)
Example:
#![allow(unused)] fn main() { renderer.add_embedded_templates(embedded)?; // Priority 1 (lowest) renderer.add_template_dir("./vendor")?; // Priority 2 renderer.add_template_dir("./templates")?; // Priority 3 (highest) renderer.add_template("report", "inline")?; // Priority 4 (always wins) }
If "report" exists in all sources, the inline version is used.
Directory Structure
Recommended project layout:
my-cli/
├── src/
│ ├── main.rs
│ ├── templates/ # Templates for embedding
│ │ ├── list.jinja
│ │ ├── detail.jinja
│ │ └── partials/
│ │ └── header.jinja
│ └── styles/ # Stylesheets for embedding
│ ├── default.css
│ └── colorblind.css
├── templates/ # Development overrides (gitignored)
└── styles/ # Development overrides (gitignored)
In main.rs:
#![allow(unused)] fn main() { let embedded_templates = embed_templates!("src/templates"); let embedded_styles = embed_styles!("src/styles"); let mut renderer = Renderer::new(theme)?; renderer.add_embedded_templates(embedded_templates)?; // In debug, also check local directories for overrides #[cfg(debug_assertions)] { if Path::new("./templates").exists() { renderer.add_template_dir("./templates")?; } } }
Error Handling
Missing Templates
#![allow(unused)] fn main() { match renderer.render("nonexistent", &data) { Ok(output) => println!("{}", output), Err(e) => { // Template not found in any source eprintln!("Template error: {}", e); } } }
Name Collisions
Same-directory collisions use extension priority (.jinja > .txt).
Cross-directory collisions are resolved by priority order (later directories win).
Invalid Content
Template syntax errors are reported with line numbers:
Template 'report' error at line 15:
unexpected end of template, expected 'endif'
API Reference
TemplateRegistry
#![allow(unused)] fn main() { use standout_render::TemplateRegistry; let mut registry = TemplateRegistry::new(); // Add sources registry.add("name", "content")?; registry.add_dir("./templates")?; registry.add_embedded(embedded_templates)?; // Query let content: Option<&str> = registry.get("name"); let names: Vec<&str> = registry.names(); let exists: bool = registry.contains("name"); }
StylesheetRegistry
#![allow(unused)] fn main() { use standout_render::StylesheetRegistry; let mut registry = StylesheetRegistry::new(); // Add sources registry.add_dir("./styles")?; registry.add_embedded(embedded_styles)?; // Get parsed theme let theme: Theme = registry.get("default")?; let names: Vec<&str> = registry.names(); }
Embed Macros
#![allow(unused)] fn main() { use standout_render::{embed_templates, embed_styles}; // At compile time, reads all matching files and embeds content let templates = embed_templates!("path/to/templates"); let styles = embed_styles!("path/to/styles"); }
Renderer Integration
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; // Templates renderer.add_template("name", "content")?; renderer.add_template_dir("./templates")?; renderer.add_embedded_templates(embedded)?; // Render let output = renderer.render("name", &data)?; }
Introduction to Dispatch
CLI applications typically mix business logic with output formatting: database queries interleaved with println!, validation tangled with ANSI codes, error handling scattered across presentation. The result is code that's hard to test, hard to change, and impossible to reuse.
standout-dispatch enforces a clean separation:
CLI args → Handler (logic) → Data → Renderer (presentation) → Output
- Handlers receive parsed arguments, return serializable data
- Renderers are pluggable callbacks you provide
- Hooks intercept execution at defined points
This isn't just architectural nicety—it unlocks:
- Testable handlers — Pure functions with explicit inputs and outputs
- Swappable renderers — JSON, templates, plain text from the same handler
- Cross-cutting concerns — Auth, logging, transformation via hooks
- Incremental adoption — Migrate one command at a time
The Problem
Here's a typical CLI command implementation:
#![allow(unused)] fn main() { fn list_command(matches: &ArgMatches) { let verbose = matches.get_flag("verbose"); let items = storage::list().expect("failed to list"); println!("\x1b[1;36mItems\x1b[0m"); println!("──────"); for item in &items { if verbose { println!("{}: {} (created: {})", item.id, item.name, item.created); } else { println!("{}: {}", item.id, item.name); } } println!("\n{} items total", items.len()); } }
Problems with this approach:
- Testing is painful — You have to capture stdout and parse it
- No format flexibility — Want JSON output? Write a whole new function
- Error handling is crude —
expector scattered error messages - Logic and presentation intertwined — Can't reuse the logic elsewhere
- Cross-cutting concerns require duplication — Auth checks in every command
The Solution: Handlers Return Data
With standout-dispatch, handlers focus purely on logic:
#![allow(unused)] fn main() { use standout_dispatch::{Handler, Output, CommandContext, HandlerResult}; use serde::Serialize; #[derive(Serialize)] struct ListResult { items: Vec<Item>, total: usize, } fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListResult> { let items = storage::list()?; // Errors propagate naturally Ok(Output::Render(ListResult { total: items.len(), items, })) } }
The handler:
- Receives parsed arguments (
&ArgMatches) and execution context - Returns a
Resultwith serializable data - Contains zero presentation logic
Rendering is handled separately:
#![allow(unused)] fn main() { use standout_dispatch::from_fn; // Simple JSON renderer let render = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); }
Or use a full template engine:
#![allow(unused)] fn main() { let render = from_fn(move |data, view| { my_renderer::render_template(view, data, &theme) }); }
Quick Start
[dependencies]
standout-dispatch = "2.1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
use standout_dispatch::{ FnHandler, Output, CommandContext, HandlerResult, from_fn, extract_command_path, path_to_string, }; use clap::{Command, Arg}; use serde::Serialize; #[derive(Serialize)] struct Greeting { message: String } fn main() -> anyhow::Result<()> { // 1. Define clap command let cmd = Command::new("myapp") .subcommand( Command::new("greet") .arg(Arg::new("name").required(true)) ); // 2. Create handler let greet_handler = FnHandler::new(|matches, _ctx| { let name: &String = matches.get_one("name").unwrap(); Ok(Output::Render(Greeting { message: format!("Hello, {}!", name), })) }); // 3. Create render function let render = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); // 4. Parse and dispatch let matches = cmd.get_matches(); let path = extract_command_path(&matches); if path_to_string(&path) == "greet" { let ctx = CommandContext { command_path: path }; let result = greet_handler.handle(&matches, &ctx)?; if let Output::Render(data) = result { let json = serde_json::to_value(&data)?; let output = render(&json, "greet")?; println!("{}", output); } } Ok(()) }
The Output Enum
Handlers return one of three output types:
#![allow(unused)] fn main() { pub enum Output<T: Serialize> { Render(T), // Data for rendering Silent, // No output (side-effect commands) Binary { // Raw bytes (file exports) data: Vec<u8>, filename: String, }, } }
Output::Render(T)
The common case. Data is passed to your render function:
#![allow(unused)] fn main() { fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let items = storage::list()?; Ok(Output::Render(items)) } }
Output::Silent
For commands with side effects only:
#![allow(unused)] fn main() { fn delete_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let id: &String = matches.get_one("id").unwrap(); storage::delete(id)?; Ok(Output::Silent) } }
Output::Binary
For generating files:
#![allow(unused)] fn main() { fn export_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let data = generate_report()?; let csv_bytes = format_as_csv(&data)?; Ok(Output::Binary { data: csv_bytes.into_bytes(), filename: "report.csv".into(), }) } }
State Management
Handlers access state through CommandContext, which provides two injection mechanisms:
App State (Shared)
Configure long-lived resources at build time:
#![allow(unused)] fn main() { use standout::cli::App; struct Database { /* connection pool */ } struct Config { api_url: String } App::builder() .app_state(Database::connect()?) // Shared across all dispatches .app_state(Config::load()?) .command("list", list_handler, "{{ items }}") .build()? }
Access in handlers via ctx.app_state:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let db = ctx.app_state.get_required::<Database>()?; let config = ctx.app_state.get_required::<Config>()?; Ok(Output::Render(db.list(&config.api_url)?)) } }
Extensions (Per-Request)
Pre-dispatch hooks inject request-scoped state via ctx.extensions:
#![allow(unused)] fn main() { Hooks::new().pre_dispatch(|matches, ctx| { let user_id = matches.get_one::<String>("user").unwrap(); ctx.extensions.insert(UserScope { user_id: user_id.clone() }); Ok(()) }) }
For full details, see App State and Extensions.
Hooks: Cross-Cutting Concerns
Hooks let you intercept execution without modifying handler logic:
#![allow(unused)] fn main() { use standout_dispatch::{Hooks, HookError, RenderedOutput}; let hooks = Hooks::new() // Before handler: validation, auth, inject per-request state .pre_dispatch(|matches, ctx| { if !is_authenticated() { return Err(HookError::pre_dispatch("auth required")); } Ok(()) }) // After handler, before render: transform data .post_dispatch(|_m, _ctx, mut data| { if let Some(obj) = data.as_object_mut() { obj.insert("timestamp".into(), json!(Utc::now().to_rfc3339())); } Ok(data) }) // After render: transform output .post_output(|_m, _ctx, output| { if let RenderedOutput::Text(s) = output { Ok(RenderedOutput::Text(format!("{}\n-- footer", s))) } else { Ok(output) } }); }
Hook Phases
| Phase | Timing | Receives | Can |
|---|---|---|---|
pre_dispatch | Before handler | ArgMatches, &mut Context | Abort execution, inject state |
post_dispatch | After handler, before render | ArgMatches, Context, Data | Transform data |
post_output | After render | ArgMatches, Context, Output | Transform output |
State Injection: Pre-dispatch hooks can inject dependencies via
ctx.extensionsthat handlers retrieve. This enables dependency injection without changing handler signatures. See Handler Contract: Extensions for details.
Hook Chaining
Multiple hooks per phase run sequentially:
#![allow(unused)] fn main() { Hooks::new() .post_dispatch(add_metadata) // Runs first .post_dispatch(filter_sensitive) // Receives add_metadata's output }
Handler Types
Closure Handlers
Most handlers are simple closures:
#![allow(unused)] fn main() { let handler = FnHandler::new(|matches, ctx| { let name: &String = matches.get_one("name").unwrap(); Ok(Output::Render(Data { name: name.clone() })) }); }
Trait Implementations
For handlers with internal state:
#![allow(unused)] fn main() { struct DbHandler { pool: DatabasePool, } impl Handler for DbHandler { type Output = Vec<Row>; fn handle(&self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Row>> { let query: &String = matches.get_one("query").unwrap(); let rows = self.pool.query(query)?; Ok(Output::Render(rows)) } } }
Struct Handlers (With State)
When handlers need internal state with &mut self:
#![allow(unused)] fn main() { impl Handler for Cache { type Output = Data; fn handle(&mut self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Data> { self.invalidate(); // &mut self works Ok(Output::Render(self.get()?)) } } }
See Handler Contract for full details.
Command Routing Utilities
Extract and navigate clap's ArgMatches:
#![allow(unused)] fn main() { use standout_dispatch::{ extract_command_path, get_deepest_matches, has_subcommand, path_to_string, }; // myapp db migrate --steps 5 let path = extract_command_path(&matches); // ["db", "migrate"] let path_str = path_to_string(&path); // "db.migrate" let deep = get_deepest_matches(&matches); // ArgMatches for "migrate" }
Testing Handlers
Because handlers are pure functions, testing is straightforward:
#![allow(unused)] fn main() { #[test] fn test_list_handler() { let cmd = Command::new("test") .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue)); let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap(); let ctx = CommandContext { command_path: vec!["list".into()], }; let result = list_handler(&matches, &ctx); assert!(result.is_ok()); if let Ok(Output::Render(data)) = result { assert!(data.verbose); } } }
No mocking needed—construct ArgMatches with clap, call your handler, assert on the result.
Summary
standout-dispatch provides:
- Clean separation — Handlers return data, renderers produce output
- Pluggable rendering — Use any output format without changing handlers
- Hook system — Cross-cutting concerns without code duplication
- Testable design — Handlers are pure functions with explicit contracts
- Incremental adoption — Migrate one command at a time
For complete API details, see the API documentation.
For standout framework users: The framework provides full integration with templates and themes. See the standout documentation for the
AppandAppBuilderAPIs that wire dispatch and render together automatically.
The Handler Contract
Handlers are where your application logic lives. The handler contract is designed to be explicit rather than permissive. By enforcing serializable return types and clear ownership semantics, the library guarantees that your code remains testable and decoupled from output formatting.
Quick Start: The #[handler] Macro
For most handlers, use the #[handler] macro to write pure functions:
#![allow(unused)] fn main() { use standout_macros::handler; #[handler] pub fn list(#[flag] all: bool, #[arg] limit: Option<usize>) -> Result<Vec<Item>, anyhow::Error> { storage::list(all, limit) } // Generates: list__handler(&ArgMatches, &CommandContext) -> HandlerResult<Vec<Item>> }
The macro:
- Extracts CLI arguments from
ArgMatchesbased on annotations - Auto-wraps
Result<T, E>inOutput::RenderviaIntoHandlerResult - Preserves the original function for direct testing
Parameter Annotations:
| Annotation | Type | Extraction |
|---|---|---|
#[flag] | bool | matches.get_flag("name") |
#[flag(name = "x")] | bool | matches.get_flag("x") |
#[arg] | T | Required argument |
#[arg] | Option<T> | Optional argument |
#[arg] | Vec<T> | Multiple values |
#[arg(name = "x")] | T | Argument with custom CLI name |
#[ctx] | &CommandContext | Access to context |
#[matches] | &ArgMatches | Raw matches (escape hatch) |
Return Type Handling:
| Return Type | Generated Wrapper |
|---|---|
Result<T, E> | Auto-wrapped in Output::Render |
Result<(), E> | Wrapped in Output::Silent |
Testing: The original function is preserved, so you can test directly:
list(true, Some(10)).
The Handler Trait
#![allow(unused)] fn main() { pub trait Handler { type Output: Serialize; fn handle(&mut self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Self::Output>; } }
Key characteristics:
- Mutable self:
&mut selfallows direct state modification - Output must be Serialize: Needed for JSON/YAML modes and template context
Implementing the trait directly is useful when your handler needs internal state—database connections, configuration, caches, etc.
Example: Struct Handler with State
#![allow(unused)] fn main() { use standout_dispatch::{Handler, Output, CommandContext, HandlerResult}; use clap::ArgMatches; use serde::Serialize; struct CachingDatabase { connection: Connection, cache: HashMap<String, Vec<Row>>, } impl CachingDatabase { fn query_with_cache(&mut self, sql: &str) -> Result<Vec<Row>, Error> { if let Some(cached) = self.cache.get(sql) { return Ok(cached.clone()); } let result = self.connection.execute(sql)?; self.cache.insert(sql.to_string(), result.clone()); Ok(result) } } impl Handler for CachingDatabase { type Output = Vec<Row>; fn handle(&mut self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Row>> { let query: &String = matches.get_one("query").unwrap(); let rows = self.query_with_cache(query)?; // &mut self works! Ok(Output::Render(rows)) } } }
Closure Handlers
Most handlers are simple closures using FnHandler:
#![allow(unused)] fn main() { use standout_dispatch::{FnHandler, Output, HandlerResult}; let mut counter = 0; let handler = FnHandler::new(move |_matches, _ctx| { counter += 1; // Mutation works! Ok(Output::Render(counter)) }); }
The closure signature:
#![allow(unused)] fn main() { fn(&ArgMatches, &CommandContext) -> HandlerResult<T> where T: Serialize }
Closures are FnMut, allowing captured variables to be mutated.
SimpleFnHandler (No Context Needed)
When your handler doesn't need CommandContext, use SimpleFnHandler for a cleaner signature:
#![allow(unused)] fn main() { use standout_dispatch::SimpleFnHandler; let handler = SimpleFnHandler::new(|matches| { let verbose = matches.get_flag("verbose"); let items = storage::list()?; Ok(ListResult { items, verbose }) }); }
The closure signature:
#![allow(unused)] fn main() { fn(&ArgMatches) -> Result<T, E> where T: Serialize, E: Into<anyhow::Error> }
SimpleFnHandler automatically wraps the result in Output::Render via IntoHandlerResult.
IntoHandlerResult Trait
The IntoHandlerResult trait enables handlers to return Result<T, E> directly instead of HandlerResult<T>:
#![allow(unused)] fn main() { use standout_dispatch::IntoHandlerResult; // Before: explicit Output wrapping fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let items = storage::list()?; Ok(Output::Render(items)) } // After: automatic conversion fn list(_m: &ArgMatches, _ctx: &CommandContext) -> impl IntoHandlerResult<Vec<Item>> { storage::list() // Result<Vec<Item>, Error> auto-converts } }
The trait is implemented for:
Result<T, E>whereE: Into<anyhow::Error>→ wrapsOk(t)inOutput::Render(t)HandlerResult<T>→ passes through unchanged
This is used internally by SimpleFnHandler and the #[handler] macro.
HandlerResult
HandlerResult<T> is a standard Result type:
#![allow(unused)] fn main() { pub type HandlerResult<T> = Result<Output<T>, anyhow::Error>; }
The ? operator works naturally for error propagation:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Items> { let items = storage::load()?; // Propagates errors let filtered = filter_items(&items)?; // Propagates errors Ok(Output::Render(Items { filtered })) } }
The Output Enum
Output<T> represents what a handler produces:
#![allow(unused)] fn main() { pub enum Output<T: Serialize> { Render(T), Silent, Binary { data: Vec<u8>, filename: String }, } }
Output::Render(T)
The common case. Data is passed to the render function:
#![allow(unused)] fn main() { #[derive(Serialize)] struct ListResult { items: Vec<Item>, total: usize, } fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListResult> { let items = storage::list()?; Ok(Output::Render(ListResult { total: items.len(), items, })) } }
Output::Silent
No output produced. Useful for commands with side effects only:
#![allow(unused)] fn main() { fn delete_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let id: &String = matches.get_one("id").unwrap(); storage::delete(id)?; Ok(Output::Silent) } }
Silent behavior:
- Post-output hooks still receive
RenderedOutput::Silent - Render function is not called
- Nothing prints to stdout
Output::Binary
Raw bytes for file output:
#![allow(unused)] fn main() { fn export_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let data = generate_report()?; let pdf_bytes = render_to_pdf(&data)?; Ok(Output::Binary { data: pdf_bytes, filename: "report.pdf".into(), }) } }
Binary output bypasses the render function entirely.
CommandContext
CommandContext provides execution environment information and state access:
#![allow(unused)] fn main() { pub struct CommandContext { pub command_path: Vec<String>, pub app_state: Rc<Extensions>, pub extensions: Extensions, } }
command_path: The subcommand chain as a vector, e.g., ["db", "migrate"]. Useful for logging or conditional logic.
app_state: Shared, immutable state configured at app build time via AppBuilder::app_state(). Wrapped in Arc for cheap cloning. Use for database connections, configuration, API clients.
extensions: Per-request, mutable state injected by pre-dispatch hooks. Use for user sessions, request IDs, computed values.
For comprehensive coverage of state management, see App State and Extensions.
State Access: App State vs Extensions
Handlers access state through two distinct mechanisms with different semantics:
| Aspect | ctx.app_state | ctx.extensions |
|---|---|---|
| Mutability | Immutable (&) | Mutable (&mut) |
| Lifetime | App lifetime | Per-request |
| Set by | AppBuilder::app_state() | Pre-dispatch hooks |
| Use for | Database, Config, API clients | User sessions, request IDs |
App State (Shared Resources)
Configure long-lived resources at build time:
#![allow(unused)] fn main() { App::builder() .app_state(Database::connect()?) .app_state(Config::load()?) .command("list", list_handler, template)? .build()? }
Access in handlers via ctx.app_state:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let db = ctx.app_state.get_required::<Database>()?; let config = ctx.app_state.get_required::<Config>()?; let items = db.query_items(config.max_results)?; Ok(Output::Render(items)) } }
Extensions (Per-Request State)
Pre-dispatch hooks inject request-scoped state:
#![allow(unused)] fn main() { use standout_dispatch::{Hooks, HookError}; struct UserScope { user_id: String, permissions: Vec<String> } let hooks = Hooks::new() .pre_dispatch(|matches, ctx| { // Can read app_state to set up per-request state let db = ctx.app_state.get_required::<Database>()?; let user_id = matches.get_one::<String>("user").unwrap().clone(); let permissions = db.get_permissions(&user_id)?; ctx.extensions.insert(UserScope { user_id, permissions }); Ok(()) }); }
Handlers retrieve from extensions:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let db = ctx.app_state.get_required::<Database>()?; // shared let scope = ctx.extensions.get_required::<UserScope>()?; // per-request let items = db.list_for_user(&scope.user_id)?; Ok(Output::Render(items)) } }
Extensions API
Both app_state and extensions use the same Extensions type with these methods:
| Method | Description |
|---|---|
insert<T>(value) | Insert a value, returns previous if any |
get<T>() | Get immutable reference, returns Option<&T> |
get_required<T>() | Get reference or return error if missing |
get_mut<T>() | Get mutable reference, returns Option<&mut T> |
remove<T>() | Remove and return value |
contains<T>() | Check if type exists |
len() | Number of stored values |
is_empty() | True if no values stored |
clear() | Remove all values |
Use get_required for mandatory dependencies (fails fast with clear error), get for optional ones.
When to Use Which
Use App State for:
- Database connections — expensive to create, should be pooled
- Configuration — loaded once at startup
- API clients — shared HTTP clients with connection pooling
Use Extensions for:
- User context — current user, session, permissions
- Request metadata — request ID, timing, correlation ID
- Transient state — data computed by one hook, used by handler
The Two-State Pattern
The separation exists because:
- Closure capture doesn't work with
#[derive(Dispatch)]— macro-generated dispatch calls handlers with a fixed signature - App-level resources shouldn't be created per-request — database pools and config are expensive
- Per-request state needs mutable injection — hooks compute values at runtime
#![allow(unused)] fn main() { // App state: configured once at build time App::builder() .app_state(Database::connect()?) // Shared via Arc .hooks("users.list", Hooks::new() .pre_dispatch(|matches, ctx| { // Extensions: computed per-request, can use app_state let db = ctx.app_state.get_required::<Database>()?; let user = authenticate(matches, db)?; ctx.extensions.insert(user); Ok(()) }))? }
For comprehensive coverage of state management patterns, see App State and Extensions.
Accessing CLI Arguments
The ArgMatches parameter provides access to parsed arguments through clap's standard API:
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Data> { // Flags let verbose = matches.get_flag("verbose"); // Required options let name: &String = matches.get_one("name").unwrap(); // Optional values let limit: Option<&u32> = matches.get_one("limit"); // Multiple values let tags: Vec<&String> = matches.get_many("tags") .map(|v| v.collect()) .unwrap_or_default(); Ok(Output::Render(Data { ... })) } }
For subcommands, you work with the ArgMatches for your specific command level.
Testing Handlers
Because handlers are pure functions with explicit inputs and outputs, they're straightforward to test:
#![allow(unused)] fn main() { #[test] fn test_list_handler() { let cmd = Command::new("test") .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue)); let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap(); let ctx = CommandContext { command_path: vec!["list".into()], ..Default::default() }; let result = list_handler(&matches, &ctx); assert!(result.is_ok()); if let Ok(Output::Render(data)) = result { assert!(data.verbose); } } }
No mocking frameworks needed—construct ArgMatches with clap, create a CommandContext, call your handler, assert on the result.
Testing with App State
When handlers depend on app_state, inject test fixtures:
#![allow(unused)] fn main() { #[test] fn test_handler_with_app_state() { use std::sync::Arc; // Create test fixtures let mock_db = MockDatabase::with_items(vec![ Item { id: "1", name: "Test" } ]); // Build app_state with test data let mut app_state = Extensions::new(); app_state.insert(mock_db); let ctx = CommandContext { command_path: vec!["list".into()], app_state: Arc::new(app_state), extensions: Extensions::new(), }; let cmd = Command::new("test"); let matches = cmd.try_get_matches_from(["test"]).unwrap(); let result = list_handler(&matches, &ctx); assert!(result.is_ok()); } }
Testing Handlers with Mutable State
Handler tests can verify state mutation across calls:
#![allow(unused)] fn main() { #[test] fn test_handler_state_mutation() { struct Counter { count: u32 } impl Handler for Counter { type Output = u32; fn handle(&mut self, _m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<u32> { self.count += 1; Ok(Output::Render(self.count)) } } let mut handler = Counter { count: 0 }; let cmd = Command::new("test"); let matches = cmd.try_get_matches_from(["test"]).unwrap(); let ctx = CommandContext { command_path: vec!["count".into()], ..Default::default() }; // State accumulates across calls let _ = handler.handle(&matches, &ctx); let _ = handler.handle(&matches, &ctx); let result = handler.handle(&matches, &ctx); assert!(matches!(result, Ok(Output::Render(3)))); } }
Execution Model
standout-dispatch manages a strict linear pipeline from CLI input to rendered output. This explicitly separated flow ensures that logic (handlers) remains decoupled from presentation (renderers) and side-effects (hooks).
The Pipeline
Clap Parsing → Pre-dispatch → Handler → Post-dispatch → Renderer → Post-output → Piping → Output
Each stage has a clear responsibility:
Clap Parsing: Your clap::Command definition is parsed normally. standout-dispatch doesn't replace clap—it works with the resulting ArgMatches.
Pre-dispatch Hook: Runs before the handler. Can abort execution (e.g., auth checks).
Handler: Your logic function executes. It receives ArgMatches and CommandContext, returning a HandlerResult<T>—either data to render, a silent marker, or binary content. For simpler handlers, use the #[handler] macro to write pure functions that return Result<T, E> directly (see Handler Contract).
Post-dispatch Hook: Runs after the handler, before rendering. Can transform data.
Renderer: Your render function receives the data and produces output (string or binary).
Post-output Hook: Runs after rendering. Can transform the final output string.
Piping: Optionally sends output to external commands (jq, tee, clipboard). Implemented as specialized post-output hooks. See Output Piping.
Output: The result is returned or written to stdout.
Command Paths
A command path is a vector of strings representing the subcommand chain:
myapp db migrate --steps 5
The command path is ["db", "migrate"].
Extracting Command Paths
#![allow(unused)] fn main() { use standout_dispatch::{extract_command_path, path_to_string, get_deepest_matches}; let matches = cmd.get_matches(); // Get the full path let path = extract_command_path(&matches); // ["db", "migrate"] // Convert to dot notation let path_str = path_to_string(&path); // "db.migrate" // Get ArgMatches for the deepest command let deep = get_deepest_matches(&matches); // ArgMatches for "migrate" }
Command Path Utilities
| Function | Purpose |
|---|---|
extract_command_path | Get subcommand chain as Vec<String> |
path_to_string | Convert path to dot notation ("db.migrate") |
string_to_path | Convert dot notation to path |
get_deepest_matches | Get ArgMatches for deepest subcommand |
has_subcommand | Check if any subcommand was invoked |
State Injection
Handlers access state through CommandContext, which provides two mechanisms:
app_state: Shared, immutable state configured at build time (database, config)extensions: Per-request, mutable state injected by hooks
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<T> { // App state: shared resources let db = ctx.app_state.get_required::<Database>()?; // Extensions: per-request state let scope = ctx.extensions.get_required::<UserScope>()?; // ... } }
For full details on state management, see App State and Extensions.
The Hooks System
Hooks are functions that run at specific points in the pipeline. They let you intercept, validate, or transform without touching handler logic—keeping concerns separated.
Three Phases
Pre-dispatch: Runs before the handler. Can abort execution or inject per-request state.
Use for: authentication checks, input validation, logging start time, injecting per-request state via extensions.
Pre-dispatch hooks receive &mut CommandContext, allowing them to inject state via ctx.extensions that handlers can retrieve. They also have read access to ctx.app_state for shared resources:
#![allow(unused)] fn main() { use standout_dispatch::{Hooks, HookError}; // Per-request state types (injected by hooks) struct UserSession { user_id: u64 } Hooks::new() .pre_dispatch(|matches, ctx| { // Read from app_state (shared) let db = ctx.app_state.get_required::<Database>()?; // Validate and set up per-request state let token = std::env::var("API_TOKEN") .map_err(|_| HookError::pre_dispatch("API_TOKEN required"))?; let user_id = db.validate_token(&token)?; // Inject into extensions (per-request) ctx.extensions.insert(UserSession { user_id }); Ok(()) }) }
Handlers then use both app_state and extensions:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { // App state: shared across all requests let db = ctx.app_state.get_required::<Database>()?; // Extensions: per-request state from hooks let session = ctx.extensions.get_required::<UserSession>()?; let items = db.fetch_items(session.user_id)?; Ok(Output::Render(items)) } }
See the Handler Contract for full Extensions API documentation, and App State for details on the two-state model.
Post-dispatch: Runs after the handler, before rendering. Can transform data.
Use for: adding timestamps, filtering sensitive fields, data enrichment. The hook receives handler output as serde_json::Value, allowing generic transformations regardless of the handler's output type.
#![allow(unused)] fn main() { Hooks::new().post_dispatch(|_matches, _ctx, mut data| { if let Some(obj) = data.as_object_mut() { obj.insert("generated_at".into(), json!(Utc::now().to_rfc3339())); } Ok(data) }) }
Post-output: Runs after rendering. Can transform the final string.
Use for: adding headers/footers, logging, metrics. The hook receives RenderedOutput—an enum of Text(String), Binary(Vec<u8>, String), or Silent.
#![allow(unused)] fn main() { use standout_dispatch::RenderedOutput; Hooks::new().post_output(|_matches, _ctx, output| { match output { RenderedOutput::Text(s) => { Ok(RenderedOutput::Text(format!("{}\n-- Generated by MyApp", s))) } other => Ok(other), } }) }
Hook Chaining
Multiple hooks per phase are supported. Pre-dispatch hooks run sequentially—first error aborts. Post-dispatch and post-output hooks chain: each receives the output of the previous, enabling composable transformations.
#![allow(unused)] fn main() { Hooks::new() .post_dispatch(add_metadata) // Runs first .post_dispatch(filter_sensitive) // Receives add_metadata's output }
Order matters: filter_sensitive sees the metadata that add_metadata inserted.
Output Piping
Piping sends rendered output to external shell commands. It's implemented as specialized post-output hooks with three modes:
#![allow(unused)] fn main() { use standout::cli::App; let app = App::builder() .commands(|g| { g.command_with("export", handlers::export, |cfg| { cfg.template("export.jinja") // Filter through jq (capture mode) .pipe_through("jq '.items'") }) .command_with("copy", handlers::copy, |cfg| { cfg.template("copy.jinja") // Send to clipboard (consume mode) .pipe_to_clipboard() }) .command_with("debug", handlers::debug, |cfg| { cfg.template("debug.jinja") // Log to file while displaying (passthrough mode) .pipe_to("tee /tmp/debug.log") }) }) .build()?; }
| Mode | Method | Behavior |
|---|---|---|
| Passthrough | pipe_to() | Run command, return original output |
| Capture | pipe_through() | Return command's stdout as new output |
| Consume | pipe_to_clipboard() | Send to clipboard, return empty |
Pipes can be chained and combined with other post-output hooks. See Output Piping for full documentation.
Error Handling
When a hook returns Err(HookError):
- Execution stops immediately
- Remaining hooks in that phase don't run
- For pre-dispatch: the handler never executes
- For post phases: the rendered output is discarded
- The error message is returned
#![allow(unused)] fn main() { use standout_dispatch::HookError; // Create error with phase context HookError::pre_dispatch("database connection failed") // With source error for debugging HookError::post_dispatch("transformation failed") .with_source(underlying_error) }
Render Handlers
The render handler is a pluggable callback that converts data to output:
#![allow(unused)] fn main() { use standout_dispatch::{from_fn, RenderFn}; // Simple JSON renderer let render: RenderFn = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); }
Render Function Signature
#![allow(unused)] fn main() { fn(&serde_json::Value, &str) -> Result<String, RenderError> }
Parameters:
data: The serialized handler outputview: A view/template name hint (can be ignored)
Using View Names
The view parameter enables template-based rendering:
#![allow(unused)] fn main() { let render = from_fn(move |data, view| { match view { "list" => format_as_list(data), "detail" => format_as_detail(data), _ => Ok(serde_json::to_string_pretty(data)?), } }); }
For standout framework users: The framework automatically maps view names to template files. See standout documentation for details.
Local Render Functions
For render functions that need mutable state:
#![allow(unused)] fn main() { use standout_dispatch::{from_fn_mut, LocalRenderFn}; let render: LocalRenderFn = from_fn_mut(|data, view| { // Can capture and mutate state Ok(format_data(data)) }); }
Default Command Support
Handle the case when no subcommand is specified:
#![allow(unused)] fn main() { use standout_dispatch::{has_subcommand, insert_default_command}; let matches = cmd.get_matches_from(args); if !has_subcommand(&matches) { // Re-parse with default command inserted let args_with_default = insert_default_command(std::env::args(), "list"); let matches = cmd.get_matches_from(args_with_default); // Now dispatch to "list" } }
insert_default_command inserts the command name after the binary name but before any flags.
Putting It Together
A complete dispatch flow:
use standout_dispatch::{ SimpleFnHandler, FnHandler, Output, CommandContext, Hooks, HookError, from_fn, extract_command_path, get_deepest_matches, path_to_string, }; fn main() -> anyhow::Result<()> { // 1. Define clap command let cmd = Command::new("myapp") .subcommand(Command::new("list")) .subcommand(Command::new("delete").arg(Arg::new("id").required(true))); // 2. Create handlers // SimpleFnHandler: for handlers that don't need CommandContext let list_handler = SimpleFnHandler::new(|_m| { storage::list() // Result<T, E> auto-wraps in Output::Render }); // FnHandler: when you need CommandContext let delete_handler = FnHandler::new(|matches, _ctx| { let id: &String = matches.get_one("id").unwrap(); storage::delete(id)?; Ok(Output::Silent) }); // 3. Create render function let render = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); // 4. Create hooks let hooks = Hooks::new() .pre_dispatch(|_m, _ctx| { println!("Starting command..."); Ok(()) }); // 5. Parse and dispatch let matches = cmd.get_matches(); let path = extract_command_path(&matches); let mut ctx = CommandContext { command_path: path.clone(), ..Default::default() }; // Run pre-dispatch hooks (may inject state via ctx.extensions) hooks.run_pre_dispatch(&matches, &mut ctx)?; // Dispatch based on command let result = match path_to_string(&path).as_str() { "list" => { let output = list_handler.handle(&matches, &ctx)?; if let Output::Render(data) = output { let json = serde_json::to_value(&data)?; let rendered = render(&json, "list")?; println!("{}", rendered); } } "delete" => { let deep = get_deepest_matches(&matches); delete_handler.handle(deep, &ctx)?; println!("Deleted."); } _ => eprintln!("Unknown command"), }; Ok(()) }
Summary
The execution model provides:
- Clear pipeline — Each stage has defined inputs and outputs
- Hook points — Intercept before, after handler, and after render
- Command routing — Utilities for navigating subcommand hierarchies
- Pluggable rendering — Render functions are separate from handlers
- Testable stages — Each component can be tested in isolation
Partial Adoption
One of the key benefits of standout-dispatch is that you don't need to adopt it all at once. You can migrate one command at a time, keeping existing code alongside dispatch-managed commands.
The Problem with All-or-Nothing Frameworks
Many CLI frameworks require a complete rewrite:
- All commands must use the framework's patterns
- Existing code can't coexist with framework code
- Migration is a massive undertaking
- Risk is concentrated in a single change
standout-dispatch is designed differently. It's a library, not a framework—you call it, it doesn't call you.
Strategy: Migrate One Command at a Time
Step 1: Identify a Good Starting Command
Pick a command that:
- Is self-contained (few dependencies on other commands)
- Has clear inputs and outputs
- Would benefit from structured output (JSON, etc.)
- Has existing tests you can update
Step 2: Create the Handler
Convert the command's logic to a handler:
#![allow(unused)] fn main() { // Before: mixed logic and output fn list_command(matches: &ArgMatches) { let items = storage::list().unwrap(); for item in items { println!("{}: {}", item.id, item.name); } } // After: handler returns data fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let items = storage::list()?; Ok(Output::Render(items)) } }
Step 3: Set Up Dispatch for That Command
use standout_dispatch::{FnHandler, from_fn, extract_command_path, path_to_string}; fn main() { let cmd = build_clap_command(); // Your existing clap definition let matches = cmd.get_matches(); let path = extract_command_path(&matches); // Dispatch-managed command if path_to_string(&path) == "list" { let handler = FnHandler::new(list_handler); let render = from_fn(|data, _| Ok(serde_json::to_string_pretty(data)?)); let ctx = CommandContext { command_path: path }; if let Ok(Output::Render(data)) = handler.handle(&matches, &ctx) { let json = serde_json::to_value(&data).unwrap(); println!("{}", render(&json, "list").unwrap()); } return; } // Fall back to existing code for other commands match matches.subcommand() { Some(("add", sub)) => add_command(sub), Some(("delete", sub)) => delete_command(sub), _ => {} } }
Step 4: Repeat
Migrate one command at a time. Each migration:
- Is a small, reviewable change
- Can be tested independently
- Doesn't affect other commands
- Is easy to roll back if needed
Coexistence Patterns
Pattern 1: Check Path First
#![allow(unused)] fn main() { let path = extract_command_path(&matches); // Dispatch-managed commands let dispatch_commands = ["list", "show", "export"]; if dispatch_commands.contains(&path_to_string(&path).as_str()) { dispatch_command(&matches, &path); return; } // Legacy commands legacy_dispatch(&matches); }
Pattern 2: Try Dispatch, Fall Back
#![allow(unused)] fn main() { if let Some(result) = try_dispatch(&matches) { handle_dispatch_result(result); } else { // Not a dispatch-managed command legacy_dispatch(&matches); } }
Pattern 3: Wrapper Function
#![allow(unused)] fn main() { fn run_command(matches: &ArgMatches) { let path = extract_command_path(matches); match path_to_string(&path).as_str() { // New dispatch-based handlers "list" => run_with_dispatch(list_handler, matches, &path), "show" => run_with_dispatch(show_handler, matches, &path), // Legacy handlers (unchanged) "add" => add_command(get_deepest_matches(matches)), "delete" => delete_command(get_deepest_matches(matches)), _ => eprintln!("Unknown command"), } } fn run_with_dispatch<T: Serialize>( handler: impl Fn(&ArgMatches, &CommandContext) -> HandlerResult<T>, matches: &ArgMatches, path: &[String], ) { let ctx = CommandContext { command_path: path.to_vec() }; match handler(matches, &ctx) { Ok(Output::Render(data)) => { let json = serde_json::to_value(&data).unwrap(); println!("{}", serde_json::to_string_pretty(&json).unwrap()); } Ok(Output::Silent) => {} Ok(Output::Binary { data, filename }) => { std::fs::write(&filename, &data).unwrap(); } Err(e) => eprintln!("Error: {}", e), } } }
Benefits During Migration
Immediate Benefits per Command
Each migrated command gains:
- Structured output — JSON/YAML support
- Testable logic — Handler is a pure function
- Error handling —
?operator, proper error types - Hook points — Add logging, auth without touching handler
Progressive Enhancement
As you migrate more commands:
- Shared hooks — Apply auth check to all migrated commands
- Consistent output — Same renderer for all commands
- Unified error handling — Errors formatted consistently
Migration Checklist
For each command:
-
Create data types (
#[derive(Serialize)]) - Write handler function
- Add to dispatch routing
- Update tests to test handler directly
- Verify existing behavior unchanged
- Document the migration
Example: Full Migration
Before (monolithic):
fn main() { let matches = build_cli().get_matches(); match matches.subcommand() { Some(("list", sub)) => list_command(sub), Some(("add", sub)) => add_command(sub), Some(("delete", sub)) => delete_command(sub), Some(("export", sub)) => export_command(sub), _ => {} } }
After (gradual migration):
fn main() { let matches = build_cli().get_matches(); let path = extract_command_path(&matches); // Dispatch-managed (migrated) if let Some(result) = dispatch_if_managed(&matches, &path) { return; } // Legacy (not yet migrated) match matches.subcommand() { Some(("add", sub)) => add_command(sub), Some(("delete", sub)) => delete_command(sub), _ => {} } } fn dispatch_if_managed(matches: &ArgMatches, path: &[String]) -> Option<()> { let ctx = CommandContext { command_path: path.to_vec() }; let render = from_fn(|data, _| Ok(serde_json::to_string_pretty(data)?)); let result = match path_to_string(path).as_str() { "list" => list_handler(matches, &ctx), "export" => export_handler(matches, &ctx), _ => return None, // Not managed by dispatch }; match result { Ok(Output::Render(data)) => { let json = serde_json::to_value(&data).ok()?; println!("{}", render(&json, "").ok()?); } Ok(Output::Silent) => {} Ok(Output::Binary { data, filename }) => { std::fs::write(&filename, &data).ok()?; } Err(e) => eprintln!("Error: {}", e), } Some(()) }
Summary
Partial adoption lets you:
- Start small — Migrate one command at a time
- Reduce risk — Each migration is independent
- Maintain velocity — Keep shipping while migrating
- Validate benefits — See the value before full commitment
The goal is pragmatic improvement, not architectural purity. Migrate what benefits most, leave what works alone.
Introduction to Input Collection
CLI applications need input from multiple sources: command-line arguments, piped stdin, environment variables, interactive prompts, and editors. Managing these sources with proper fallback logic and validation is tedious and error-prone.
standout-input provides a declarative API for input collection with automatic fallback chains. Define where input can come from, and the library handles the rest.
See Also:
- Backends - Detailed backend options and custom implementations
- Introduction to Standout - Full framework integration
The Problem
Typical CLI input handling looks like this:
#![allow(unused)] fn main() { fn get_message(matches: &ArgMatches) -> Result<String, Error> { // Try CLI argument first if let Some(msg) = matches.get_one::<String>("message") { return Ok(msg.clone()); } // Try stdin if piped if !std::io::stdin().is_terminal() { let mut buffer = String::new(); std::io::stdin().read_to_string(&mut buffer)?; if !buffer.trim().is_empty() { return Ok(buffer.trim().to_string()); } } // Try environment variable if let Ok(msg) = std::env::var("MY_MESSAGE") { return Ok(msg); } // Fall back to prompting print!("Enter message: "); std::io::stdout().flush()?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; Ok(line.trim().to_string()) } }
Problems:
- Imperative logic obscures the intended priority
- Hard to test (stdin, environment, terminal detection)
- Duplicated across commands
- Easy to miss edge cases (empty input, whitespace)
The Solution: Input Chains
standout-input replaces imperative logic with declarative chains:
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EnvSource, TextPromptSource}; let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) // 1. CLI argument .try_source(StdinSource::new()) // 2. Piped stdin .try_source(EnvSource::new("MY_MESSAGE")) // 3. Environment variable .try_source(TextPromptSource::new("Enter message: ")) // 4. Interactive prompt .resolve(&matches)?; }
The chain tries each source in order. The first source that provides input wins. If all sources return None, the chain returns InputError::NoInput.
Benefits:
- Declarative — Priority is explicit and readable
- Testable — All sources accept mocks for deterministic testing
- Composable — Build chains for different commands with shared sources
- Validated — Add validation rules that apply to any source
Quick Start
Add standout-input to your Cargo.toml:
[dependencies]
standout-input = "0.1"
Basic Chain
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, DefaultSource}; use clap::{Command, Arg}; // Set up clap let cmd = Command::new("myapp") .arg(Arg::new("message").short('m').long("message")); let matches = cmd.get_matches(); // Build an input chain let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) .try_source(StdinSource::new()) .default("Hello, World!".to_string()) .resolve(&matches)?; }
This chain:
- Checks if
--messagewas provided - If not, reads from stdin (only if piped, not interactive)
- Falls back to the default value
With Validation
Add validation rules that apply regardless of the source:
#![allow(unused)] fn main() { let email = InputChain::<String>::new() .try_source(ArgSource::new("email")) .try_source(TextPromptSource::new("Email: ")) .validate(|s| s.contains('@'), "Must be a valid email address") .validate(|s| s.len() >= 5, "Email too short") .resolve(&matches)?; }
For interactive sources (prompts, editor), validation failures trigger re-prompting. For non-interactive sources (args, stdin), validation failures return an error.
Knowing the Source
Sometimes you need to know where input came from:
#![allow(unused)] fn main() { use standout_input::InputSourceKind; let result = InputChain::<String>::new() .try_source(ArgSource::new("file")) .try_source(StdinSource::new()) .default("default.txt".to_string()) .resolve_with_source(&matches)?; match result.source { InputSourceKind::Arg => println!("From --file argument"), InputSourceKind::Stdin => println!("From piped input"), InputSourceKind::Default => println!("Using default"), _ => {} } let filename = result.value; }
Available Sources
Non-Interactive Sources
These sources don't require user interaction and work in CI/scripted environments:
| Source | Type | Description |
|---|---|---|
ArgSource | String | CLI argument value |
FlagSource | bool | CLI flag (true/false) |
StdinSource | String | Piped stdin (skipped if stdin is a terminal) |
EnvSource | String | Environment variable |
ClipboardSource | String | System clipboard contents |
DefaultSource<T> | T | Fallback value |
Interactive Sources (Feature-Gated)
These require a terminal and are feature-gated to control dependencies:
simple-prompts feature (default, no dependencies):
| Source | Type | Description |
|---|---|---|
TextPromptSource | String | Basic text input prompt |
ConfirmPromptSource | bool | Yes/no confirmation prompt |
editor feature (default, adds tempfile + which):
| Source | Type | Description |
|---|---|---|
EditorSource | String | Opens $VISUAL/$EDITOR for multi-line input |
inquire feature (optional, adds inquire crate):
| Source | Type | Description |
|---|---|---|
InquireText | String | Rich text input with autocomplete |
InquireConfirm | bool | Polished yes/no prompt |
InquireSelect<T> | T | Single selection with arrow keys |
InquireMultiSelect<T> | Vec<T> | Multiple selection with checkboxes |
InquirePassword | String | Masked password input |
InquireEditor | String | Editor with preview |
See Backends for full documentation on each source.
Standalone Prompts (No Chain)
Chains shine for CLI commands that need fallback between sources. For interactive flows that drive standout themselves — wizards, REPLs, setup helpers — every interactive source has a .prompt() shortcut that skips the chain machinery and the &ArgMatches plumbing entirely:
#![allow(unused)] fn main() { use standout_input::{InquireConfirm, InquireSelect, InquireText}; let pack: String = InquireText::new("Pack name:") .help("a-z0-9-") .prompt()?; let env: String = InquireSelect::new("Environment:", vec!["dev", "staging", "prod"]) .prompt()? .to_string(); let proceed: bool = InquireConfirm::new("Continue?") .default(true) .prompt()?; }
prompt() returns Result<T, InputError> directly — no Option to unwrap. Stdin not being a TTY or an empty submission both map to [InputError::NoInput], so a re-ask loop is just match on the error. User cancellation is reported as a backend-specific variant (PromptCancelled for prompts, EditorCancelled for editors); see the Interactive Flows topic for the full table.
Available on every interactive source:
| Source | Returns |
|---|---|
TextPromptSource, ConfirmPromptSource | Result<String, _>, Result<bool, _> |
EditorSource | Result<String, _> |
InquireText, InquireConfirm, InquirePassword, InquireEditor | as above |
InquireSelect<T>, InquireMultiSelect<T> | Result<T, _>, Result<Vec<T>, _> |
The InputCollector impls are unchanged — these sources still work in chains exactly as before. See Interactive Flows for a full wizard walkthrough that pairs .prompt() with standout's renderer.
Common Patterns
The gh pr create Pattern
Many CLI tools follow this pattern for body text:
#![allow(unused)] fn main() { // arg → stdin → editor → default let body = InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource::new()) .try_source(EditorSource::new().extension(".md")) .default(String::new()) .resolve(&matches)?; }
Confirmation with --yes Flag
Skip prompts in scripts with a flag override:
#![allow(unused)] fn main() { let confirmed = InputChain::<bool>::new() .try_source(FlagSource::new("yes")) .try_source(ConfirmPromptSource::new("Proceed?").default(false)) .resolve(&matches)?; }
Running with --yes returns true immediately. Without the flag, the user is prompted.
API Token with Environment Fallback
#![allow(unused)] fn main() { let token = InputChain::<String>::new() .try_source(ArgSource::new("token")) .try_source(EnvSource::new("GITHUB_TOKEN")) .try_source(InquirePassword::new("GitHub token:")) .resolve(&matches)?; }
Clipboard Prefill
For tools like paste managers:
#![allow(unused)] fn main() { let content = InputChain::<String>::new() .try_source(ArgSource::new("content")) .try_source(StdinSource::new()) .try_source(ClipboardSource::new()) .try_source(EditorSource::new()) .resolve(&matches)?; }
Testing
All sources accept mock implementations, enabling deterministic tests without actual terminal I/O, environment variables, or clipboard access.
Mocking Stdin
#![allow(unused)] fn main() { use standout_input::{StdinSource, MockStdin}; // Simulate piped input let source = StdinSource::with_reader(MockStdin::piped("test content")); // Simulate interactive terminal (no piped input) let source = StdinSource::with_reader(MockStdin::terminal()); }
Mocking Environment Variables
#![allow(unused)] fn main() { use standout_input::{EnvSource, MockEnv}; let env = MockEnv::new() .with_var("API_KEY", "secret123") .with_var("DEBUG", "true"); let source = EnvSource::with_reader("API_KEY", env); }
Mocking Clipboard
#![allow(unused)] fn main() { use standout_input::{ClipboardSource, MockClipboard}; let source = ClipboardSource::with_reader(MockClipboard::with_content("clipboard text")); let source = ClipboardSource::with_reader(MockClipboard::empty()); }
Mocking Prompts
#![allow(unused)] fn main() { use standout_input::{TextPromptSource, MockTerminal}; // Simulate user typing "Alice" and pressing Enter let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Alice")); // Simulate multiple responses for retry scenarios let terminal = MockTerminal::with_responses(["invalid", "valid@email.com"]); }
Mocking Editor
#![allow(unused)] fn main() { use standout_input::{EditorSource, MockEditorRunner}; // Simulate editor returning content let source = EditorSource::with_runner(MockEditorRunner::with_result("user input")); // Simulate no editor available let source = EditorSource::with_runner(MockEditorRunner::no_editor()); }
Full Integration Test
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EnvSource, MockStdin, MockEnv}; use clap::{Command, Arg}; #[test] fn test_input_priority() { let cmd = Command::new("test") .arg(Arg::new("token").long("token")); // Test: env var is used when arg is not provided let matches = cmd.clone().get_matches_from(["test"]); let chain = InputChain::<String>::new() .try_source(ArgSource::new("token")) .try_source(StdinSource::with_reader(MockStdin::terminal())) .try_source(EnvSource::with_reader("TOKEN", MockEnv::new().with_var("TOKEN", "from-env"))); let result = chain.resolve(&matches).unwrap(); assert_eq!(result, "from-env"); // Test: arg overrides env var let matches = cmd.get_matches_from(["test", "--token", "from-arg"]); let chain = InputChain::<String>::new() .try_source(ArgSource::new("token")) .try_source(EnvSource::with_reader("TOKEN", MockEnv::new().with_var("TOKEN", "from-env"))); let result = chain.resolve(&matches).unwrap(); assert_eq!(result, "from-arg"); } }
Feature Flags
standout-input uses feature flags to control dependencies:
| Feature | Default | Dependencies | Provides |
|---|---|---|---|
editor | Yes | tempfile, which | EditorSource |
simple-prompts | Yes | none | TextPromptSource, ConfirmPromptSource |
inquire | No | inquire (~29 deps) | Rich TUI prompts |
Minimal Dependencies
For the smallest footprint:
[dependencies]
standout-input = { version = "0.1", default-features = false }
This gives you only non-interactive sources (~2 dependencies).
Full Feature Set
[dependencies]
standout-input = { version = "0.1", features = ["inquire"] }
Standalone vs. Standout Framework
standout-input works as a standalone library with any clap-based CLI:
#![allow(unused)] fn main() { // Standalone usage use standout_input::{InputChain, ArgSource, StdinSource}; let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) .try_source(StdinSource::new()) .resolve(&matches)?; }
When using the full Standout framework, input chains integrate with the dispatch system:
#![allow(unused)] fn main() { // With Standout framework (future integration) use standout::cli::App; use standout_input::{InputChain, ArgSource, EditorSource}; App::builder() .command_with("create", handlers::create, |cfg| { cfg.input("body", |chain| { chain .try_source(ArgSource::new("body")) .try_source(EditorSource::new()) }) }) .build()?; }
Summary
standout-input transforms CLI input handling from imperative spaghetti into declarative chains:
- Declarative priority — Source order is explicit in the chain definition
- Testable — All sources accept mocks for deterministic testing
- Feature-gated — Control dependencies with feature flags
- Validated — Chain-level validation with retry support for interactive sources
- Composable — Build reusable source configurations
For detailed information on specific backends, including how to implement custom sources, see Backends.
Input Sources
standout-input provides a unified way to acquire input before your handler runs. This enables interactive workflows like:
- Opening an editor for commit messages
- Prompting for confirmation ("Delete 5 items?")
- Selecting from a list of options
- Reading piped stdin for scripting
- Pre-filling from clipboard
All without polluting your handler logic.
Why Input Sources?
CLI commands often need content that doesn't fit in command-line arguments. The gh pr create pattern is common:
# Option 1: Inline (awkward for long text)
gh pr create --body "Long description..."
# Option 2: Editor (interactive)
gh pr create --editor
# Option 3: Piped (scriptable)
echo "Description" | gh pr create --body-file -
Your CLI should support these patterns, but the logic doesn't belong in handlers:
- Separation of concerns: Handlers produce results, input acquisition is a setup concern
- Testability: Handlers remain pure functions that receive data
- Composability: Different commands can mix input sources
Standout's input system integrates as a pre-handler phase, running before your handler executes. Your handler receives resolved content—input acquisition is transparent.
Source Types
Input sources fall into two categories:
Non-Interactive Sources
These work in scripts and CI pipelines:
| Source | Use Case |
|---|---|
| Arg | Short content as CLI arguments |
| Stdin | Piped content (cat file | cmd) |
| Clipboard | Pre-filled content from clipboard |
| Env | Environment variable |
| Default | Hardcoded fallback |
Interactive Sources
These require a TTY and user interaction:
| Source | Use Case | Output Type |
|---|---|---|
| Editor | Long-form text (commit messages) | String |
| Text | Short text input ("Enter name:") | String |
| Confirm | Yes/no questions ("Proceed?") | bool |
| Select | Pick one from list | T |
| MultiSelect | Pick many from list | Vec<T> |
| Password | Hidden text input | String |
Non-Interactive Sources
Arg Source
Read directly from a clap argument:
#![allow(unused)] fn main() { InputSource::arg("message") }
Stdin Source
Read piped content when stdin is not a TTY:
#![allow(unused)] fn main() { InputSource::stdin() }
Only reads if stdin is actually piped. Returns None if stdin is a terminal.
Clipboard Source
Read from system clipboard:
#![allow(unused)] fn main() { InputSource::clipboard() }
Env Source
Read from environment variable:
#![allow(unused)] fn main() { InputSource::env("MY_APP_TOKEN") }
Interactive Sources
Editor Source
Open the user's preferred editor:
#![allow(unused)] fn main() { InputSource::editor() .initial("# Enter your message\n\n") .extension(".md") .require_save(true) }
Use for multi-line content like commit messages or descriptions.
Text Prompt
Prompt for short text input:
#![allow(unused)] fn main() { InputSource::text("Enter your name:") .default("Anonymous") .placeholder("John Doe") }
Confirm Prompt
Ask a yes/no question:
#![allow(unused)] fn main() { InputSource::confirm("Delete 5 items?") .default(false) // Default to "no" }
Returns bool. In chains, use with #[input] on a bool parameter.
Select Prompt
Pick one from a list:
#![allow(unused)] fn main() { InputSource::select("Choose format:") .option("json", "JSON output") .option("yaml", "YAML output") .option("csv", "CSV output") .default("json") }
Multi-Select Prompt
Pick multiple from a list:
#![allow(unused)] fn main() { InputSource::multi_select("Select features:") .option("auth", "Authentication") .option("logging", "Request logging") .option("cache", "Response caching") }
Password Prompt
Hidden text input:
#![allow(unused)] fn main() { InputSource::password("Enter API token:") .confirm("Confirm token:") // Optional confirmation }
Quick Start
The simplest integration uses the handler macro:
#![allow(unused)] fn main() { use standout_macros::handler; #[handler] pub fn create( #[input(fallback = "editor")] message: String, #[flag] verbose: bool, ) -> Result<CreateResult, Error> { // `message` is resolved from: arg → stdin → editor Ok(CreateResult { message, verbose }) } }
Or use the builder API for more control:
#![allow(unused)] fn main() { let app = App::builder() .command_with("create", handlers::create, |cfg| { cfg.template("create.jinja") .input("message", InputSource::chain() .try_arg("message") .try_stdin() .fallback_editor(EditorConfig::new() .initial("# Enter message") .extension(".md"))) }) .build()?; }
Input Chains
Chain multiple sources with fallback behavior:
#![allow(unused)] fn main() { InputSource::chain() .try_arg("body") // First: try CLI arg .try_stdin() // Second: try piped stdin .fallback_editor(config) // Third: open editor }
The chain stops at the first source that provides content. This enables the gh pr create pattern:
gh pr create --body "text"→ uses argecho "text" | gh pr create→ uses stdingh pr create→ opens editor
Chain with Skip Flag
Some commands want --no-editor to skip interactive input:
#![allow(unused)] fn main() { InputSource::chain() .try_arg("body") .try_stdin() .fallback_editor_unless("no-editor", config) .default("") // If --no-editor and no other source, use empty }
API Reference
Macro Attributes
| Attribute | Behavior |
|---|---|
#[input] | Resolve from arg of same name |
#[input(fallback = "editor")] | Arg → stdin → editor chain |
#[input(fallback = "stdin")] | Arg → stdin chain |
#[input(source = "editor")] | Editor only |
Builder Methods
#![allow(unused)] fn main() { // Single sources InputSource::arg("name") // From CLI argument InputSource::stdin() // From piped stdin InputSource::editor() // Always open editor InputSource::clipboard() // From system clipboard // Editor configuration InputSource::editor() .initial("prefilled content") .extension(".md") // For syntax highlighting .require_save(true) // Abort if user doesn't save .trim_newlines(true) // Strip trailing newlines // Chains InputSource::chain() .try_arg("message") .try_stdin() .fallback_editor(config) .default("fallback value") // With validation InputSource::chain() .try_arg("message") .validate(|s| !s.is_empty(), "Message cannot be empty") }
Low-Level API
For standalone use without the framework:
#![allow(unused)] fn main() { use standout_input::{Editor, detect_editor, read_stdin_if_piped}; // Detect preferred editor let editor = detect_editor()?; // Checks: VISUAL, EDITOR, then fallbacks // Read stdin only if piped let piped: Option<String> = read_stdin_if_piped()?; // Open editor with content let content = Editor::new() .executable(&editor) .initial("# Enter message\n") .extension(".md") .edit()?; // Returns Option<String>, None if user aborted }
Editor Detection
Editor detection follows established conventions:
| Priority | Source | Example |
|---|---|---|
| 1 | VISUAL env var | VISUAL=code |
| 2 | EDITOR env var | EDITOR=vim |
| 3 | Platform default | vim (Unix), notepad (Windows) |
For apps that want custom precedence (like gh with GH_EDITOR):
#![allow(unused)] fn main() { let editor = detect_editor_with_precedence(&[ "GH_EDITOR", // App-specific first "VISUAL", "EDITOR", ])?; }
Integration with Handlers
Resolved input is injected into CommandContext.extensions:
#![allow(unused)] fn main() { // Framework resolves input before handler runs // Handler receives it via #[input] attribute or ctx.extensions #[handler] pub fn create( #[input(fallback = "editor")] body: String, #[ctx] ctx: &CommandContext, ) -> Result<Pad, Error> { // `body` is already resolved // Can also access: ctx.extensions.get::<ResolvedInput<"body">>() } }
For complex cases that need the resolution metadata:
#![allow(unused)] fn main() { fn create(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Pad> { let input = ctx.extensions.get_required::<ResolvedInput>()?; match input.source { InputSourceKind::Arg => log::debug!("Got body from --body arg"), InputSourceKind::Stdin => log::debug!("Got body from piped stdin"), InputSourceKind::Editor => log::debug!("Got body from editor"), } let body = input.content; // ... } }
Direct Use in Handlers
For commands with complex input logic (like padz's "smart create"), use the library directly:
#![allow(unused)] fn main() { use standout_input::{Editor, read_stdin_if_piped, read_clipboard}; fn create(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Pad> { let no_editor = matches.get_flag("no-editor"); let title_arg = matches.get_one::<String>("title"); let content = if let Some(piped) = read_stdin_if_piped()? { // Piped input takes precedence piped } else if let Some(title) = title_arg { if no_editor { // Title only, no body title.clone() } else { // Title provided, open editor for body let body = Editor::new() .initial(&format!("# {}\n\n", title)) .extension(".md") .edit()? .unwrap_or_default(); format!("{}\n\n{}", title, body) } } else if no_editor { // No input and no editor - error return Err(anyhow!("No content provided. Use --title or pipe input.")); } else { // No args - prefill from clipboard, open editor let clipboard = read_clipboard().unwrap_or_default(); Editor::new() .initial(&clipboard) .edit()? .ok_or_else(|| anyhow!("Editor cancelled"))? }; // ... rest of handler } }
This gives full control while still using standardized primitives.
Clipboard Integration
Read from system clipboard as an input source:
#![allow(unused)] fn main() { // As part of a chain InputSource::chain() .try_arg("content") .try_clipboard() .fallback_editor(config) // Or for prefilling editor let initial = read_clipboard().unwrap_or_default(); Editor::new().initial(&initial).edit()? }
Platform support:
| Platform | Read Command |
|---|---|
| macOS | pbpaste |
| Linux | xclip -selection clipboard -o |
| Windows | PowerShell Get-Clipboard |
Comparison with Output Piping
Input sources and output piping are symmetric but opposite:
| Aspect | Input Sources | Output Piping |
|---|---|---|
| Direction | External → Handler | Handler → External |
| Pipeline position | Pre-handler | Post-output |
| Interactive | Can be (editor) | Never |
| Purpose | Acquire content | Transform/route output |
INPUT SOURCES OUTPUT PIPING
↓ ↓
[Arg/Stdin/Editor] → Handler → Render → [jq/tee/clipboard]
Error Handling
Input errors are returned before handler execution:
#![allow(unused)] fn main() { // Editor not found // Error: No editor found. Set VISUAL or EDITOR environment variable. // User cancelled editor (with require_save) // Error: Editor cancelled without saving. // Stdin read failed // Error: Failed to read from stdin: <io error> // Validation failed // Error: Input validation failed: Message cannot be empty }
Security Considerations
Editor execution: The editor command is resolved from environment variables. Ensure VISUAL/EDITOR are set by the user, not from untrusted sources.
Temp file handling: Editor content is written to a temp file. The file is deleted after reading. Content may briefly exist on disk.
#![allow(unused)] fn main() { // Files are created in system temp directory with random names // e.g., /tmp/standout-input-a7b3c9.md }
Summary
| Feature | Method/Attribute |
|---|---|
| From CLI arg | InputSource::arg("name") |
| From piped stdin | InputSource::stdin() |
| From editor | InputSource::editor() |
| From clipboard | InputSource::clipboard() |
| Chain with fallback | InputSource::chain().try_arg().fallback_editor() |
| Prefill editor | .initial("content") |
| File extension | .extension(".md") |
| Require save | .require_save(true) |
| Validation | .validate(fn, "error message") |
Input Backends
standout-input provides multiple backend implementations for collecting user input. Each backend is a source that can be composed into input chains. This document covers all available backends in detail and explains how to implement custom sources.
The InputCollector Trait
All input sources implement the InputCollector<T> trait:
#![allow(unused)] fn main() { pub trait InputCollector<T>: Send + Sync { /// Human-readable name for this collector (e.g., "argument", "stdin", "editor"). fn name(&self) -> &'static str; /// Check if this collector can provide input in the current environment. /// Return false if stdin isn't piped, no TTY for prompts, etc. fn is_available(&self, matches: &ArgMatches) -> bool; /// Attempt to collect input. /// - Ok(Some(value)) — Input collected successfully /// - Ok(None) — No input available, try the next source /// - Err(e) — Collection failed, abort the chain fn collect(&self, matches: &ArgMatches) -> Result<Option<T>, InputError>; /// Validate the collected value. Default accepts all values. fn validate(&self, _value: &T) -> Result<(), String> { Ok(()) } /// Whether this collector supports retry on validation failure. /// Interactive sources (prompts, editor) should return true. fn can_retry(&self) -> bool { false } } }
The chain calls is_available() first. If it returns false, the source is skipped. Otherwise, collect() is called. If validation fails and can_retry() is true, the source is retried (for interactive sources).
Non-Interactive Sources
These sources work in any environment, including CI pipelines and scripts.
ArgSource
Reads a value from a clap CLI argument.
#![allow(unused)] fn main() { use standout_input::ArgSource; let source = ArgSource::new("message"); // Reads --message or -m }
Behavior:
is_available(): Returnstrueif the argument was providedcollect(): ReturnsSome(value)if present,Noneotherwise- Type:
String
FlagSource
Reads a boolean flag from clap.
#![allow(unused)] fn main() { use standout_input::FlagSource; let source = FlagSource::new("verbose"); // Reads --verbose let source = FlagSource::new("no-color").inverted(); // --no-color → false }
Behavior:
is_available(): Returnstrueif the flag was provided (set to true)collect(): ReturnsSome(true)if set,Noneotherwiseinverted(): Inverts the logic (flag set →false)- Type:
bool
StdinSource
Reads from piped stdin. Skipped when stdin is a terminal.
#![allow(unused)] fn main() { use standout_input::StdinSource; let source = StdinSource::new(); let source = StdinSource::new().trim(false); // Don't trim whitespace }
Behavior:
is_available(): Returnstrueif stdin is piped (not a terminal)collect(): Reads all stdin content, returnsNoneif emptytrim: Whether to trim leading/trailing whitespace (default:true)- Type:
String
Testing:
#![allow(unused)] fn main() { use standout_input::{StdinSource, MockStdin}; let source = StdinSource::with_reader(MockStdin::piped("content")); let source = StdinSource::with_reader(MockStdin::terminal()); // Simulates no pipe let source = StdinSource::with_reader(MockStdin::piped_empty()); }
EnvSource
Reads from an environment variable.
#![allow(unused)] fn main() { use standout_input::EnvSource; let source = EnvSource::new("GITHUB_TOKEN"); }
Behavior:
is_available(): Returnstrueif the variable is set and non-emptycollect(): ReturnsSome(value)if set,Noneotherwise- Type:
String
Testing:
#![allow(unused)] fn main() { use standout_input::{EnvSource, MockEnv}; let env = MockEnv::new() .with_var("API_KEY", "secret") .with_var("DEBUG", "1"); let source = EnvSource::with_reader("API_KEY", env); }
ClipboardSource
Reads from the system clipboard.
#![allow(unused)] fn main() { use standout_input::ClipboardSource; let source = ClipboardSource::new(); }
Behavior:
is_available(): Returnstrueif clipboard has non-empty text contentcollect(): Returns clipboard text,Noneif empty- Platform: Uses
pbpaste(macOS),xclip(Linux) - Type:
String
Testing:
#![allow(unused)] fn main() { use standout_input::{ClipboardSource, MockClipboard}; let source = ClipboardSource::with_reader(MockClipboard::with_content("text")); let source = ClipboardSource::with_reader(MockClipboard::empty()); }
DefaultSource
Provides a fallback value. Always available, always returns its value.
#![allow(unused)] fn main() { use standout_input::DefaultSource; let source = DefaultSource::new("default value".to_string()); let source = DefaultSource::new(42); // Works with any Clone type }
Note: You can also use .default(value) on InputChain, which is equivalent to adding a DefaultSource at the end.
Editor Backend
Feature: editor (default)
Dependencies: tempfile, which
Opens the user's preferred text editor for multi-line input.
#![allow(unused)] fn main() { use standout_input::EditorSource; let source = EditorSource::new(); }
Configuration
#![allow(unused)] fn main() { let source = EditorSource::new() .initial_content("# Enter your message\n\n") // Pre-populate editor .extension(".md") // Syntax highlighting .require_save(true) // Fail if user doesn't save .trim(true); // Trim result (default) }
Editor Detection
Editors are detected in this order:
$VISUALenvironment variable (supports GUI editors like VS Code)$EDITORenvironment variable- Platform fallbacks:
vim,vi,nanoon Unix;notepadon Windows
Behavior
is_available(): Returnstrueif an editor is found AND stdin is a terminalcollect(): Opens editor, waits for exit, returns file contentscan_retry(): Returnstrue(validation failures re-open editor)- Type:
String
Testing
#![allow(unused)] fn main() { use standout_input::{EditorSource, MockEditorRunner, MockEditorResult}; // Simulate successful edit let source = EditorSource::with_runner(MockEditorRunner::with_result("user content")); // Simulate no editor available let source = EditorSource::with_runner(MockEditorRunner::no_editor()); // Simulate editor failure let source = EditorSource::with_runner(MockEditorRunner::failure("editor crashed")); // Simulate closing without saving let source = EditorSource::with_runner(MockEditorRunner::no_save()); }
Custom Editor Runner
Implement EditorRunner for custom editor behavior:
#![allow(unused)] fn main() { pub trait EditorRunner: Send + Sync { /// Detect the editor to use. Returns None if no editor is available. fn detect_editor(&self) -> Option<String>; /// Run the editor on the given file path. fn run(&self, editor: &str, path: &Path) -> io::Result<()>; } }
Simple Prompts Backend
Feature: simple-prompts (default)
Dependencies: none
Basic terminal prompts without external dependencies.
TextPromptSource
Simple text input prompt.
#![allow(unused)] fn main() { use standout_input::TextPromptSource; let source = TextPromptSource::new("Enter your name: "); let source = TextPromptSource::new("Email: ").trim(false); }
Behavior:
is_available(): Returnstrueif stdin is a terminalcollect(): Prints prompt, reads line, returnsNoneif emptycan_retry(): Returnstrue- Type:
String
Testing:
#![allow(unused)] fn main() { use standout_input::{TextPromptSource, MockTerminal}; let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Alice")); // Multiple responses for retry testing let terminal = MockTerminal::with_responses(["", "Bob"]); // Empty first, then "Bob" let source = TextPromptSource::with_terminal("Name: ", terminal); // Simulate EOF (Ctrl+D) let source = TextPromptSource::with_terminal("Name: ", MockTerminal::eof()); }
ConfirmPromptSource
Yes/no confirmation prompt.
#![allow(unused)] fn main() { use standout_input::ConfirmPromptSource; let source = ConfirmPromptSource::new("Proceed?"); let source = ConfirmPromptSource::new("Delete all?").default(false); }
Behavior:
is_available(): Returnstrueif stdin is a terminalcollect(): Prints prompt with[y/n],[Y/n], or[y/N]suffix based on default- Accepts:
y,yes,Y,YES→true;n,no,N,NO→false - Invalid input returns
ValidationFailederror (triggers retry) - Empty input uses default if set, otherwise returns
None can_retry(): Returnstrue- Type:
bool
Testing:
#![allow(unused)] fn main() { use standout_input::{ConfirmPromptSource, MockTerminal}; let source = ConfirmPromptSource::with_terminal("OK?", MockTerminal::with_response("y")); let source = ConfirmPromptSource::with_terminal("OK?", MockTerminal::with_response("no")); }
Custom Terminal IO
Implement TerminalIO for custom terminal behavior:
#![allow(unused)] fn main() { pub trait TerminalIO: Send + Sync { /// Check if stdin is a terminal. fn is_terminal(&self) -> bool; /// Write a prompt to stdout. fn write_prompt(&self, prompt: &str) -> io::Result<()>; /// Read a line from stdin. fn read_line(&self) -> io::Result<String>; } }
Inquire Backend
Feature: inquire
Dependencies: inquire crate (~29 dependencies)
Rich TUI prompts with arrow-key navigation, autocomplete, and visual feedback.
InquireText
Text input with autocomplete and help messages.
#![allow(unused)] fn main() { use standout_input::InquireText; let source = InquireText::new("What is your name?") .default("Anonymous") .placeholder("Your name...") .help("Enter your full name"); }
InquireConfirm
Polished yes/no prompt.
#![allow(unused)] fn main() { use standout_input::InquireConfirm; let source = InquireConfirm::new("Proceed with deployment?") .default(false) .help("This will deploy to production"); }
InquireSelect
Single selection from a list with arrow-key navigation.
#![allow(unused)] fn main() { use standout_input::InquireSelect; let source = InquireSelect::new("Choose environment:", vec![ "development", "staging", "production", ]) .help("Use arrow keys to select") .page_size(5); }
Type: Returns the selected item's type (T)
InquireMultiSelect
Multiple selection with checkboxes.
#![allow(unused)] fn main() { use standout_input::InquireMultiSelect; let source = InquireMultiSelect::new("Select features:", vec![ "logging", "metrics", "tracing", "profiling", ]) .help("Space to toggle, Enter to confirm") .min_selections(1) .max_selections(3) .page_size(10); }
Type: Returns Vec<T> of selected items
InquirePassword
Secure password input with masking.
#![allow(unused)] fn main() { use standout_input::InquirePassword; let source = InquirePassword::new("API token:") .help("Your token won't be displayed") .masked() // Show asterisks (default) .with_confirmation("Confirm token:"); // Require confirmation // Display modes let source = InquirePassword::new("Password:").hidden(); // No characters shown let source = InquirePassword::new("Password:").full(); // Show password as typed }
InquireEditor
Editor with preview in the terminal.
#![allow(unused)] fn main() { use standout_input::InquireEditor; let source = InquireEditor::new("Enter commit message:") .help("Press Enter to open editor") .extension(".md") .predefined_text("# Summary\n\n# Details\n"); }
Testing Inquire Sources
Inquire prompts are interactive and require a real terminal. For testing, use the simpler backends or test at the integration level with MockTerminal equivalents.
Implementing Custom Sources
Create custom sources by implementing InputCollector<T>:
#![allow(unused)] fn main() { use standout_input::{InputCollector, InputError}; use clap::ArgMatches; /// Read from a configuration file. struct ConfigFileSource { key: String, path: PathBuf, } impl ConfigFileSource { pub fn new(key: impl Into<String>, path: impl Into<PathBuf>) -> Self { Self { key: key.into(), path: path.into(), } } } impl InputCollector<String> for ConfigFileSource { fn name(&self) -> &'static str { "config file" } fn is_available(&self, _matches: &ArgMatches) -> bool { self.path.exists() } fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> { let content = std::fs::read_to_string(&self.path) .map_err(|e| InputError::PromptFailed(e.to_string()))?; // Parse as TOML and extract key let config: toml::Value = toml::from_str(&content) .map_err(|e| InputError::PromptFailed(e.to_string()))?; match config.get(&self.key) { Some(toml::Value::String(s)) => Ok(Some(s.clone())), Some(_) => Err(InputError::ValidationFailed( format!("Config key '{}' is not a string", self.key) )), None => Ok(None), } } } // Usage let source = ConfigFileSource::new("api_key", "~/.myapp/config.toml"); }
Making Sources Testable
Use the generic pattern to inject mock implementations:
#![allow(unused)] fn main() { use std::sync::Arc; pub trait ConfigReader: Send + Sync { fn read(&self, key: &str) -> Result<Option<String>, InputError>; fn exists(&self) -> bool; } pub struct ConfigFileSource<R: ConfigReader = RealConfigReader> { reader: Arc<R>, key: String, } impl ConfigFileSource<RealConfigReader> { pub fn new(key: impl Into<String>, path: impl Into<PathBuf>) -> Self { Self { reader: Arc::new(RealConfigReader::new(path)), key: key.into(), } } } impl<R: ConfigReader> ConfigFileSource<R> { pub fn with_reader(key: impl Into<String>, reader: R) -> Self { Self { reader: Arc::new(reader), key: key.into(), } } } // Mock for testing pub struct MockConfigReader { values: HashMap<String, String>, } impl MockConfigReader { pub fn new() -> Self { Self { values: HashMap::new() } } pub fn with_value(mut self, key: &str, value: &str) -> Self { self.values.insert(key.to_string(), value.to_string()); self } } impl ConfigReader for MockConfigReader { fn read(&self, key: &str) -> Result<Option<String>, InputError> { Ok(self.values.get(key).cloned()) } fn exists(&self) -> bool { true } } // Test #[test] fn test_config_source() { let reader = MockConfigReader::new().with_value("token", "secret123"); let source = ConfigFileSource::with_reader("token", reader); let result = source.collect(&empty_matches()).unwrap(); assert_eq!(result, Some("secret123".to_string())); } }
Summary
| Backend | Feature | Dependencies | Sources |
|---|---|---|---|
| Core | always | clap, thiserror | ArgSource, FlagSource, StdinSource, EnvSource, ClipboardSource, DefaultSource |
| Editor | editor | tempfile, which | EditorSource |
| Simple Prompts | simple-prompts | none | TextPromptSource, ConfirmPromptSource |
| Inquire | inquire | inquire | InquireText, InquireConfirm, InquireSelect, InquireMultiSelect, InquirePassword, InquireEditor |
All sources follow the same pattern:
- Implement
InputCollector<T> - Accept a mock via
with_reader()orwith_runner() - Return
Ok(None)to pass to the next source in the chain - Return
Ok(Some(value))when input is collected - Return
Err(...)to abort the chain with an error
Framework Integration
This page describes how standout-input plugs into the standout CLI framework so that input chains become a declarative part of your command configuration. If you only want to use standout-input standalone, see Introduction to Input — the framework integration is purely additive.
The Picture
Without framework integration, a handler resolves chains imperatively:
#![allow(unused)] fn main() { fn create(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Pad> { let body = InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource::new()) .try_source(EditorSource::new()) .resolve(matches)?; // <-- handler does this itself /* business logic ... */ } }
That works, but the chain becomes invisible to anyone reading the command's registration: input rules are mixed in with logic, and you can't see at a glance "this command takes a body that may come from arg / stdin / editor".
With the integration, the chain is part of CommandConfig, just like template, hooks, and pipe_through:
#![allow(unused)] fn main() { use standout::cli::{App, CommandContextInput, Output}; use standout::input::{ArgSource, EditorSource, InputChain, StdinSource}; App::builder() .command_with("create", create, |cfg| { cfg.template("create.jinja") .input("body", InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource::new()) .try_source(EditorSource::new())) })? .build()?; fn create(_m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Value> { let body: &String = ctx.input("body")?; // <-- already resolved /* business logic ... */ } }
The chain runs in the pre-dispatch phase — before the handler is called — so handlers always see fully-resolved input. Errors during resolution (validation failure, editor cancelled, …) abort the request before any business logic runs.
Where Resolution Happens
standout's execution pipeline runs hooks in three phases:
parsed CLI args → PRE-DISPATCH → handler → POST-DISPATCH → render → POST-OUTPUT
.input(name, chain) is sugar over .pre_dispatch(...) — the same hook used for auth checks, request-scoped state, etc. Each .input(...) call adds one pre-dispatch hook that:
- Walks to the deepest subcommand's
ArgMatches(so chains see the same args the handler does). - Calls
chain.resolve_with_source(matches). - Stashes the result in an
Inputsbag onctx.extensionsundername.
If resolution returns an error, dispatch stops and the framework reports Hook error: input `body`: <error message>. The handler does not run.
Reading Inputs in the Handler
Bring the CommandContextInput extension trait into scope and call .input::<T>(name):
#![allow(unused)] fn main() { use standout::cli::{CommandContextInput, Output}; fn create(_m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Value> { let body: &String = ctx.input("body")?; let force: &bool = ctx.input("force")?; /* ... */ } }
The lookup is by (name, T). If the name was never registered, you get a MissingInput::NotRegistered error. If the registered type doesn't match T, you get MissingInput::TypeMismatch. The error type implements std::error::Error and converts cleanly with ?.
Inspecting the source
Sometimes you want to know where an input came from — for instance, to log "title was read from clipboard" or to alter behavior when input is piped vs. interactive:
#![allow(unused)] fn main() { match ctx.input_source("body") { Some(InputSourceKind::Editor) => log::info!("body composed in editor"), Some(InputSourceKind::Stdin) => log::info!("body piped from stdin"), Some(other) => log::debug!("body came from {other}"), None => unreachable!("body is registered, so it was resolved"), } }
Iterating all inputs
For diagnostic output (like --explain flags) you can grab the whole bag:
#![allow(unused)] fn main() { if let Some(bag) = ctx.inputs() { for (name, source) in bag.iter_sources() { eprintln!(" {name}: {source}"); } } }
Multiple Inputs
.input(...) accumulates. A command can declare any number of named inputs of any types — including multiple inputs of the same type, which the TypeId-keyed ctx.app_state / raw ctx.extensions cannot disambiguate:
#![allow(unused)] fn main() { .command_with("create", create, |cfg| { cfg.template("create.jinja") .input("title", InputChain::<String>::new() .try_source(ArgSource::new("title")) .default("untitled".to_string())) .input("body", InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource::new()) .try_source(EditorSource::new())) .input("force", InputChain::<bool>::new() .try_source(FlagSource::new("force")) .default(false)) }) }
Each chain runs in registration order during pre-dispatch. They share the same Inputs bag on ctx.extensions, so two String inputs (title, body) coexist without colliding.
Validation
Chain-level validation runs as part of resolve_with_source. If validation fails on a non-interactive source, the pre-dispatch hook returns an error and dispatch aborts:
#![allow(unused)] fn main() { .input("body", InputChain::<String>::new() .try_source(ArgSource::new("body")) .validate(|s| !s.trim().is_empty(), "body must not be empty")) }
If the user runs mycli create --body " ", the framework reports:
Hook error: input `body`: validation failed: body must not be empty
For interactive sources (prompts, editor), validation failure re-prompts instead of aborting — the chain decides the loop. See Backends for the full validation/retry semantics.
Testing
The framework path composes naturally with standout-test:
#![allow(unused)] fn main() { use standout_test::TestHarness; #[test] fn create_uses_arg_when_provided() { let app = build_app(); let cmd = my_clap_command(); let result = TestHarness::new() .text_output() .run(&app, cmd, ["mycli", "create", "--body", "hello"]); result.assert_stdout_contains("hello"); } #[test] fn create_falls_back_to_stdin() { let app = build_app(); let cmd = my_clap_command(); let result = TestHarness::new() .piped_stdin("from pipe\n") .text_output() .run(&app, cmd, ["mycli", "create"]); result.assert_stdout_contains("from pipe"); } }
The harness installs MockStdin / MockClipboard via standout-input's process-global default readers, so StdinSource::new() and ClipboardSource::new() inside the chain transparently see the mocks. No source code changes are needed to make the chain testable.
For lower-level tests that don't need the harness, you can manipulate the readers directly with set_default_stdin_reader and friends; serialize tests that touch them with #[serial] from serial_test.
Re-exports and Feature Flags
standout re-exports standout-input as standout::input, so a single dependency on standout is enough:
[dependencies]
standout = "7"
#![allow(unused)] fn main() { use standout::input::{ArgSource, InputChain, StdinSource}; }
A default standout dependency only enables standout-input's simple-prompts backend, which has no extra deps. The heavier backends are opt-in via these standout features:
| Feature | Enables | Adds deps |
|---|---|---|
input-editor | EditorSource (opens $VISUAL / $EDITOR) | tempfile, which, shell-words |
input-inquire | The Inquire* rich TUI prompt sources | inquire (~29 transitive) |
[dependencies]
standout = { version = "7", features = ["input-editor"] }
You can still depend on standout-input directly if you want to bypass the standout re-export and pick features there.
When NOT to Use the Builder Integration
The standalone chain.resolve(matches)? form is still the right tool when:
- Input shape depends on already-resolved values. If
--modedecides which other inputs to ask for, you can't precompute a static chain. - You're adopting
standoutincrementally and your handler isn't yet on the framework path. - You're using
standout-inputoutside thestandoutframework altogether.
In every other case, .input(...) keeps the command's input contract visible at registration time, alongside its template and hooks.
Interactive Flows
This page is for apps that drive an interactive shell themselves — wizards, setup helpers, REPLs, anything that asks one question, reacts, asks the next. standout does not own the driver loop; you do. What it does provide is the two ingredients each step needs:
- Dynamic, themed text for the step body — same
Renderer+Themeyou use for normal command output. - Prompts that work without a
&clap::ArgMatches— every interactive source instandout::inputexposes a.prompt()shortcut.
Composing those with a ~30-line step graph you own gives you the full pattern.
The Step Graph You Own
Standout is deliberately not opinionated about flow control. A small, hand-rolled state machine is the right tool — you get loops, jumps, early exit, branching on side-effect output, all in idiomatic Rust:
#![allow(unused)] fn main() { use std::collections::HashMap; enum Next { Go(&'static str), // jump to a step (also used to re-ask) Done, Quit, } struct Step { render: fn(&Ctx, &Renderer) -> String, prompt: fn(&Ctx) -> Result<Answer, FlowError>, branch: fn(Answer, &mut Ctx) -> Next, } struct Ctx { /* whatever your wizard accumulates */ } enum Answer { Text(String), Bool(bool), Choice(usize) } fn run(steps: &HashMap<&str, Step>, mut ctx: Ctx, r: &Renderer) -> Result<(), FlowError> { let mut cur = "intro"; loop { let step = &steps[cur]; println!("{}", (step.render)(&ctx, r)); let answer = (step.prompt)(&ctx)?; match (step.branch)(answer, &mut ctx) { Next::Go(next) => cur = next, Next::Done => return Ok(()), Next::Quit => return Err(FlowError::Cancelled), } } } }
That's the whole driver. From here on we focus on what each step looks like.
A Step in Detail
Render
Every step's body is a registered template, rendered against Ctx. Templates can use the full styling system: colors, adaptive themes, tags, {% if %} / {% for %}. The same machinery your CLI commands already use.
#![allow(unused)] fn main() { // One-time setup, before the loop let theme = Theme::default() .add("title", Style::new().bold().cyan()) .add("path", Style::new().green()); let mut renderer = Renderer::new(theme)?; renderer.add_template("pick_pack", PICK_PACK_TPL)?; // Inside the step's render fn fn render_pick_pack(ctx: &Ctx, r: &Renderer) -> String { r.render("pick_pack", ctx).expect("template") } }
The body of pick_pack template is just a normal standout template:
[title]Choose a pack[/title]
Found [count]{{ packs | length }}[/count] packs in [path]{{ root }}[/path]:
{% for p in packs %}
- {{ p.name }}{% if p.recommended %} [hint](recommended)[/hint]{% endif %}
{% endfor %}
Use embed_templates! for static templates so the wizard ships with no runtime file dependencies.
Prompt
Every interactive source exposes .prompt(). No &ArgMatches, no chain — just call it:
#![allow(unused)] fn main() { use standout::input::{InquireSelect, InquireText, InquireConfirm}; // Free-form text let pack = InquireText::new("Pack name:") .help("a-z0-9-") .prompt()?; // Result<String, InputError> // Pick from options let env = InquireSelect::new("Environment:", vec!["dev", "staging", "prod"]) .prompt()?; // Result<&'static str, _> // Yes/no let proceed = InquireConfirm::new("Continue?") .default(true) .prompt()?; // Result<bool, _> }
Behavior:
- Stdin not a TTY or empty submission →
InputError::NoInput - Otherwise → the typed value
- User cancellation is backend-specific:
Inquire*prompts: Esc / Ctrl+C →InputError::PromptCancelledTextPromptSource/ConfirmPromptSource: EOF (Ctrl+D) →InputError::PromptCancelled; Ctrl+C terminates the process the same way it does for any line-buffered readEditorSource(withrequire_save): closing the editor without saving →InputError::EditorCancelled
A re-ask on bad input is a single match:
#![allow(unused)] fn main() { fn prompt_pack_name(_ctx: &Ctx) -> Result<Answer, FlowError> { loop { let pack = InquireText::new("Pack name:").prompt()?; if valid_pack_name(&pack) { return Ok(Answer::Text(pack)); } // Could render an error template here for context eprintln!("Pack names must be lowercase a-z, 0-9, '-'."); } } }
Same idea for EditorSource if a step opens an editor:
#![allow(unused)] fn main() { let body = EditorSource::new() .extension(".md") .initial_content("# Pack notes\n\n") .prompt()?; }
Branch
Pure user code. The branch decides the next step from the answer plus any side-effects you ran:
#![allow(unused)] fn main() { fn branch_pick_pack(answer: Answer, ctx: &mut Ctx) -> Next { let Answer::Text(pack) = answer else { return Next::Quit }; ctx.pack = Some(pack.clone()); match read_status(&ctx.root, &pack) { Ok(s) if s.dirty => Next::Go("confirm_dirty"), Ok(_) => Next::Go("apply"), Err(_) => Next::Go("setup_help"), } } }
Restart Later
"Run the wizard again next week" is just run(&steps, Ctx::fresh(), &renderer). If you want to resume mid-flow with previously collected state, make Ctx Serialize/Deserialize, persist on each branch, and pass cur and Ctx into run. Standout doesn't standardize a checkpoint format — but every piece of Ctx is your data, so serde is fine.
Section Framing (cliclack-style)
cliclack ships nice intro/outro/note/log helpers for visual pacing. Standout doesn't ship equivalents, but the pattern is two lines of template:
{# templates/note.jinja #}
[note_marker]●[/note_marker] [note_title]{{ title }}[/note_title]
{{ body }}
#![allow(unused)] fn main() { fn note(r: &Renderer, title: &str, body: &str) { let v = serde_json::json!({ "title": title, "body": body }); println!("{}", r.render("note", &v).unwrap()); } }
Style note_marker and note_title in your theme — adaptive light/dark falls out for free.
Putting It Together
use std::collections::HashMap; use standout::{Renderer, Theme}; use standout::input::{InquireConfirm, InquireSelect, InquireText}; fn main() -> anyhow::Result<()> { let mut renderer = Renderer::new(theme())?; register_templates(&mut renderer)?; let steps: HashMap<&str, Step> = HashMap::from([ ("intro", Step { render: render_intro, prompt: noop_prompt, branch: |_, _| Next::Go("pick_pack") }), ("pick_pack", Step { render: render_pick_pack, prompt: prompt_pack, branch: branch_pick_pack }), ("confirm_dirty",Step { render: render_dirty, prompt: prompt_confirm, branch: branch_dirty }), ("apply", Step { render: render_apply, prompt: noop_prompt, branch: |_, _| Next::Done }), ("setup_help", Step { render: render_help, prompt: noop_prompt, branch: |_, _| Next::Done }), ]); let ctx = Ctx::fresh(); run(&steps, ctx, &renderer)?; Ok(()) }
You wrote ~50 lines of glue and got: themed dynamic text per step, polished TUI prompts, branching, looping, re-ask, restart. That's the deal: standout owns the I/O quality, you own the flow shape.
Testing Wizards
A wizard built on .prompt() is fully testable in process — no real TTY, no expectrl subprocess. Every interactive source consults a PromptResponder before it touches stdin; in tests you install a ScriptedResponder and the production wizard code is unchanged.
#![allow(unused)] fn main() { use serial_test::serial; use standout_input::{PromptResponse, ScriptedResponder}; use standout_test::TestHarness; use std::sync::Arc; #[test] #[serial] fn setup_wizard_creates_pack_and_picks_environment() { let result = TestHarness::new() .prompts(Arc::new(ScriptedResponder::new([ PromptResponse::text("foo"), // pack name PromptResponse::Bool(true), // confirm dirty PromptResponse::Choice(2), // env: dev=0, staging=1, prod=2 -> "prod" ]))) .run(&app(), command(), ["mycli", "setup"]); result.assert_success(); result.assert_stdout_contains("Created pack `foo` in prod"); } }
Two design choices to keep tests honest:
- Open prompts (
InquireText,InquirePassword,InquireEditor,TextPromptSource,EditorSource) takePromptResponse::Text("...")— the answer is the value. - Finite-choice prompts take a position, not a label.
Choice(2)picksoptions[2]from whatever the wizard passed toInquireSelect::new. Renaming"Production"to"Live"in the option list doesn't break a test that picked index 2 — the wizard logic is unchanged, only copy moved. Same forConfirm: assert on the bool, not on"y"/"yes".
ScriptedResponder validates each response against the prompt kind the source actually asked for. A wizard reorder bug — e.g., a Confirm step swapped to land where a Text was expected — fails the test loudly with the position, the prompt kind, and the queued response, rather than producing a silently wrong assertion three steps later.
Two kind-agnostic responses cover the cancel and skip branches:
#![allow(unused)] fn main() { PromptResponse::Cancel // -> Err(InputError::PromptCancelled) inside the wizard PromptResponse::Skip // -> Err(InputError::NoInput) — same path as "no TTY" }
Use them to test the wizard's abort and re-ask logic without involving real signal handling.
For lower-level tests that don't need the harness, install the responder directly:
#![allow(unused)] fn main() { use std::sync::Arc; use standout_input::{ set_default_prompt_responder, reset_default_prompt_responder, ScriptedResponder, PromptResponse, }; #[test] #[serial(prompt_responder)] fn pack_name_validation_re_asks_on_invalid() { set_default_prompt_responder(Arc::new(ScriptedResponder::new([ PromptResponse::text("BadName!"), // first try, rejected by validator PromptResponse::text("good-name"), // re-ask, accepted ]))); assert_eq!(prompt_pack_name(&Ctx::fresh()).unwrap(), Answer::Text("good-name".into())); reset_default_prompt_responder(); } }
This serializes on the prompt_responder axis (the global override is process-wide, like stdin / clipboard). The harness handles the install + reset for you when used as .prompts(...).
When to Reach for the Framework Instead
If your interactive flow is launched as a subcommand of an otherwise-normal CLI app (e.g. mycli setup), you can still use App::builder() for everything outside the wizard — argument parsing, help rendering, the other commands. Just have the setup handler call your wizard run() function. The handler itself produces Output::Silent (or a small summary) and lets the wizard own its own stdout while it runs. See Framework Integration for the broader CLI integration story.
Output Modes
Standout supports multiple output formats through a single handler because modern CLI tools serve two masters: human operators and machine automation.
The same handler logic produces styled terminal output for eyes, plain text for logs, or structured JSON for jq pipelines—controlled entirely by the user's --output flag. This frees you from writing separate "API" and "CLI" logic.
The OutputMode Enum
#![allow(unused)] fn main() { pub enum OutputMode { Auto, // Auto-detect terminal capabilities Term, // Always use ANSI escape codes Text, // Never use ANSI codes (plain text) TermDebug, // Keep style tags as [name]...[/name] Json, // Serialize as JSON (skip template) Yaml, // Serialize as YAML (skip template) Xml, // Serialize as XML (skip template) Csv, // Serialize as CSV (skip template) } }
Three categories:
Templated modes (Auto, Term, Text): Render the template, vary ANSI handling.
Debug mode (TermDebug): Render the template, keep tags as literals for inspection.
Structured modes (Json, Yaml, Xml, Csv): Skip the template entirely, serialize handler data directly.
Auto Mode
Auto is the default. It queries the terminal for color support:
#![allow(unused)] fn main() { Term::stdout().features().colors_supported() }
If colors are supported, Auto behaves like Term (ANSI codes applied). If not, Auto behaves like Text (tags stripped).
This detection happens at render time, not startup. Piping output to a file or another process typically disables color support, so:
myapp list # Colors (if terminal supports)
myapp list > file.txt # No colors (not a TTY)
myapp list | less # No colors (pipe)
The --output Flag
Standout adds a global --output flag accepting these values:
myapp list --output=auto # Default
myapp list --output=term # Force ANSI codes
myapp list --output=text # Force plain text
myapp list --output=term-debug # Show style tags
myapp list --output=json # JSON serialization
myapp list --output=yaml # YAML serialization
myapp list --output=xml # XML serialization
myapp list --output=csv # CSV serialization
The flag is global—it applies to all subcommands.
Term vs Text
Term: Always applies ANSI escape codes, even when piping:
myapp list --output=term > colored.txt
Useful when you want to preserve colors for later display (e.g., less -R).
Text: Never applies ANSI codes:
myapp list --output=text
Useful for clean output regardless of terminal capabilities, or when processing output with other tools.
TermDebug Mode
TermDebug preserves style tags instead of converting them:
Template: [title]Hello[/title]
Output: [title]Hello[/title]
Use cases:
- Debugging template issues
- Verifying style tag placement
- Automated testing of template output
Unlike Term mode, unknown tags don't get the ? marker in TermDebug.
Structured Modes
Structured modes bypass the template entirely. Handler data is serialized directly:
#![allow(unused)] fn main() { #[derive(Serialize)] struct ListOutput { items: Vec<Item>, total: usize, } fn list_handler(...) -> HandlerResult<ListOutput> { Ok(Output::Render(ListOutput { items, total: items.len() })) } }
myapp list --output=json
{
"items": [...],
"total": 42
}
Same handler, same types—different output format. This enables:
- Machine-readable output for scripts
- Integration with other tools (
jq, etc.) - API-like behavior from CLI apps
CSV Output
CSV mode flattens nested JSON automatically. For more control, use FlatDataSpec.
See Tabular Layout for detailed CSV configuration.
#![allow(unused)] fn main() { let spec = FlatDataSpec::builder() .column(Column::new(Width::Fixed(10)).key("name").header("Name")) .column(Column::new(Width::Fixed(10)).key("meta.role").header("Role")) .build(); render_auto_with_spec(template, &data, &theme, OutputMode::Csv, Some(&spec))? }
The key field uses dot notation for nested paths ("meta.role" extracts data["meta"]["role"]).
File Output
The --output-file-path flag redirects output to a file:
myapp list --output-file-path=results.txt
myapp list --output=json --output-file-path=data.json
Behavior:
- Text output: written to file, nothing printed to stdout
- Binary output: written to file (same as without flag)
- Silent output: no-op
After writing to file, stdout output is suppressed to prevent double-printing.
Customizing Flags
Rename or disable the flags via AppBuilder:
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // --format instead of --output .output_file_flag(Some("out")) // --out instead of --output-file-path .build()? }
#![allow(unused)] fn main() { App::builder() .no_output_flag() // Disable --output entirely .no_output_file_flag() // Disable file output .build()? }
Accessing OutputMode in Handlers
CommandContext carries the resolved output mode:
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Data> { if ctx.output_mode.is_structured() { // Skip interactive prompts in JSON mode } if ctx.output_mode == OutputMode::Csv { // Maybe adjust data structure for flat output } Ok(Output::Render(data)) } }
Helper methods:
#![allow(unused)] fn main() { ctx.output_mode.should_use_color() // True for Term, depends on terminal for Auto ctx.output_mode.is_structured() // True for Json, Yaml, Xml, Csv ctx.output_mode.is_debug() // True for TermDebug }
Rendering Without CLI
For standalone rendering with explicit mode:
#![allow(unused)] fn main() { use standout::{render_auto, OutputMode}; // Renders template for Term/Text, serializes for Json/Yaml let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
The "auto" in render_auto refers to template-vs-serialize dispatch, not color detection.
For full control over both output mode and color mode:
#![allow(unused)] fn main() { use standout::{render_with_mode, ColorMode}; let output = render_with_mode( template, &data, &theme, OutputMode::Term, ColorMode::Dark, )?; }
App Configuration
AppBuilder is the unified entry point for configuring your application. Instead of scattering configuration across multiple structs (Standout, RenderSetup, Theme), everything from command registration to theme selection happens in one fluent interface.
This design ensures that your application defines its entire environment—commands, styles, templates, and hooks—before the runtime starts, preventing configuration race conditions and simplifying testing.
This guide covers the full setup: embedding resources, registering commands, configuring themes, and customizing behavior.
See also:
- Rendering System for details on templates and styles.
- Topics System for help topics.
Basic Setup
#![allow(unused)] fn main() { use standout::cli::App; use standout_macros::{embed_templates, embed_styles}; let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .command("list", list_handler, "list.j2") .build()?; app.run(Cli::command(), std::env::args()); }
Embedding Resources
Templates
embed_templates! embeds template files at compile time:
#![allow(unused)] fn main() { .templates(embed_templates!("src/templates")) }
Collects files matching: .jinja, .jinja2, .j2, .stpl, .txt (in priority order).
Custom template engines: For advanced use cases,
standout-rendersupports pluggable template engines. See the Template Engines topic for details on usingSimpleEngineor implementing custom engines.
Directory structure:
src/templates/
list.j2
add.j2
db/
migrate.j2
status.j2
Templates are referenced by path without extension: "list", "db/migrate".
Styles
embed_styles! embeds stylesheet files:
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) }
Collects files matching: .css (and legacy .yaml, .yml).
src/styles/
default.css
dark.css
light.css
Themes are referenced by filename without extension: "default", "dark".
Hot Reloading
In debug builds, embedded resources are re-read from disk on each render—edit without recompiling. In release builds, embedded content is used directly.
This is automatic when the source path exists on disk.
Runtime Overrides
Users can override embedded resources with local files:
#![allow(unused)] fn main() { App::builder() .templates(embed_templates!("src/templates")) .templates_dir("~/.myapp/templates") // Overrides embedded .styles(embed_styles!("src/styles")) .styles_dir("~/.myapp/themes") // Overrides embedded }
Local directories take precedence. This enables user customization without recompiling.
Theme Selection
From Stylesheet Registry
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) // Optional: set explicit default name // If omitted, tries "default", "theme", then "base" .default_theme("dark") }
If .default_theme() is not called, AppBuilder attempts to load a theme from the registry in this order:
defaultthemebase
This allows you to provide a standard base.css or theme.css without requiring explicit configuration code. If the explicit theme isn't found, build() returns SetupError::ThemeNotFound.
Explicit Theme
#![allow(unused)] fn main() { let theme = Theme::new() .add("title", Style::new().bold().cyan()) .add("muted", Style::new().dim()); App::builder() .theme(theme) // Overrides stylesheet registry }
Explicit .theme() takes precedence over .default_theme().
Command Registration
Simple Commands
#![allow(unused)] fn main() { App::builder() .command("list", list_handler, "list.j2") .command("add", add_handler, "add.j2") }
Arguments: command name, handler function, template path.
With Configuration
#![allow(unused)] fn main() { App::builder() .command_with("delete", delete_handler, |cfg| cfg .template("delete.j2") .pre_dispatch(require_confirmation) .post_dispatch(log_deletion)) }
Inline hook attachment without separate .hooks() call.
Nested Groups
#![allow(unused)] fn main() { App::builder() .group("db", |g| g .command("migrate", migrate_handler, "db/migrate.j2") .command("status", status_handler, "db/status.j2") .group("backup", |b| b .command("create", backup_create, "db/backup/create.j2") .command("restore", backup_restore, "db/backup/restore.j2"))) }
Creates command paths: db.migrate, db.status, db.backup.create, db.backup.restore.
From Dispatch Macro
#![allow(unused)] fn main() { #[derive(Dispatch)] enum Commands { List, Add, #[dispatch(nested)] Db(DbCommands), } App::builder() .commands(Commands::dispatch_config()) }
The macro generates registration for all variants.
Default Command
When a CLI is invoked without a subcommand (a "naked" invocation like myapp or myapp --verbose), you can specify a default command to run:
#![allow(unused)] fn main() { App::builder() .default_command("list") .command("list", list_handler, "list.j2") .command("add", add_handler, "add.j2") }
With this configuration:
myappbecomesmyapp listmyapp --output=jsonbecomesmyapp list --output=jsonmyapp add foostays asmyapp add foo(explicit command takes precedence)
With Dispatch Macro
Use the #[dispatch(default)] attribute to mark a variant as the default:
#![allow(unused)] fn main() { #[derive(Dispatch)] #[dispatch(handlers = handlers)] enum Commands { #[dispatch(default)] List, Add, } App::builder() .commands(Commands::dispatch_config()) }
Only one command can be marked as default. Multiple #[dispatch(default)] attributes will cause a compile error.
Hooks
Attach hooks to specific command paths:
#![allow(unused)] fn main() { App::builder() .command("migrate", migrate_handler, "migrate.j2") .hooks("db.migrate", Hooks::new() .pre_dispatch(require_admin) .post_dispatch(add_timestamp) .post_output(log_result)) }
The path uses dot notation matching the command hierarchy.
Context Injection
Add values available in all templates:
Static Context
#![allow(unused)] fn main() { App::builder() .context("version", "1.0.0") .context("app_name", "MyApp") }
Dynamic Context
#![allow(unused)] fn main() { App::builder() .context_fn("terminal_width", |ctx| { Value::from(ctx.terminal_width.unwrap_or(80)) }) .context_fn("timestamp", |_ctx| { Value::from(chrono::Utc::now().to_rfc3339()) }) }
Dynamic providers receive RenderContext with output mode, terminal width, and handler data.
Topics
Add help topics:
#![allow(unused)] fn main() { App::builder() .topics_dir("docs/topics") .add_topic(Topic::new("auth", "Authentication...", TopicType::Text, None)) }
See Topics System for details.
Flag Customization
Output Flag
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // --format instead of --output }
#![allow(unused)] fn main() { App::builder() .no_output_flag() // Disable entirely }
File Output Flag
#![allow(unused)] fn main() { App::builder() .output_file_flag(Some("out")) // --out instead of --output-file-path }
#![allow(unused)] fn main() { App::builder() .no_output_file_flag() // Disable entirely }
The App Struct
build() produces an App:
#![allow(unused)] fn main() { pub struct App { registry: TopicRegistry, output_flag: Option<String>, output_file_flag: Option<String>, output_mode: OutputMode, theme: Option<Theme>, command_hooks: HashMap<String, Hooks>, template_registry: Option<TemplateRegistry>, stylesheet_registry: Option<StylesheetRegistry>, } }
Running the App
Standard Execution
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy legacy_dispatch(matches); } }
Parses args, dispatches to handler, prints output. Returns Option<ArgMatches>—None if handled, Some(matches) for fallback.
Capture Output
For testing, post-processing, or when you need the output string:
#![allow(unused)] fn main() { match app.run_to_string(cmd, args) { RunResult::Handled(output) => { /* use output string */ } RunResult::Binary(bytes, filename) => { /* handle binary */ } RunResult::NoMatch(matches) => { /* fallback dispatch */ } } }
Returns RunResult instead of printing.
Parse Only
#![allow(unused)] fn main() { let matches = app.parse_with(cmd); // Use matches for manual dispatch }
Parses with Standout's augmented command but doesn't dispatch.
Build Validation
build() validates:
- Theme exists if
.default_theme()was called - Returns
SetupError::ThemeNotFoundif not found
What's NOT validated at build time:
- Templates (resolved lazily at render time)
- Command handlers
- Hook signatures (verified at registration)
Complete Example
use standout::cli::{App, HandlerResult, Output}; use standout_macros::{embed_templates, embed_styles}; use clap::{Command, Arg}; use serde::Serialize; #[derive(Serialize)] struct ListOutput { items: Vec<String>, } fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListOutput> { let items = vec!["one".into(), "two".into()]; Ok(Output::Render(ListOutput { items })) } fn main() -> Result<(), Box<dyn std::error::Error>> { let cli = Command::new("myapp") .subcommand(Command::new("list").about("List items")); let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .context("version", env!("CARGO_PKG_VERSION")) .command("list", list_handler, "list.j2") .topics_dir("docs/topics") .build()?; app.run(cli, std::env::args()); Ok(()) }
Template src/templates/list.j2:
[header]Items[/header] ({{ items | length }} total)
{% for item in items %}
- {{ item }}
{% endfor %}
[muted]v{{ version }}[/muted]
Style src/styles/default.css:
.header { color: cyan; font-weight: bold; }
.muted { opacity: 0.5; }
Testing
Standout treats testability as a primary design constraint, not an afterthought. This page is the reference view: how Standout's layers compose to make a CLI testable, which seams the framework exposes, and where each testing technique fits.
For the tutorial introduction — how to use TestHarness starting from a small surface — see Introduction to Testing.
Why this section exists
Most CLI frameworks punt on testing. Users end up with one of two patterns: (a) a tangled handler they can't unit test, tested only via subprocess + regex on stdout; (b) a split architecture they enforce by convention, with scaffolding to match mocks to sources duplicated across every test file. Standout tries to make the clean path the easy path.
This is a mix of architectural choices (which move more testable code closer to the surface) and concrete tooling (standout-test, environment detectors, default reader shims).
Three levels, three tools
A Standout app has three testing layers, each appropriate to a different kind of change:
| Level | What it covers | Tool | Speed |
|---|---|---|---|
| Unit | A single handler's logic, as a pure function | Plain #[test] + direct call | Microseconds |
| Integration | Full dispatch pipeline in-process: argv → handler → render | standout-test::TestHarness | Microseconds to low milliseconds |
| End-to-end | Real process, real PTY, real signals, real subprocess fan-out | assert_cmd, expectrl, rexpect | Tens to hundreds of milliseconds per test |
Choose by what the change touches. A bug in a filter predicate belongs in level 1. A bug in "does this command actually read $TODO_FILE?" belongs in level 2. A bug in raw-mode TUI redraw belongs in level 3 — and is a signal to look hard at whether the logic in question could be extracted.
What each layer gives you for free
Handlers are pure functions
The HandlerResult<T> contract is that a handler takes &ArgMatches + &CommandContext and returns a serializable value. It doesn't touch stdout. It doesn't render. It returns data.
This alone covers the majority of a real CLI's logic surface. You test handlers the way you test any Rust function: construct inputs, call, assert on the output struct. No stdout capture, no regex.
For the canonical example, see the Introduction to Standout. The key invariant is that nothing about the handler depends on terminal state.
Clap is already tested
Argument parsing is clap's responsibility, and clap has an extensive test suite of its own. You don't need to rewrite those tests; you just need to trust the seam. If you have truly exotic arg-parsing logic, test it by calling Command::try_get_matches_from(...) directly — that's clap's in-process API.
Rendering is already tested
standout-render has snapshot tests for MiniJinja template evaluation, CSS parsing, style resolution, tag transforms, tabular layouts, and every output mode. Again, you don't need to re-test it — you need to test that your templates render the shape of data you think they do. The harness covers that naturally by running the full pipeline.
What the harness adds
TestHarness (in the standout-test crate) is the unified in-process runner. It wraps App::run_to_string with fluent setup for every injectable piece of state:
- Env vars (real
std::env::set_var, originals captured and restored on drop) - Working directory (real
std::env::set_current_dir, original restored on drop) - Fixture files (written into a
tempfile::TempDir) - Terminal detectors: width, TTY, color capability
- Stdin reader (process-global override consulted by
StdinSource::new()) - Clipboard reader (same mechanism for
ClipboardSource::new()) - Interactive prompt responder (process-global override consulted by every interactive source's
.prompt()shortcut, so wizard handlers are testable in process — see Interactive Flows → Testing Wizards) - Forced
OutputMode(injected as--output=<mode>into argv)
A RestoreState held inside the returned TestResult runs on drop — on both normal exit and panic unwind — and tears down every override, so a failing assertion never leaks state into sibling tests. Two nuances worth knowing:
- Env vars and cwd are restored to the values captured at
run()time. This is a true "put it back the way you found it." - Terminal detectors and default stdin/clipboard readers are reset to the library defaults, not to whatever was installed before
run(). If you mixTestHarnesswith a manually installedset_*_detector/set_default_*_readeron the same thread, the harness's drop will wipe your override. Keep them separate, or scope the manual override entirely outside the harness.
The harness is #[must_use]: a TestHarness::new() without a .run(...) does nothing and gets flagged by the compiler.
See Introduction to Testing for the full builder tour.
Environment seams exposed by the framework
The harness doesn't invent new mechanisms; it wires together seams that Standout exposes deliberately, all of which you can also use directly.
standout-render::environment
The render crate exposes three overridable detectors:
#![allow(unused)] fn main() { use standout_render::{ set_terminal_width_detector, set_tty_detector, set_color_capability_detector, reset_environment_detectors, DetectorGuard, }; }
Each takes a fn() -> T (function pointer or non-capturing closure). DetectorGuard is a RAII helper that resets all three on drop.
These drive OutputMode::Auto's color decision and the render context's terminal width. Install an override in any test that snapshots rendered output, and the result becomes deterministic across machines.
standout-input default readers
StdinSource::new() and ClipboardSource::new() resolve their reader through the DefaultStdin / DefaultClipboard shims. Each shim first consults a process-global override; if none is installed, it falls back to the real OS-backed reader.
#![allow(unused)] fn main() { use std::sync::Arc; use standout_input::{ set_default_stdin_reader, reset_default_stdin_reader, set_default_clipboard_reader, reset_default_clipboard_reader, }; use standout_input::env::{MockStdin, MockClipboard}; set_default_stdin_reader(Arc::new(MockStdin::piped("hello"))); // ... run test ... reset_default_stdin_reader(); }
Handlers that use StdinSource::new() / ClipboardSource::new() / read_if_piped() pick up the mock transparently — no handler refactor needed.
Handlers that need per-instance control keep using StdinSource::with_reader(MockStdin::piped(...)) as before.
standout-input prompt responder
The .prompt() shortcut on every interactive source (InquireText, InquireSelect, TextPromptSource, EditorSource, …) consults a process-global PromptResponder before opening any real prompt. Install one to make wizard handlers testable in-process:
The override is process-global, so tests installing it directly must (a) carry a #[serial(prompt_responder)] attribute and (b) reset on every exit path including panics — either via an RAII guard or by preferring the harness, which handles both:
#![allow(unused)] fn main() { use std::sync::Arc; use serial_test::serial; use standout_input::{ set_default_prompt_responder, reset_default_prompt_responder, ScriptedResponder, PromptResponse, }; struct ResponderGuard; impl Drop for ResponderGuard { fn drop(&mut self) { reset_default_prompt_responder(); } } #[test] #[serial(prompt_responder)] fn pack_name_re_asks_on_invalid() { set_default_prompt_responder(Arc::new(ScriptedResponder::new([ PromptResponse::text("BadName!"), // rejected by validator PromptResponse::text("good-name"), // accepted on re-ask ]))); let _guard = ResponderGuard; // resets even if assertions panic // ... call the wizard step under test, assert, etc ... } }
Most tests should reach for TestHarness::prompts(...) instead — the harness's RestoreState runs reset_default_prompt_responder() on drop just like it does for stdin and clipboard, so the boilerplate disappears.
Open prompts (Text/Password/Editor) take a Text(String); finite-choice prompts (Confirm/Select/MultiSelect) take a Bool / Choice(usize) / Choices(Vec<usize>). Position-based responses are deliberate: a test that picked Choice(2) keeps working when you rename "Production" to "Live". ScriptedResponder panics on kind mismatch so a wizard reorder fails loudly. PromptResponse::Cancel and PromptResponse::Skip are kind-agnostic and let tests cover the abort and re-ask paths without real signal handling. See Interactive Flows for the wizard-shape walkthrough and TestHarness::prompts(...) for the harness-level wiring.
Env vars and cwd
These aren't proxied through a Standout abstraction — they're just real OS primitives. Use std::env::set_var / std::env::set_current_dir (directly or through the harness). The harness adds: (a) capture-and-restore around .run(), and (b) a tempdir per test for fixtures.
Concurrency model
Every seam above is process-global. Parallel tests that mutate them will interfere with each other.
Use #[serial] from the serial_test crate (re-exported as standout_test::serial) on every test that uses TestHarness or any of the lower-level detectors. Within a test binary, serial execution is automatic; across test binaries, cargo runs one test binary at a time by default, so there's no extra coordination needed.
Recipes
Snapshot testing with insta
Pin terminal state for determinism, run, snapshot the output:
#![allow(unused)] fn main() { use insta::assert_snapshot; #[test] #[serial] fn list_snapshot() { let result = TestHarness::new() .fixture("todos.txt", "a\nb\nc\n") .terminal_width(80) .no_color() .run(&app(), command(), ["todo", "list"]); assert_snapshot!(result.stdout()); } }
Asserting JSON shape
Force OutputMode::Json to bypass the template and serialize the handler's data directly:
#![allow(unused)] fn main() { let result = TestHarness::new() .output_mode(OutputMode::Json) .run(&app, cmd, ["myapp", "list"]); let v: serde_json::Value = serde_json::from_str(result.stdout()).unwrap(); assert_eq!(v["todos"].as_array().unwrap().len(), 3); }
Testing a handler without going through dispatch
For pure logic tests, skip the harness entirely:
#![allow(unused)] fn main() { #[test] fn filter_excludes_done_by_default() { let matches = Command::new("t") .arg(clap::Arg::new("all").long("all").action(clap::ArgAction::SetTrue)) .try_get_matches_from(["t"]) .unwrap(); let ctx = CommandContext::default(); let Output::Render(result) = list(&matches, &ctx).unwrap() else { panic!() }; assert!(result.todos.iter().all(|t| matches!(t.status, Status::Pending))); } }
This path has no #[serial] requirement — nothing global is touched.
Mixing levels
A common layout for a CLI crate:
tests/
├── handlers.rs # level 1 — direct handler calls
├── harness.rs # level 2 — TestHarness integration tests
└── e2e.rs # level 3 — assert_cmd for the few things the harness can't cover
Run them together with cargo test. Level 1 is by far the largest file; level 3 is usually less than a dozen tests.
Boundaries
TestHarness is an in-process runner. It cannot simulate:
- Real PTY.
isatty()on the real stdin file descriptor, raw-mode terminals, progress bars that depend on cursor control. Useexpectrl/rexpectwith a spawned subprocess. - Signals. SIGINT / SIGTERM handling needs a real process.
- Shelling out from your handler. If a handler invokes
git,rg,$EDITOR, etc., those run as real subprocesses in the test too. AProcessRunnerabstraction to address this is in progress (Phase 3 of the test-tooling work); until it lands, structure shell-outs behind a local trait you can swap for a mock in handler tests. - Build / linker integration. Testing that the compiled binary has the right embedded resources, dependencies, or
--versionoutput is fair game for a smallassert_cmdsuite.
The goal is to keep level-3 tests small and intentional — the cases where you really do need a real process — and put everything else at level 1 or 2.
See also
- Introduction to Testing — the tutorial
- Handler Contract — what makes a handler pure
- Output Modes — forcing deterministic output
- Introduction to Input — input sources and their mock variants