Standout
Test your data. Render your view.
Standout is a CLI framework for Rust that enforces separation between logic and presentation. Keep application behavior in a CLI-free library; handlers adapt that behavior into serializable CLI view data instead of strings.
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, the library owns behavior, handlers return CLI view data, and the framework handles rendering:
#![allow(unused)] fn main() { #[handler] fn list( #[flag] all: bool, #[ctx] ctx: &CommandContext, ) -> Result<Output<TodoListView>, anyhow::Error> { let store = ctx.app_state.get_required::<TodoStore>()?; let filter = if all { TodoFilter::All } else { TodoFilter::Pending }; let todos = store.list(filter).into_iter().map(TodoView::from).collect(); let total = todos.len(); Ok(Output::Render(TodoListView { todos, total })) } #[test] fn test_list_returns_pending_view() { let Output::Render(result) = list(false, &ctx).unwrap() else { panic!("expected rendered data"); }; assert!(result.todos.iter().all(|todo| !todo.done)); } }
Test filtering and state transitions through the library interface. Test only the mapping and returned view struct in the handler. No stdout capture, regex, or template coupling. See the production-shaped application.
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 typed handler adapters.
#![allow(unused)] fn main() { use standout::cli::{CommandContext, Dispatch, Output}; use standout::handler; use clap::{Parser, Subcommand}; use serde::Serialize; use todo_core::{Todo, TodoFilter, TodoStore}; #[derive(Parser)] #[command(name = "myapp")] pub struct Cli { #[command(subcommand)] pub command: Commands, } #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] // handlers are in the `handlers` module pub enum Commands { #[dispatch(pure)] List, #[dispatch(pure)] Add { title: String }, } #[derive(Serialize)] struct TodoResult { todos: Vec<Todo>, } mod handlers { use super::*; #[handler] pub fn list(#[ctx] ctx: &CommandContext) -> Result<Output<TodoResult>, anyhow::Error> { let core = ctx.app_state.get_required::<TodoStore>()?; Ok(Output::Render(TodoResult { todos: core.list(TodoFilter::Pending) })) } #[handler] pub fn add( #[arg] title: String, #[ctx] ctx: &CommandContext, ) -> Result<Output<TodoResult>, anyhow::Error> { let core = ctx.app_state.get_required::<TodoStore>()?; Ok(Output::Render(TodoResult { todos: vec![core.add(title)?] })) } } }
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 clap::CommandFactory; use standout::cli::App; use standout::{embed_templates, embed_styles}; use todo_core::TodoStore; fn main() -> Result<(), Box<dyn std::error::Error>> { let store = TodoStore::load("todos.json")?; let app = App::builder() .app_state(store) // the handlers above read it back with `get_required` .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .commands(Commands::dispatch_config())? // Register handlers from derive macro .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
- CLI-free library separated from shell presentation
- Handlers adapt library results; framework handles rendering
- Core behavior and CLI adapters testable 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 standout-dispatch
Migrating an Existing CLI
Already have a CLI? Standout supports incremental adoption. run reports
whether Standout handled the command:
#![allow(unused)] fn main() { if !app.run(Cli::command(), std::env::args()) { your_existing_dispatch(); } }
Use run_to_string(...) and match DispatchResult::NoMatch(matches) on
into_outcome() when the legacy dispatcher needs the unmatched ArgMatches.
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
Guides
Step-by-step walkthroughs covering principles, rationale, and features.
Framework Guides
- Introduction to Standout — Adopting Standout in a working CLI. Start here.
- TLDR Quick Start — Fast-paced intro for experienced developers.
- Introduction to Testing — Testing Standout CLIs end-to-end, in-process, with full environment control.
- Leveraging Standout: Value and Implementation Quality Checklist — Review ownership, rendering, testing, optional capabilities, and framework gaps.
Crate Guides
For detailed guides on the underlying libraries:
Rendering (standout-render)
- Introduction to Rendering — Creating polished terminal output with templates and styles.
- Introduction to Tabular — Building aligned, readable tabular layouts.
Dispatch (standout-dispatch)
- Introduction to Dispatch — The execution pattern with handlers and hooks.
Where to Start
If you're new to Standout, begin with Introduction to Standout. It walks through adopting Standout in an existing CLI, step by step.
For a quick overview without the explanations, see the TLDR Quick Start.
Once you have the framework in place, Introduction to Testing shows how the architecture plus the standout-test harness largely replaces slow, brittle subprocess-based CLI tests with fast in-process ones.
Use the implementation quality checklist to review whether an application preserves Standout's invariants, applies relevant capabilities, and identifies framework gaps explicitly.
If you want to use the crates independently (without the full framework), start with the crate-specific guides above.
Bootstrap a Standout project
The standout package includes a new-project wizard that creates a small,
runnable workspace. Use it when you want the production-shaped Standout
ownership split without assembling the first command, template, theme, and
tests by hand.
The result is an architectural starter, not a complete application. A CLI-free library owns reusable behavior, while a binary crate owns Clap, Standout assembly, input-source policy, view types, templates, styles, and process execution.
Install and start the wizard
Install the package's standout executable from crates.io:
cargo install standout
Run the wizard from the directory that should contain the new project:
standout new-project
The project name is also the destination directory. The wizard refuses to overwrite a non-empty destination.
The questionnaire asks for the project and executable names, one initial
command, its inputs, and a message or record result. It then prints the
destination, generated files, command syntax, source precedence, core
operation, output shape, and generated test seams. No files are published until
you type yes at the final confirmation prompt.
An invalid field answer does not publish anything. Interactive collection asks
again for the current question when the entered answer fails validation, keeping
earlier valid answers. Whole-form rules still run after collection: unsupported
input combinations — for example a path input with a file source, or an
input whose generated flag collides with an earlier input's — are reported
before publication and leave the destination untouched.
Work from an answer sheet
Long or repeated questionnaires are easier to complete in an editor than one prompt at a time. The wizard can render its complete questionnaire as a prose answer sheet and later generate the project from the completed file:
# Print the blank questionnaire; nothing is generated.
standout new-project questions
# Write the same deterministic sheet to a file.
standout new-project questions --file answers.txt
# Generate from the completed file.
standout new-project --answers answers.txt
# Generate from an answer sheet on stdin, with attended confirmation.
standout new-project --answers - < answers.txt
# Generate from stdin without prompting for confirmation.
standout new-project --answers - --yes < answers.txt
# Automate a named-file run the same way.
standout new-project --answers answers.txt --yes
Each question renders as one line — a cosmetic number, its wording, a type
hint, and a stable ID tag such as <id:project.name> at the end of the line.
Write the answer on the line (or lines) below the question; a text answer
such as the command description may span several lines, and everything up to
the next question line belongs to it. Static defaults are pre-filled as the
answer text — leave them untouched to accept them. Dynamic defaults render
blank because they depend on earlier answers, but leaving them blank still
resolves them the same way in prompts, files, and stdin: the executable name
defaults to the project name, bool inputs default to boolean cardinality,
string required/optional inputs default to argument,file,stdin sources, and
the other input shapes default to argument. The repeatable input section
renders one block; add another input by copying the complete block — its
heading line and its questions — below the last block and answering the copy.
Only the line-ending <id:...> tags carry meaning: rewording, renumbering, or
re-indenting a sheet does not change what it means, and a tag only counts when
it ends its line, so mentioning one mid-prose is harmless (the wizard prints a
warning when an answer contains <id:, in case a tag was mangled).
--answers replaces question collection entirely — it never merges file
answers with prompts — but everything after collection is the interactive
experience: the same validation, the same review, and the same yes
confirmation before anything is published. --answers - reads exactly one
complete sheet from piped standard input instead of a file; both sources
produce identical results for identical documents. A sheet that fails to
parse or validate reports every independent problem in one pass, each
identified by its stable ID (for repeated inputs, an indexed path such as
command.inputs[1].sources), and publishes nothing; the same no-partial-write
guarantee as the interactive wizard applies to every failure and rejection.
Submitting a sheet is not consent to generate. Piping a file — or reaching
its end — never confirms anything: without --yes, the wizard shows the
review and asks for confirmation on your terminal, independent of the answer
stream, and only an exact yes reply publishes the project. If confirmation
is required but no attended terminal is available (a CI job, a redirected
shell), the run fails before publishing anything and says so. Automation
opts out of the prompt explicitly with --yes, which skips only the
confirmation gate — parsing, validation, the review output, and atomic
publication all still run.
The sheet's #! preamble pins the answer format, the questionnaire ID, and a
fingerprint of the questionnaire's semantics. A sheet rendered by an older
standout whose questionnaire has since changed is rejected with a
compatibility error rather than reinterpreted; render a fresh sheet with
standout new-project questions and copy your answers into it. Answer sheets
are plain text and hold whatever you answered — including any sensitive
values — so keep them out of version control, shared locations, and shell
history (piping with < answers.txt beats inlining a heredoc), and delete
them when done.
Supported inputs
The first release deliberately supports a small, explicit matrix:
| Value type | Cardinality | Sources |
|---|---|---|
string | required or optional | Any ordered combination of argument, file, and stdin |
string | repeated | argument only |
bool | boolean | argument only |
path | required, optional, or repeated | argument only |
For a string with multiple sources, the order entered is the precedence order.
For example, argument,file,stdin tries --document, then
--document-file PATH, then piped standard input. A file source means the
file's contents become the string value. Path inputs instead pass a
PathBuf; they do not read the file.
Boolean inputs are generated as --name flags. Repeated string and path
inputs repeat the same named option:
myapp process --tag first --tag second
What the wizard generates
For a project named myapp, the workspace has this shape:
myapp/
├── Cargo.toml
└── crates/
├── myapplib/
│ ├── Cargo.toml
│ └── src/lib.rs
└── myapp/
├── Cargo.toml
├── README.md
└── src/
├── main.rs
├── cli.rs
├── handlers.rs
├── templates/<command>.jinja
└── styles/myapp.css
The library includes a typed operation, result, validation error, and unit
tests without Clap or Standout dependencies. The binary includes the command
declaration, a thin typed handler, a serializable view, human-output assets,
and tests for handler mapping and the full argv-to-output pipeline through
TestHarness. The generated manifest uses the installed wizard's Standout
version as a normal compatible Cargo requirement.
The generated binary supports the chosen command in human and structured output modes:
cd myapp
cargo run -p myapp -- process --document "hello"
cargo run -p myapp -- process --document "hello" --output json
Its generated README records the exact syntax and input-source policy selected in the wizard.
Verify and continue
The generated project is ready for the standard Rust checks:
cargo fmt --check
cargo check --workspace
cargo test --workspace
Keep application behavior in the CLI-free library as the project grows. Keep shell inputs, environment lookup, Standout wiring, view models, and presentation assets in the binary crate. The production-shaped application explains that ownership boundary in depth.
Minimal single-crate example
This self-contained project is a compact way to see Standout's dispatch and rendering pipeline. It keeps everything in one binary package for brevity; it is not the recommended layout for an application with reusable logic.
For production-shaped ownership, continue to the two-package worked application, where a CLI-free library owns application behavior and a binary owns all CLI concerns.
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 = "9"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"
src/main.rs
use clap::{CommandFactory, Parser, Subcommand}; use serde::Serialize; use standout::cli::{App, CommandContext, Dispatch, Output}; use standout::{embed_styles, embed_templates, handler}; #[derive(Parser)] #[command(name = "my-todo")] struct Cli { #[command(subcommand)] command: Option<Commands>, } #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] enum Commands { /// List all todos. Running the binary with no command lists too. #[dispatch(pure, default)] List, } #[derive(Serialize)] struct TodoResult { todos: Vec<TodoView>, } #[derive(Serialize)] struct TodoView { title: String, status: String, } mod handlers { use super::*; // This is a CLI adapter returning view data. In a real application it // should call a CLI-free library rather than contain application behavior. #[handler] pub fn list( #[ctx] _ctx: &CommandContext, ) -> Result<Output<TodoResult>, anyhow::Error> { Ok(Output::Render(TodoResult { todos: vec![ TodoView { title: "Write documentation".into(), status: "done".into() }, TodoView { title: "Ship v1.0".into(), status: "pending".into() }, ], })) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let app = App::builder() .version(env!("CARGO_PKG_VERSION")) .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 { color: gray; text-decoration: line-through; }
.pending { color: white; font-weight: bold; }
@media (prefers-color-scheme: light) {
.pending { color: black; }
}
Run it
cargo run
cargo run -- list
cargo run -- list --output json
cargo run -- list --output text
This demonstrates command dispatch, template rendering, structured output, hot
reload in debug builds, adaptive styles, and standout's themed --help, which
is on unless an application calls .help_handling(false). It intentionally
does not teach package ownership or the testing pyramid; the
production-shaped example does.
Production-shaped application
When an application has behavior worth reusing or testing independently, keep that behavior in a fully CLI-free library and put every shell concern in the binary package.
The repository's canonical tdoo worked example
is executable documentation for that layout:
todo-core (library) tdoo (binary)
------------------------------ ---------------------------------
model and invariants Clap commands and flags
validation and filtering Standout App construction
state transitions handlers as adapters
JSON persistence CLI view models
explicit storage path env lookup, templates, styles
fast core tests hooks and TestHarness tests
The dependency arrow goes one way:
tdoo ---> todo-core
todo-core has no dependency on Clap, Standout, command contexts, environment
variables, view DTOs, templates, styles, or terminal output. Its interface is
the surface used by both the CLI and core tests.
The CLI translates at the seam. A handler maps --all to a core TodoFilter,
calls the store, then maps domain values to CLI-owned TodoView values. That
keeps structured output stable even when the library's persisted model changes.
Read the canonical source rather than copying a second implementation here:
todo-core/src/lib.rs— the small public library interface;todo-core/src/store.rs— behavior, persistence, and fast tests;tdoo/src/handlers.rs— thin adapters and direct typed-handler tests;tdoo/src/app.rs— app assembly, shell pipeline features, and focusedTestHarnesstests.
For a five-minute look at dispatch and rendering without the package split, use the minimal single-crate example.
For a broader review of ownership, rendering, testing, and optional framework capabilities, use the Leveraging Standout implementation quality checklist.
Derived Questionnaires
Derived questionnaires let an application describe a form once as Rust types and let Standout provide the answer-sheet command surface around it.
Use this when a command needs a multi-question setup flow, an editable answer
sheet, or repeatable automation through --answers FILE and --answers -.
For lower-level control over the same runtime model, use the hand-built
standout_input::questionnaire::Questionnaire builder API directly.
Define the Answer Type
Derive Questionnaire on named-field structs. The container
#[question(id = "...")] is the stable questionnaire identity written into
every answer sheet. Field doc comments become prompts; field names become
stable IDs unless #[question(id = "...")] overrides them.
#![allow(unused)] fn main() { use std::path::PathBuf; #[derive(Debug, Clone, PartialEq, Eq, standout::Questionnaire)] #[question(id = "demo.import")] struct ImportAnswers { /// What is the project name? #[question(validate = validate_name, revision = "project-name.v1")] project_name: String, /// Where is the manifest? manifest: PathBuf, /// Add release notes. #[question(prose)] notes: String, /// Which output format should be generated? #[question(choice, default = "json")] format: OutputFormat, } }
The derive lowers to standout-input's public builder and implements typed
filling from decoded answers. It does not use serde, so serde field renames on
the same struct cannot change questionnaire IDs.
Choice Enums
Derive QuestionnaireChoices on unit-variant enums used by
#[question(choice)] fields.
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, standout::QuestionnaireChoices)] enum OutputFormat { #[question(rename = "json")] Json, #[question(rename = "yaml")] Yaml, #[question(rename = "plain-text")] PlainText, } }
Every variant declares its user-facing spelling with
#[question(rename = "...")]; a variant without one is a compile error, so
the accepted answer strings are always explicit in the source. The enum is the
single source for the rendered hint, allowed choices, FromStr, and Display.
Field Shapes
Supported scalar field types are String, PathBuf, and bool.
Option<T> makes a scalar or choice field optional. A nested questionnaire
struct lowers to a group, and Vec<NestedStruct> lowers to a repeatable group.
Vec<T> over a scalar element type (for example Vec<String>) is a compile
error: collect a list as a String field and split it in application code, or
model the items as a Vec of a nested questionnaire struct.
Useful field attributes:
| Attribute | Meaning |
|---|---|
id = "..." | Override the stable field or group ID. |
default = "..." | Static default answer text. |
default_with = path, revision = "..." | Dynamic default from earlier answers. |
validate = path, revision = "..." | Field validator hook. |
active_when(field = "...", is = "...") | Conditional Option<T> field; the controller names a field of the same struct. |
choice | Treat an enum as a choice field, not a nested struct. |
prose | Treat a String as multiline text. |
min = N, max = M | Bounds for repeatable groups. |
Static and dynamic defaults are mutually exclusive. default_with and
validate both require a non-empty revision; if both hooks are on the same
field, the one revision identifies both hook contracts for the fingerprint.
Bump it whenever either hook changes which answers are accepted or supplied.
Hook signatures are plain function paths:
#![allow(unused)] fn main() { use standout::input::questionnaire::{AnswerValue, EarlierAnswers}; fn default_name(answers: &EarlierAnswers<'_>) -> String { answers.get_text("project_name").unwrap_or("demo").to_string() } fn validate_name(value: &AnswerValue) -> Result<(), String> { let text = value.as_text().unwrap_or_default(); text.chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') .then_some(()) .ok_or_else(|| "name may only contain letters, numbers, hyphens, or underscores".into()) } }
active_when is intentionally bounded. It only applies to Option<T> fields,
and its controller must be an earlier scalar or choice field of the same
derived struct, named by its Rust field name; a name that does not resolve
within the struct is a compile error. The derive resolves the name through any
explicit id remapping, so renaming a group with #[question(id = "project")]
also remaps children to paths such as project.name.
Wire a Command
With #[derive(Dispatch)], attach the questionnaire to the command variant:
#![allow(unused)] fn main() { #[derive(clap::Subcommand, standout::cli::Dispatch)] #[dispatch(handlers = handlers)] enum Commands { #[dispatch(questionnaire = ImportAnswers, template_name = "import")] Import, } }
The framework injects the reserved command surface:
myapp import questions
myapp import questions --file answers.txt
myapp import --answers answers.txt
myapp import --answers - --yes
questions renders the blank answer sheet and has no side effects.
--answers FILE reads one completed sheet from a named file. --answers -
reads one completed sheet from piped stdin. With no --answers, the framework
collects the same questionnaire interactively.
Sources never merge. A file or stdin submission replaces interactive question collection; after collection, every path goes through the same decoding, dynamic defaults, validators, and whole-form rules.
The same configuration is available through CommandConfig:
#![allow(unused)] fn main() { use standout::cli::{App, FnHandler}; let app = App::builder() .command_with("import", FnHandler::new(handlers::import), |cfg| { cfg.template_name("import") .questionnaire_with_form_and_review::<ImportAnswers, _, _>( validate_form, write_review, ) })? .build()?; }
Use questionnaire::<T>() when field-level validation is enough.
Use questionnaire_with_form::<T, _>(form) for cross-field rules that return
Vec<FormError>. Use questionnaire_with_form_and_review::<T, _, _>(form, review) when the user must see an application review before the confirmation
gate.
Hook Order Around Questionnaire Resolution
questionnaire::<T>() and its two siblings register an ordinary pre-dispatch
hook. Pre-dispatch hooks run in the order they were registered on the
CommandConfig, so where you write the questionnaire call decides whether
your own hook sees the resolved answers:
#![allow(unused)] fn main() { cfg.pre_dispatch(require_answer_source) // runs first: no answers yet .questionnaire::<ImportAnswers>() // resolves, validates, confirms .pre_dispatch(record_submission) // runs last: ctx.questionnaire() works }
A hook registered before the questionnaire call runs before resolution and
cannot read ctx.questionnaire(); one registered after runs only if
resolution, whole-form rules and the confirmation gate all succeeded. Every
pre-dispatch hook receives the command's own ArgMatches — the deepest
subcommand's, the same the handler gets — so a hook can read the injected
--answers and --yes arguments directly.
Registering the same phase through both CommandConfig and
AppBuilder::hooks(path, …) is a configuration error naming the path and
phase, so one command's pre-dispatch order is always readable in one place.
Read an Application's Own Sheet Format
--answers reads the preamble/fingerprint sheet questions renders. An
application whose own spec pins the shape of that file supplies an
AnswerSheetFormat instead:
#![allow(unused)] fn main() { use standout::input::questionnaire::{ AnswerSheetDiagnostic, AnswerSheetFormat, Questionnaire, RawAnswers, }; struct SpecSheet; impl AnswerSheetFormat for SpecSheet { fn parse( &self, questionnaire: &Questionnaire, text: &str, ) -> Result<RawAnswers, Vec<AnswerSheetDiagnostic>> { questionnaire.parse_answer_sheet_body(text) } } }
Wire it with CommandConfig::answer_sheet_format:
#![allow(unused)] fn main() { cfg.questionnaire::<ImportAnswers>() .answer_sheet_format(SpecSheet) }
parse_answer_sheet_body reads the tagged body of a sheet without requiring
the preamble, which is the shortest way to accept a sheet the application
renders itself. A format that shares nothing with the rendered sheet fills a
RawAnswers directly (set, set_occurrence_count) and returns its own
diagnostics. Parsing is all the format owns: decoding, defaults, validators,
whole-form rules, review and confirmation run the same way afterwards.
Read Answers in the Handler
Bring CommandContextInput into scope and read the typed questionnaire value:
#![allow(unused)] fn main() { use standout::cli::{CommandContext, CommandContextInput, HandlerResult, Output}; fn import(_matches: &clap::ArgMatches, ctx: &CommandContext) -> HandlerResult<serde_json::Value> { let answers: &ImportAnswers = ctx.questionnaire()?; Ok(Output::Render(serde_json::json!({ "project": answers.project_name.as_str(), }))) } }
The handler runs only after questionnaire resolution, field decoding, whole-form rules, optional review, and confirmation have succeeded. Keep side effects in the handler or later so a rejected confirmation writes nothing.
Confirmation and Warnings
Questionnaire commands get --yes from the framework. Without it, Standout asks
for an exact yes on an attended controlling terminal after any configured
review. Piped stdin never confirms a run, EOF never confirms a run, and a missing
attended terminal is an error.
CommandConfig::confirmation makes the gate's three decisions the
application's:
#![allow(unused)] fn main() { use standout::cli::{Confirmation, ConfirmationAcceptance, ReviewStream}; cfg.questionnaire::<ImportAnswers>().confirmation( Confirmation::default() .prompt("Ship it? [y/N] ") .acceptance(ConfirmationAcceptance::YesOrY) .review_stream(ReviewStream::Stdout), ) }
ConfirmationAcceptance::Word(word) takes that word alone and is the default
with yes; the reply and the word are both trimmed before they are compared, so
an empty or all-whitespace word accepts nothing and pressing Enter cannot
confirm. YesOrY takes y or yes in any case; Disabled runs without
asking, as --yes does. The prompt goes to the controlling terminal, and the
review a command writes goes to stderr unless review_stream says otherwise —
stdout is the data channel.
A hook or handler that reads ArgMatches for itself names the injected
arguments by their ids, standout::cli::QUESTIONNAIRE_ANSWERS_ARG and
QUESTIONNAIRE_YES_ARG.
Accepted answer sheets can still produce warnings, for example when answer text
contains a suspected <id: fragment. Standout queues these as framework
warnings: App::run renders them after the primary output, and
standout-test::TestHarness exposes them through TestResult::warnings().
Builder Alternative
The derive is sugar over the same public runtime model described in
Questionnaire Answer Sheets. Use the
builder API when the definition is not known at compile time or when a
standalone library wants to render and decode answer sheets without depending
on standout-macros or standout command wiring.
Leveraging Standout: Value and Implementation Quality Checklist
Standout is most valuable when it creates a testable seam between application behavior and shell presentation. Use this guide to review an implementation, decide which framework capabilities add value, and name missing framework support without pushing presentation concerns back into handlers.
Evaluation classes
Classify every finding before deciding whether it needs work:
- Invariant — a property the application should preserve wherever Standout owns a command. Violating it weakens the architecture or public behavior.
- Applicable capability — a Standout feature that is valuable only when the application's needs call for it. Not using it is not automatically a defect.
- Framework gap — a desirable behavior that Standout does not currently integrate. Record the gap instead of hiding a custom workaround in a handler.
The checklist uses I, A, and G for those classes.
Ownership and dispatch
- I — Keep reusable behavior CLI-free. Domain rules, state transitions, validation, persistence, and reusable services should expose ordinary Rust interfaces. A separate core crate is the recommended production shape, not a requirement enforced by Standout. Small applications may keep the same seam inside one crate.
- I — Let the binary own the shell. Clap types, Standout app construction, handlers, CLI view DTOs, templates, styles, environment lookup, and final output belong in the CLI package. Handlers are adapters: translate parsed arguments, call the core, and return serializable data.
- I — Integrate with Clap. Standout does not replace Clap. Clap remains the command and argument parser; Standout adds dispatch, rendering, output modes, hooks, and test seams around the resulting command model.
- A — Prefer declarative dispatch when conventions fit.
#[derive(Dispatch)]maps Clap variants to handlers and can attach templates, hooks, nested dispatch, and pipes. Use explicitcommand_withregistration when a command needs configuration that is clearer in builder code.
The production-shaped todo-core + tdoo example
shows this ownership boundary in executable code. The
dispatch guide covers the
pipeline in detail.
Output and rendering
- I — Return data, not presentation. A normal handler returns
Output::Render(data),Output::Silent, orOutput::Binary { ... }. It should not print, emit ANSI, render a template, or branch on output mode. - I — Let the framework own the write for reported artifacts. When a command
produces a file and a report, return
Output::Artifact(...)with owned bytes, an opt-in suggested destination, and the report. Standout selects the destination, writes, and renders the report afterwards with a receipt — the application never opens the file or words a success it hasn't earned. - I — Keep one data contract across modes.
auto,term, andtextrender the MiniJinja template and transform semantic style tags.term-debugkeeps the tags visible. Structuredjson,yaml,xml, andcsvmodes serialize handler data directly and bypass templates, including template-injected context. - A — Use MiniJinja and semantic CSS. Put layout in file-backed MiniJinja
templates and appearance in CSS classes such as
.titleor.warning. Define adaptive overrides with@media (prefers-color-scheme: light)and@media (prefers-color-scheme: dark); Standout resolves the matching style variant for the terminal background. - A — Embed resources and keep the debug loop short.
embed_templates!andembed_styles!provide release-safe assets. Hot reload specifically means that debug file-backed resources are re-read on render, so source edits can be observed without rebuilding while the original paths remain available. - A — Use tabular layout for column relationships. Reach for the
colfilter and tabular specifications when alignment, width allocation, truncation, or nested columns matter; do not hand-pad strings in handlers. - I — Use the right render diagnostic.
--output term-debugshows where style tags were placed, but it preserves known and unknown tags alike and does not validate that the tags exist. Usevalidate_templatein development or tests to detect missing style definitions. Terminal mode's?marker is a visible warning, not a substitute for validation. - G — Do not imply integrated per-command CSV projection. Normal app
dispatch automatically flattens serializable data for CSV. The direct
render_auto_with_specAPI can render CSV with aFlatDataSpec, but arbitrary per-command projection through normalAppdispatch is not currently integrated. Prefer a stable CLI-owned view DTO, or record the missing app configuration seam.
See Output Modes, Templating, Styling System, File System Resources, and Introduction to Tabular for the focused APIs and trade-offs.
Testing pyramid
- I — Core tests: exercise validation, filtering, state transitions, and persistence through the CLI-free Rust interface.
- I — Adapter tests: call the typed handler function directly and assert the CLI-to-core mapping plus returned view DTO.
- I — Pipeline tests: use
standout-test::TestHarnessfor Clap parsing, dispatch, hooks, inputs, templates, structured modes, and controlled process seams. Mark harness tests serial because those seams are process-global. - A — Process tests: reserve a small end-to-end layer for real PTYs, signals, subprocess behavior, and build or link boundaries the harness cannot model.
This ordering keeps most failures close to their owner while still proving the user-visible shell contract. See Testing for the full boundary matrix.
Optional application capabilities
These are applicable capabilities, not baseline requirements:
- Hooks place cross-command validation, request-context injection, serialized-data transformation, or post-output observation at an explicit pipeline phase.
- Declarative input chains resolve values from arguments, environment, stdin, clipboard, defaults, editors, or prompts with one validation path.
- Pipes send or transform rendered text after output; binary and silent results pass through unchanged.
- Partial adoption lets an existing Clap application delegate selected commands to Standout and retain its legacy path for unmatched commands.
Adopt each capability only when it removes application-owned orchestration or improves a test seam. If the framework cannot express the required behavior, classify it as a framework gap and keep the workaround isolated in the CLI layer.
Review outcome
A high-quality implementation has no unresolved invariant violations, uses the applicable capabilities that materially simplify its shell boundary, and names framework gaps explicitly. That is a stronger signal than counting how many Standout features an application uses.
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 CLI-free library function that owns the application behavior, plus a handler
that adapts parsed CLI input to that library and returns serializable view data.
The library must not depend on Clap, Standout, CommandContext, templates,
styles, environment lookup, or app construction.
For this guide's purpose we'll use a fictitious "list" command of our todo list manager
The core and its handler adapter
The library owns filtering, validation, and state transitions. The handler is a CLI adapter: it receives parsed arguments, calls the library, and returns a serializable CLI view:
#![allow(unused)] fn main() { #[handler] pub fn list( #[flag] all: bool, #[ctx] ctx: &CommandContext, ) -> Result<Output<TodoResult>, anyhow::Error> { let store = ctx.app_state.get_required::<TodoStore>()?; let filter = if all { TodoFilter::All } else { TodoFilter::Pending }; Ok(Output::Render(TodoResult::from(store.list(filter)))) } }
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. The variant declares every
argument its handler asks for — list takes #[flag] all, so List carries
an all field for it to read:
#![allow(unused)] fn main() { #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { // ... #[dispatch(pure)] List { #[arg(long)] all: bool, }, } }
And finally, run in main, the autodispatcher:
#![allow(unused)] fn main() { if !app.run(Cli::command(), std::env::args()) { // If some commands still use manual dispatch, fall back here. legacy_dispatch(); } }
When the fallback needs the unmatched ArgMatches, call run_with(cmd, args, target, sources) instead of run, and match DispatchResult::NoMatch(matches)
on the result's into_outcome().
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.
This guide keeps the migration in one package so each step is small. Once the application behavior is separated from output, move it into a CLI-free library; the canonical production-shaped application shows the final ownership model.
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 application function. It knows nothing about clap and returns // an ordinary Rust data type. // // Note: This example uses immutable references. If your application service // needs mutable state (&mut self), see the "Mutable Handlers" section below. pub fn list(show_done: bool) -> TodoResult { 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.get_flag("all"))) } }
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 passing domain inputs directly to the application function.
- 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
├── core.rs # list(), add() - CLI-free application behavior
└── render.rs # render_list(), render_add() - output formatting
3. Fine Tune the Application Result 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"
A note on booleans and none: a bare
minijinja::Environmentrenders these the Jinja2 way —True,False,None— from minijinja 2.22 onward. Standout renderstrue,false, andnone, and normalizes for you from step 7 on, once rendering goes throughApp. Until then the spelling is minijinja's. If you want standout's spelling now, addstandout-render = "9"next to minijinja and build the environment withstandout_render::template::new_environment()instead ofminijinja::Environment::new()in the snippets below — the snippets keep the bare constructor, sincestandoutonly arrives in step 7.
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 = "9"
anyhow = "1"
A single standout dependency is enough: standout re-exports standout-dispatch
as standout::dispatch, and code generated by #[handler], Questionnaire, and
QuestionnaireChoices resolves against whichever of the two crates your
Cargo.toml declares.
Verify: Run
cargo build- dependencies should download and compile.
7.2 Create Handlers with the #[handler] Macro
The #[handler] macro transforms typed Rust functions into Standout-compatible
handlers. Keep application behavior in the CLI-free core; the handler annotates
CLI parameters, calls the core, and maps its result into serializable view data.
See Handler Contract for full handler API details.
#![allow(unused)] fn main() { use standout::cli::Output; use standout::handler; mod handlers { use super::*; // Thin CLI adapter - easy to test, no extraction boilerplate #[handler] pub fn list(#[flag] all: bool) -> Result<Output<TodoResult>, anyhow::Error> { let filter = if all { TodoFilter::All } else { TodoFilter::Pending }; let todos = core::list(filter)?; Ok(Output::Render(TodoResult { message: None, todos })) } #[handler] pub fn add(#[arg] title: String) -> Result<Output<TodoResult>, anyhow::Error> { let todo = core::add(title)?; Ok(Output::Render(TodoResult { message: Some(format!("Added: {}", todo.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 { #[dispatch(pure)] List { #[arg(short, long)] all: bool, }, #[dispatch(pure)] Add { title: String, }, } }
The derive macro matches each variant to a handler function by its kebab-case
command name (List registers as list). With #[dispatch(pure)], the
variant resolves to the wrapper #[handler] generated for the function —
List → handlers::list__handler. Without pure, the variant calls
handlers::list directly, and that function must already have the
fn(&ArgMatches, &CommandContext) -> HandlerResult<T> dispatch signature.
The Dispatch derive connects a variant to a handler; it does not declare the
variant's arguments. Those stay clap's, so each variant keeps the fields its
handler's #[flag] and #[arg] parameters read — all for list, title
for add — exactly as in the plain-clap version at the top of this guide.
app.verify_command(&cmd) reports a handler asking for an argument the
variant does not declare, which otherwise surfaces as a get_flag panic at
run time.
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<Output<TodoResult>, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(Output::Render(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<Output<TodoResult>, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(Output::Render(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 !app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy. legacy_dispatch(); } }
If the fallback needs the unmatched ArgMatches, call run_with with a
detected target and input sources, then match DispatchResult::NoMatch(matches)
on into_outcome():
#![allow(unused)] fn main() { let target = standout::TargetProperties::detect(); let sources = standout::InputSources::from_process(); let result = app.run_with(Cli::command(), std::env::args(), target, sources); match result.into_outcome() { standout::cli::DispatchResult::NoMatch(matches) => 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). A single derive attribute links each CLI handler
adapter to a command name, a few lines configure Standout, and auto dispatch
handles the shell boilerplate.
For the next commands you migrate, add a thin adapter plus its template. By default the macro matches the command's name to handlers and template files, but you can map either explicitly.
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] - CLI adapters returning view data
└── 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
- Directly testable handler adapters
Your final files:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() with #[handler] - CLI adapters
├── 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_name = "custom")] - 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. #[handler] generates a unit struct named after the
function with a capitalized suffix (list → list_Handler) that implements
Handler directly; register that struct — the plain handlers::list function
is not itself registrable — with command_with:
#![allow(unused)] fn main() { let app = App::builder() .command_with("list", handlers::list_Handler, |cfg| { cfg.template_name("list") .pipe_through("jq '.todos'") // Filter JSON output })? .command_with("export", handlers::export_Handler, |cfg| { cfg.template_name("export") .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, FnHandler, Output}; use standout::input::{ArgSource, EditorSource, EnvSource, InputChain, StdinSource}; App::builder() .command_with("create", FnHandler::new(create), |cfg| { cfg.template_name("create") .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
A closure passed to command_with runs as FnMut, so it can capture and
mutate state directly — no Arc<Mutex<_>> wrappers needed. This is common
with database connections, file caches, or in-memory indices. Wrap the
closure in FnHandler::new so it implements Handler:
use std::collections::HashMap; use clap::ArgMatches; use standout::cli::{App, CommandContext, FnHandler, Output}; use standout::embed_templates; use uuid::Uuid; struct PadStore { index: HashMap<Uuid, Metadata>, } impl PadStore { fn complete(&mut self, id: Uuid) -> anyhow::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_with( "complete", FnHandler::new(|m: &ArgMatches, _ctx: &CommandContext| { let id = m.get_one::<Uuid>("id").unwrap(); store.complete(*id)?; // &mut store works! Ok(Output::Silent) }), |cfg| cfg.silent(), )? .command_with( "list", FnHandler::new(|_m: &ArgMatches, _ctx: &CommandContext| { Ok(Output::Render(store.list())) }), |cfg| cfg, )? .build()? .run(Cli::command(), std::env::args()); Ok(()) }
Handler capabilities:
command_withaccepts anything implementingHandler;FnHandler::newturns a plainFnMutclosure into one- 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 is the architecture around the framework: a CLI-free library owns behavior, and Standout handlers adapt shell input and library results.
The library exposes ordinary Rust behavior:
#![allow(unused)] fn main() { pub fn list(&self, filter: TodoFilter) -> Vec<Todo> { // filtering and persistence details stay behind this interface } }
The handler is a typed adapter returning CLI view data:
#![allow(unused)] fn main() { #[handler] pub fn list( #[flag] all: bool, #[ctx] ctx: &CommandContext, ) -> Result<Output<TodoListView>, anyhow::Error> { let store = ctx.app_state.get_required::<TodoStore>()?; let filter = if all { TodoFilter::All } else { TodoFilter::Pending }; let todos = store.list(filter).into_iter().map(TodoView::from).collect(); let total = todos.len(); Ok(Output::Render(TodoListView { todos, total })) } }
Test behavior through the library interface, then test only adapter mapping in the handler:
#![allow(unused)] fn main() { #[test] fn list_filters_completed_by_default() { let store = fixture_store(); let todos = store.list(TodoFilter::Pending); assert!(todos.iter().all(|todo| !todo.done)); } #[test] fn list_handler_maps_pending_view() { let ctx = context_with_fixture_store(); let Output::Render(result) = list(false, &ctx).unwrap() else { panic!("expected Render"); }; assert!(result.todos.iter().all(|todo| !todo.done)); } }
No stdout capture. No regex. No subprocess. Just a function call and a struct assertion. The test reads like the behavior it describes.
This keeps the majority of real logic—filtering, aggregation, validation, and business rules—independent of the shell. Standout keeps the adapter data-first so the CLI seam is directly testable too.
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:
- Core behavior is tested through a CLI-free library interface.
- Typed handlers are directly testable adapters returning
Output<T>. - 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 = "9"
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). Destination facts (width, color, color-scheme, icon mode) are injected onTargetPropertiesand do not need#[serial]for detector reasons. All in-processruntests still need#[serial]while those env/cwd overrides exist:serial_testonly orders annotated tests against each other, so an unannotatedruncan race with one that mutates env or cwd. 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.
For width-sensitive fixtures, run the same app with
.ambiguous_width(AmbiguousWidth::Narrow) and
.ambiguous_width(AmbiguousWidth::Wide). Narrow is the compatibility default;
the harness override makes either policy deterministic without locale guessing.
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_from(ctx.input_sources()), InquireSelect::new(...).prompt_from(ctx.input_sources()), etc. Without a seam those calls need a real TTY and become level-3 territory. With .prompts(...), the harness places a responder on the run's InputSources so a wizard handler that reads ctx.input_sources() is fully testable in process:
#![allow(unused)] fn main() { use standout_input::{PromptResponse, ScriptedResponder}; use std::sync::Arc; #[test] 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
Two orthogonal knobs, injected on TargetProperties (never detected from the process):
#![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 }
Useful for snapshot testing: pin the width, turn off color, and the rendered string is deterministic across developer machines and CI.
with_color() is also what makes an ANSI-positive assertion possible in-process. Two switches stand between a styled template and escape bytes: Standout's own color decision, and console's process-global color switch, which Style::apply_to consults and which is off in a non-TTY process — and a test binary is never a TTY. with_color() sets both (and restores the second on drop), so a Term render in a test emits the escapes a terminal user would see, with no force_styling needed in the theme:
#![allow(unused)] fn main() { let result = TestHarness::new() .with_color() .output_mode(OutputMode::Term) .run(&app(), command(), ["myapp", "list"]); assert!(result.stdout().contains('\x1b')); // really styled assert_eq!(result.stdout_plain(), expected); // and strippable }
There is no TTY knob. The harness once offered .is_tty() / .no_tty(), driving a detector no production code ever read; both are gone, along with standout_render::detect_is_tty. Questions that genuinely depend on being (or not being) a terminal belong to a real process — see run_process — and a future terminal-citizenship seam will be stream-aware rather than a single stdout-wide global. The reasoning is recorded in docs/adr/0022-delete-the-in-process-tty-seam.md.
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") }
4.8 Invariant assertions
assert_stdout_contains("default: auto") is an existential claim: this string
is somewhere on the page. Most rendering defects are not that shape. They are
universals ("every value-taking option shows a metavar") and negatives ("no
presence flag lists possible values") — and a list of strings that should be
present says nothing about a wrong line rendered beside them.
standout_test::invariants holds those as reusable assertions, each naming the
offending element when it fails:
#![allow(unused)] fn main() { use standout_test::invariants::*; let page = TestHarness::new().text_output().run(&app, cmd(), ["notes", "--help"]); assert_every_tag_resolved(&page); // the theme defines every tag rendered assert_no_unresolved_tag_markers(&page); // no `[tag?]` reached the page assert_metavar_for_valued_args(&page, &cmd()); // clap's metadata is the oracle assert_no_possible_values_for_valueless_args(&page, &cmd()); assert_descriptions_aligned(&page); // every section's column, not two rows by hand }
assert_every_tag_resolved reads structured data — TestResult::tag_resolutions(),
what each style-tag pass could not resolve — so it holds in every output mode
and names the tag. The [tag?] marker only appears in Term, so the two
assertions catch the same defect from different directions and both are worth
running. Each text assertion also has an *_in_page form taking the rendered
page directly (assert_styling_preserves_layout_in_pages takes the two it
compares), for asserting on a page the harness did not produce.
assert_every_tag_resolved is the exception: what it reads is
TestResult::tag_resolutions(), which no page carries — that is exactly why it
can name a tag in a mode where the page shows nothing.
4.9 Clap-parity: the differential oracle
The invariants above state properties of the rows a page does render. They say
nothing about a row that was never rendered at all — and a field the help data
extractor forgot to copy has no row, no wrong line, and nothing for an
existential assertion to trip over. That is the shape of every defect in the
themed-help cluster: long_about, defaults, possible values and metavars were
all things clap knew and the page did not say.
standout_test::clap_parity closes that hole by asserting against an oracle
outside standout — clap's own metadata:
#![allow(unused)] fn main() { use standout::cli::HelpLength; use standout_test::clap_parity::assert_states_clap_facts; let page = TestHarness::new().text_output().run(&app, cmd(), ["notes", "--help"]); // `--help` and the `help` word owe `long_about`; `-h` owes `about`. assert_states_clap_facts(&page, &cmd(), HelpLength::Long); }
It walks the command — subcommands, arguments, value names, help texts,
defaults and possible values with clap's own suppression rules, hidden metadata
respected — and requires each fact to appear in the row that owns it, naming
the argument and the value when one is missing. It asserts presence of
facts, never layout: themed help is meant to look different from clap's, so
default: brief and [default: brief] satisfy it equally.
Facts standout deliberately does not render live in one allowlist,
clap_parity::DELIBERATE_OMISSIONS, each with its reason — an unexplained
exemption is indistinguishable from a forgotten field, which is the failure
mode the differential exists to end. Pass your own list to
assert_page_states_clap_facts_with when a page is deliberately narrower;
&[] asserts full parity.
4.10 Running the real binary
run() calls into your app inside the test process, so the two text streams it reports are a faithful reconstruction of what App::run's writer seam would have emitted — not a recording of what the OS carried. For the handful of facts only the real boundary settles, run_process() runs the compiled binary instead and returns what the kernel saw:
#![allow(unused)] fn main() { #[test] // no #[serial]: nothing process-global is touched fn a_usage_error_goes_to_stderr_and_leaves_stdout_clean() { let result = TestHarness::new() .fixture("todos.json", STORE) .env("TODO_FILE", "todos.json") .run_process(env!("CARGO_BIN_EXE_mycli"), ["bogus-command"]); result.assert_exit_code(2); result.assert_stdout_empty(); // real pipe, not a model of one result.assert_stderr_contains("unexpected argument"); } }
ProcessResult carries stdout() / stderr() (and stdout_bytes() / stderr_bytes() when the output isn't text), the ANSI-stripping stdout_plain() / stderr_plain(), status() / code() / success(), and the assertion helpers above. tempdir() returns the fixture tempdir — the child's working directory too, unless you passed an explicit cwd(), which wins — so a command's effect on disk is assertable.
The builder settings that describe a process carry over — env() / env_remove(), cwd(), fixture() (whose tempdir becomes the child's working directory), and output_mode(), which is the same argv edit run() makes. The settings that describe an in-process injection seam cannot: a child resolves width, color, stdin, clipboard, and prompts from its own environment, so declaring one and then calling run_process() panics rather than quietly asking the CI machine's terminal instead. Express those through something the child can see — an environment variable, a fixture file, argv.
It costs a compile and a fork per call. Use it for evidence, not for coverage.
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. Linkage, the real exit code, which stream a byte actually went to, behavior that keys off stdout not being a terminal — that's integration-of-the-build. run_process() covers it from the same builder; reach for assert_cmd only if you want its matcher vocabulary.
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]) // destination facts on TargetProperties (fixed defaults when unset: // width None, ColorMode::Dark, IconMode::Classic, AmbiguousWidth::Narrow) .terminal_width(80) .no_terminal_width() .with_color() // or .no_color(); fills per-stream capability // 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 in-process... .run(&app, cmd, ["binname", "subcommand", "--flag"]) // ...or as the real binary (ProcessResult; rejects in-process // destination facts, stdin, clipboard, and prompt settings a child can't inherit) .run_process(env!("CARGO_BIN_EXE_binname"), ["subcommand", "--flag"]) // TestResult: choose the assertion group that matches the observed outcome. // Success result.assert_success(); // Handled / Silent / Binary result.assert_exit_status(ExitStatus::SUCCESS); result.assert_stdout_contains("hi"); result.assert_stdout_eq("hi\n"); // No match result.assert_no_match(); // clap didn't match any subcommand assert_eq!(result.exit_status(), None); // fallback owns the eventual status // Error result.assert_error(); result.assert_exit_status(ExitStatus::FAILURE); result.assert_error_kind(RunErrorKind::Handler); // Accessors for any outcome result.stdout(); // &str result.outcome(); // &DispatchResult, for bespoke assertions result.binary(); // Option<(&[u8], &str)> for Binary result.exit_status(); // Option<ExitStatus>; None for NoMatch result.success_kind(); // command / Clap help / Clap version result.error_kind(); // typed failure origin result.tag_resolutions(); // what each style-tag pass resolved result.unresolved_tag_names(); // the tags the theme did not define }
The harness captures the pipeline before the real stdout/stderr write. Use it
for typed parser, handler, hook, render, pipe, and output-file outcomes. Keep a
small run_process() suite for OS exit codes, stream routing, and broken final
writers.
Appendix: common pitfalls
- Tests leak state into each other. Every in-process
runtest must be#[serial]while the harness still mutates env/cwd.serial_testonly orders annotated tests against each other, so an unannotatedruncan race with one that mutates those globals. Detector reasons no longer apply. 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").- Unset destination facts are fixed defaults, not detected.
$COLUMNS,$NERD_FONT, and the OS appearance setting cannot change an in-process run. Injectterminal_width/color_scheme/icon_modewhen a test needs non-default facts. - 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); }
render is the convenience wrapper. The contract is render_request: an owned RenderRequest carrying data, template, theme, format, color policy, and TargetProperties. Wrappers detect destination facts at their edge, build that request, and delegate. They keep their own names. Tests construct TargetProperties rather than installing detector overrides (those APIs are removed).
The same render as an explicit request — destination facts on TargetProperties, no process detection:
#![allow(unused)] fn main() { use std::collections::HashMap; use serde_json::json; use standout_render::{ AmbiguousWidth, ColorMode, ColorPolicy, IconMode, OutputMode, RenderRequest, TargetProperties, TemplateRef, Theme, default_template_engine, render_request, }; use console::Style; let theme = Theme::new().add("title", Style::new().cyan().bold()); let request = RenderRequest { data: json!({"name": "Tasks", "count": 42}), template: TemplateRef::Inline("[title]{{ name }}[/title]: {{ count }} items".into()), theme, format: OutputMode::Text, color_policy: ColorPolicy::Auto, target: TargetProperties { width: Some(80), stdout_is_terminal: true, stderr_is_terminal: true, stdout_color_capability: true, stderr_color_capability: true, color_scheme: ColorMode::Dark, icon_mode: IconMode::Classic, ambiguous_width: AmbiguousWidth::Narrow, }, engine: default_template_engine(), registry: None, context_registry: None, csv_projection: None, extras: HashMap::new(), warnings: None, }; let output = render_request(&request)?; }
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)
- The same request, with file-backed templates and context providers held fixed, produces the same bytes
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.
Ambiguous character widths
Unicode marks some characters, including ≈ and Δ, as East Asian
Ambiguous. Standout treats them as one column by default, preserving existing
layouts. Applications that target terminals where these glyphs occupy two
columns can select the policy explicitly:
#![allow(unused)] fn main() { use standout_render::{AmbiguousWidth, Renderer, Theme}; let renderer = Renderer::new(Theme::new())? .with_ambiguous_width(AmbiguousWidth::Wide); }
The selection flows through measurement, ANSI and style-tag-aware visible
width, padding, truncation, wrapping, tabular formatting, and MiniJinja width
filters. Standout makes no locale-based guess and promises no automatic
detection. The policy-aware helper variants, such as
standout_render::tabular::display_width_with_policy, are available when
formatting directly.
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 columns, and as wide as the widest cell when you pass rows= |
{"min": 10, "max": 30} | Between 10 and 30, alongside a "fill" column |
"fill" | Takes all remaining space |
"2fr" | 2 parts of remaining (proportional) |
A {"min": ..., "max": ...} column is resolved once, when tabular() or
table() builds the formatter. The formatter then formats one row at a time,
so it can only measure content it was handed up front: pass the rows you are
about to render as rows=, and every bounded column is sized to its widest
cell (still clamped by max) before the first row is formatted.
rows= takes an array of row arrays — the same cells .row(...) takes — so
hand it the list you are about to loop over:
{% set t = tabular([
{"width": {"min": 0}},
{"width": {"min": 0}},
{"width": "fill"}
], separator=" ", rows=rows) %}
{% for row in rows %}
{{ t.row(row) }}
{% endfor %}
with rows a list of [index, status, title] arrays from the handler's data.
table() takes the same rows=, and measures its header= row alongside the
data so a header wider than its column is not truncated. A row — or a header=
— shorter than the column list measures the columns it leaves out at their
null_repr, the text the formatter renders there. A column carrying
sub_columns is not measured from rows=, because its sub-columns are
resolved per row against the parent's width (see Step 7).
Without rows= a bounded column has nothing to grow it: it lands on its min,
and with no "fill" (or fractional) column in the table any leftover terminal
width is added to the rightmost bounded column instead, regardless of its
max.
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"), ]); }
TabularFormatter::new uses narrow ambiguous-character widths for backward
compatibility. To match terminals where East Asian Ambiguous glyphs occupy two
columns, construct the formatter explicitly:
#![allow(unused)] fn main() { use standout_render::AmbiguousWidth; let formatter = TabularFormatter::with_ambiguous_width( &spec, 60, AmbiguousWidth::Wide, ); }
The same policy is used for separators, data-driven width resolution,
sub-columns, borders, padding, truncation, and wrapping. MiniJinja col,
display_width, padding, truncation, tabular, and table operations receive
the renderer or application policy automatically.
MiniJinja tabular() and table() also use the terminal width supplied by the
current application render context when width is omitted. An explicit
width=... argument takes precedence. The default terminal-width detector
consults a valid positive $COLUMNS value before probing the terminal. When
neither source resolves a width, both helpers use a deterministic 80-column
fallback.
Policy-aware counterparts are also available for direct construction, including
TabularFormatter::with_widths_and_ambiguous_width,
TabularFormatter::from_type_with_ambiguous_width,
Table::from_spec_with_ambiguous_width, and
Table::from_type_with_ambiguous_width. Lower-level MiniJinja environments can
use register_tabular_filters_with_policy.
In Wide mode, a decorated Unicode table treats its requested width as a hard maximum. The selected Light, Heavy, Double, or Rounded border is preserved. If an odd width cannot be filled by a two-column horizontal border glyph, that border row may underfill by one column; it never exceeds the maximum or silently switches to ASCII. Narrow table geometry is unchanged.
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
Semantic style tags are zero-width layout metadata. Tabular measures only the
visible characters, and truncation preserves balanced tags around retained
text. For example, truncating prefix [match]needle[/match] suffix at 12
columns produces prefix [match]need[/match]…; the highlighted portion remains
styled when rendered. Plain-text output is the only stage that strips tags.
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::{Col, TabularFormatter, TabularSpec}; let spec = TabularSpec::builder() .column(Col::fixed(4).header("#").style("muted")) .column(Col::fixed(10).header("Status")) .column(Col::fill().header("Title").truncate_middle()) .separator(" | ") .build(); let formatter = TabularFormatter::new(&spec, 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, applies the explicitly selected Unicode ambiguous-width policy consistently, 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)?; }
Theme parses a CSS string; reading the file is the caller's job:
#![allow(unused)] fn main() { let css = std::fs::read_to_string("styles/theme.css")?; let theme = Theme::from_css(&css)?; }
For a whole directory of themes with hot reload in debug builds, use
AppBuilder::styles_dir (see App Configuration)
rather than reading a single file.
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(). 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 when the caller probes the process:
#![allow(unused)] fn main() { use standout_render::{ColorMode, TargetProperties}; let properties = TargetProperties::detect(); match properties.color_scheme { ColorMode::Light => println!("Light mode"), ColorMode::Dark => println!("Dark mode"), } }
TargetProperties::detect() is the one process probe, at the crate edge. Convenience wrappers call it then pass the result into render_request. Tests construct TargetProperties with an explicit color_scheme rather than installing a detector; set_theme_detector and the other detector override APIs are removed.
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; if let Err(error) = validate_template(template, &sample_data, &theme) { eprintln!("Unknown style tag: {}", error); 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)?; // Empty theme (for programmatic building) let theme = Theme::new(); // Legacy: YAML is still supported let theme = Theme::from_yaml(yaml_str)?; }
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 the mode-agnostic style let style: Option<Style> = theme.get_style("title", None); // Get style resolved for a specific mode let style = theme.get_style("panel", Some(ColorMode::Dark)); }
Color Mode
#![allow(unused)] fn main() { use standout_render::{ColorMode, TargetProperties}; // Auto-detect at the crate edge let properties = TargetProperties::detect(); let mode = properties.color_scheme; // Tests construct TargetProperties instead of installing a detector let mut target = properties; target.color_scheme = 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).
Pipeline 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.
Booleans and None
Standout renders these the Rust way — true, false, none — not the Jinja2
way MiniJinja itself uses (True, False, None). This holds for
interpolation, loop and set bindings, | string, | join, sequence and map
literals, standout's own filters, and table cells:
{{ flag }} {# true #}
{{ missing }} {# none #}
{{ flags }} {# [true, false, none] #}
{{ flags | join(", ") }} {# true, false, none #}
Two exceptions:
- The
~concatenation operator formats inside MiniJinja's evaluator, which exposes no hook:{{ "x" ~ flag }}yieldsxTrue. Write{{ "x" }}{{ flag }}or{{ "x" ~ flag | string }}. - Structured output (JSON, YAML, XML, CSV) skips templates entirely and serializes your data directly, so those modes follow their format's own rules.
If you build a minijinja::Environment yourself, use
standout_render::template::new_environment() — or call register_filters on
your own environment, which installs the same spelling.
The Trailing-Newline Contract
Two things happen to the newline at the end of a template, and together they are observable in the bytes a script reads, so they are stated here rather than discovered by probing.
The engine consumes exactly one final newline. This is Jinja's rule and
MiniJinja keeps it. A template file ending in a single \n renders with no
trailing newline at all; a file ending in two renders with one.
| Template source | Rendered string |
|---|---|
{{ name }} | x |
{{ name }}\n | x |
{{ name }}\n\n | x\n |
{{ name }}\n\n\n | x\n\n |
The process edge appends exactly one newline. App::run writes a handled
command's text with writeln!, so what reaches stdout is the rendered string
plus one \n — whatever the template ended with.
The practical consequence: a template that ends with one newline and a template that ends with none produce identical bytes. To end a page with a blank line, the template needs two trailing newlines. Every editor that adds a final newline on save is therefore invisible here, which is the reason the rule is worth stating.
standout-render/tests/trailing_newline.rs pins the engine half;
final_emission_routes_success_and_diagnostics_to_distinct_streams in
standout/src/cli/builder/execution.rs pins the process half.
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 |
Processing Modes 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)?; }
Template Engines
standout-render uses a pluggable template engine architecture. While MiniJinja is the default (and recommended for most users), you can choose a lighter engine or implement your own.
Available Engines
| Engine | Syntax | Features | Binary Size | Use When |
|---|---|---|---|---|
MiniJinjaEngine | {{ var }} | Loops, conditionals, filters, includes | ~248KB | Full template logic needed (default) |
SimpleEngine | {var} | Variable substitution only | ~5KB | Simple output, minimal binary size |
Feature Comparison
| Feature | MiniJinjaEngine | SimpleEngine |
|---|---|---|
| Variable substitution | {{ name }} | {name} |
| Nested property access | {{ user.name }} | {user.name} |
| Array index access | {{ items[0] }} | {items.0} |
| Filters | {{ name | upper }} | - |
| Conditionals | {% if %}...{% endif %} | - |
| Loops | {% for %}...{% endfor %} | - |
| Template includes | {% include "file" %} | - |
| Macros | {% macro %}...{% endmacro %} | - |
| Comments | {# comment #} | - |
| Escaped delimiters | {{ "{{" }} | {{ → { |
| Context injection | Yes | Yes |
| Named templates | Yes | Yes |
| Style tags | Yes (pass-through) | Yes (pass-through) |
| Hot reload | Yes | Yes |
| Structured output (JSON/YAML) | Yes | Yes |
MiniJinjaEngine (Default)
Full-featured Jinja2-compatible engine. This is what you get by default.
[title]{{ name | upper }}[/title]
{% for item in items %}
{{ loop.index }}. {{ item.name }}
{% endfor %}
Supports:
- Variable substitution with filters:
{{ name | upper }} - Control flow:
{% if %},{% for %},{% macro %} - Template includes:
{% include "partial.jinja" %} - Custom filters and functions
File extensions: .jinja, .jinja2, .j2
SimpleEngine
Lightweight engine using format-string style syntax. No loops, conditionals, or filters.
[title]{name}[/title]
Status: {status}
Contact: {user.profile.email}
Supports:
- Simple variable substitution:
{name} - Nested property access:
{user.profile.email} - Array index access:
{items.0} - Escaped braces:
{{renders as{
Does NOT support:
- Loops (
{% for %}) - Conditionals (
{% if %}) - Filters (
| upper) - Template includes
File extension: .stpl
Choosing an Engine
Use MiniJinjaEngine (default) when
- Templates need loops or conditionals
- You use filters for formatting
- Templates include other templates
- You're not concerned about binary size
Use SimpleEngine when
- Templates only substitute variables
- Binary size is critical
- You want faster parsing (no template compilation)
- Templates are simple status messages or one-liners
Using SimpleEngine
With Renderer
#![allow(unused)] fn main() { use standout_render::{Renderer, Theme, OutputMode}; use standout_render::template::SimpleEngine; let engine = Box::new(SimpleEngine::new()); let mut renderer = Renderer::with_output_and_engine( Theme::new(), OutputMode::Auto, engine, )?; renderer.add_template("status", "Status: {status}, Count: {count}")?; let output = renderer.render("status", &data)?; }
With render_auto_with_engine
#![allow(unused)] fn main() { use standout_render::{render_auto_with_engine, Theme, OutputMode}; use standout_render::template::SimpleEngine; use standout_render::context::{ContextRegistry, RenderContext}; let engine = SimpleEngine::new(); let theme = Theme::new(); let data = serde_json::json!({"name": "World"}); let registry = ContextRegistry::new(); let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &data); let output = render_auto_with_engine( &engine, "Hello, {name}!", &data, &theme, OutputMode::Text, ®istry, &render_ctx, )?; }
File Extension Mapping
When loading templates from files, the extension determines the intended engine:
| Priority | Extension | Engine |
|---|---|---|
| 1 | .jinja | MiniJinjaEngine |
| 2 | .jinja2 | MiniJinjaEngine |
| 3 | .j2 | MiniJinjaEngine |
| 4 | .stpl | SimpleEngine |
| 5 | .txt | (generic) |
When multiple files share the same base name, higher-priority extensions win for extensionless lookups.
Note: The registry resolves templates by name, but doesn't automatically select the engine. You must configure the appropriate engine when creating the Renderer.
Implementing a Custom Engine
To create your own template engine, implement the TemplateEngine trait:
#![allow(unused)] fn main() { use standout_render::template::TemplateEngine; use standout_render::RenderError; use std::collections::HashMap; pub struct MyEngine { templates: HashMap<String, String>, } impl TemplateEngine for MyEngine { fn render_template( &self, template: &str, data: &serde_json::Value, ) -> Result<String, RenderError> { // Your rendering logic here Ok(format!("Rendered: {}", template)) } fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError> { self.templates.insert(name.to_string(), source.to_string()); Ok(()) } fn render_named( &self, name: &str, data: &serde_json::Value, ) -> Result<String, RenderError> { let template = self.templates.get(name) .ok_or_else(|| RenderError::TemplateNotFound(name.to_string()))?; self.render_template(template, data) } fn has_template(&self, name: &str) -> bool { self.templates.contains_key(name) } fn render_with_context( &self, template: &str, data: &serde_json::Value, context: HashMap<String, serde_json::Value>, ) -> Result<String, RenderError> { // Merge context with data and render self.render_template(template, data) } fn supports_includes(&self) -> bool { false } fn supports_filters(&self) -> bool { false } fn supports_control_flow(&self) -> bool { false } } }
Trait Methods
| Method | Purpose |
|---|---|
render_template | Render an inline template string |
add_template | Register a named template |
render_named | Render a previously registered template |
has_template | Check if a template exists |
render_with_context | Render with additional context variables |
supports_* | Feature flags for capability discovery |
API Reference
Engine Types
#![allow(unused)] fn main() { use standout_render::template::{ TemplateEngine, // Trait for all engines MiniJinjaEngine, // Default, full-featured SimpleEngine, // Lightweight alternative }; }
Renderer with Custom Engine
#![allow(unused)] fn main() { use standout_render::{Renderer, Theme, OutputMode}; // Default (MiniJinja) let renderer = Renderer::new(theme)?; // With explicit engine let engine = Box::new(SimpleEngine::new()); let renderer = Renderer::with_output_and_engine(theme, mode, engine)?; }
Standalone Rendering
#![allow(unused)] fn main() { use standout_render::{ render_auto_with_engine, // Render with custom engine }; }
Migration Notes
If you're upgrading from a version before pluggable engines:
- No changes required - MiniJinja remains the default
- Error type changed -
minijinja::Erroris nowRenderError - New capability - You can now inject custom engines via
with_output_and_engine()
#![allow(unused)] fn main() { // Before use minijinja::Error; // After use standout_render::RenderError; }
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 templates are re-read from disk on each render. This means:
- Edit
templates/report.jinja→ re-run → see changes - No recompilation needed
#![allow(unused)] fn main() { use standout_render::{Renderer, Theme}; let mut renderer = Renderer::new(Theme::new())?; renderer.add_template_dir("./templates")?; // In debug: reads from disk each time // In release: content was scanned once at registration let output = renderer.render("report", &data)?; }
How It Works
Renderer tracks the source of each template name:
- Inline (
add_template) and embedded (with_embedded,with_embedded_source) content: always cached, never re-read. - File-based (
add_template_dir): path recorded; in debug builds the file is re-read before each render, so edits are visible without recompiling.
Supported Extensions
Templates
| Extension | Priority |
|---|---|
.jinja | 1 (highest) |
.jinja2 | 2 |
.j2 | 3 |
.stpl | 4 |
.txt | 5 (lowest) |
If both report.jinja and report.txt exist in the same directory, report.jinja is used. A lookup tries the given name exactly first; if that carries one of the extensions above, it also tries the name with that extension stripped.
Stylesheets
| Extension | Format |
|---|---|
.css | CSS syntax |
.yaml | YAML syntax |
.yml | YAML syntax |
Embedding Resources
For release builds, embed resources into the binary at compile time with embed_templates! / embed_styles!. Each macro reads matching files under the given directory and returns an EmbeddedTemplates / EmbeddedStyles value (both are EmbeddedSource<R>, differing only in the resource kind).
#![allow(unused)] fn main() { use standout_render::{embed_templates, embed_styles, Renderer, Theme}; let templates = embed_templates!("src/templates"); let styles = embed_styles!("src/styles"); let mut renderer = Renderer::new(Theme::new())?; renderer.with_embedded_source(templates); }
EmbeddedSource::should_hot_reload() is true in debug builds when the original source directory (recorded at compile time) still exists on disk. What that buys depends on which consumer takes the value, and the two differ:
App::builder().templates(embedded)/.styles(embedded)callEmbeddedSource::into_registry, which undershould_hot_reload()walks the source directory and registers the entries as file-backed. A debug build therefore re-reads them per render, exactly likeadd_template_dir. See the standout framework docs for that wiring.Renderer::with_embedded_sourcebuilds that registry and then copies every resolved entry in withadd_inline. The content is snapshotted at registration, so aRendererdoes not re-read the source directory afterwards —should_hot_reload()changes nothing a caller can observe on this path.
Hybrid Approach
Combine embedded defaults with an optional file directory. The directory adds names; it cannot replace one, because tier 1 is consulted first (see Resolution Priority) and with_embedded_source has already put every embedded name there:
#![allow(unused)] fn main() { use standout_render::{embed_templates, Renderer, Theme}; use std::path::Path; let embedded = embed_templates!("src/templates"); let mut renderer = Renderer::new(Theme::new())?; renderer.with_embedded_source(embedded); // Only reached for names not already resolved above if Path::new("./templates").exists() { renderer.add_template_dir("./templates")?; } }
So ./templates supplies templates the binary does not embed. A same-named file there is not an override: render("report") still resolves the embedded report. To let a directory win over the binary's copy, do not register the embedded source at all when the directory exists.
Resolution Priority
Renderer resolves a name in two tiers:
- Inline and embedded —
add_templateandwith_embedded/with_embedded_sourcewrite into the same namespace. Whichever call registered a name last wins; there's no separate priority between "inline" and "embedded" content. - File-based directories (
add_template_dir) — checked only for a name not already resolved in tier 1.
Registering the same name from two different directories is a collision error, not a silent override — file-based names must be unique across every directory you register.
#![allow(unused)] fn main() { renderer.with_embedded_source(embedded); // Tier 1 renderer.add_template("report", "inline"); // Tier 1 — overwrites "report" if embedded also defined it renderer.add_template_dir("./templates")?; // Tier 2 — only used for names tier 1 doesn't have }
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/ # Extra development templates (gitignored)
└── styles/ # Extra development stylesheets (gitignored)
In main.rs:
#![allow(unused)] fn main() { use std::path::Path; let embedded_templates = embed_templates!("src/templates"); let mut renderer = Renderer::new(theme)?; renderer.with_embedded_source(embedded_templates); // In debug, also pick up local templates the binary does not embed. // Names the binary already embeds keep resolving to the embedded copy. #[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 beats .txt, etc. — see the table above).
Collisions across two different directories registered with add_template_dir are reported as RegistryError::Collision, not silently resolved by registration order.
Invalid Content
Template syntax errors are reported with the template name and the underlying engine's message.
API Reference
Renderer
The primary entry point for most applications:
#![allow(unused)] fn main() { use standout_render::{Renderer, Theme}; let mut renderer = Renderer::new(Theme::new())?; // Templates renderer.add_template("name", "content")?; renderer.add_template_dir("./templates")?; renderer.with_embedded_source(embed_templates!("src/templates")); // Render let output = renderer.render("name", &data)?; let count = renderer.template_count(); }
TemplateRegistry
The lower-level registry Renderer builds on internally. Use it directly only when bypassing Renderer:
#![allow(unused)] fn main() { use standout_render::TemplateRegistry; let mut registry = TemplateRegistry::new(); registry.add_inline("greeting", "Hello, {{ name }}!"); registry.add_embedded(embedded_map); // HashMap<String, String> // Query — `get` returns the resolved source, not the raw content let resolved = registry.get("greeting")?; // Result<ResolvedTemplate, RegistryError> let content = registry.get_content("greeting")?; // Result<String, RegistryError> let names: Vec<&str> = registry.names().collect(); }
StylesheetRegistry
#![allow(unused)] fn main() { use standout_render::StylesheetRegistry; let mut registry = StylesheetRegistry::new(); registry.add_dir("./styles")?; registry.add_embedded(embedded_themes); // HashMap<String, Theme> let theme = registry.get("default")?; // Result<Theme, StylesheetError> let exists: bool = registry.contains("default"); let names: Vec<&str> = registry.names().collect(); }
Embed Macros
#![allow(unused)] fn main() { use standout_render::{embed_templates, embed_styles}; // At compile time, reads all matching files and embeds their content let templates = embed_templates!("path/to/templates"); let styles = embed_styles!("path/to/styles"); }
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 (adapter) → View data → consuming framework
- Handlers receive parsed arguments, return serializable data
- Hooks intercept execution at defined points
This isn't just architectural nicety—it unlocks:
- Testable handlers — Typed adapters with explicit inputs and outputs
- Reusable results — JSON, templates, and plain text can all start from the same handler data
- 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 adapt CLI input into application calls and
return view data:
#![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
Presentation is handled separately by the caller or by the standout framework.
Quick Start
[dependencies]
standout-dispatch = "9"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
use standout_dispatch::{ FnHandler, Output, CommandContext, HandlerResult, 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. 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 { println!("{}", serde_json::to_string_pretty(&data)?); } } 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()?) .commands(|g| g.command_with("list", list_handler, |c| c.template_name("list")))? .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 App State and 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 have explicit inputs and outputs, testing their CLI adapter behavior 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 — CLI-free library behavior and typed handler adapters have 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 shell adapters: they map parsed CLI input to application calls and return serializable CLI-owned view data. Keep reusable behavior in a CLI-free library. The handler contract is designed to be explicit rather than permissive, so adapters remain testable and decoupled from output formatting.
Quick Start: The #[handler] Macro
For most handlers, use the #[handler] macro to write typed adapter functions:
use standout_macros::handler;
#[handler]
pub fn list(#[flag] all: bool, #[arg] limit: Option<usize>) -> Result<Vec<Item>, anyhow::Error> {
storage::list(all, limit)
}
The macro leaves list alone and adds three items beside it:
| Item | What it is |
|---|---|
list__handler(&ArgMatches, &CommandContext) | reads the arguments out of ArgMatches and calls list. It returns the annotated return type verbatim — here Result<Vec<Item>, anyhow::Error>, not HandlerResult<Vec<Item>> |
list__expected_args() -> Vec<ExpectedArg> | what App::verify_command reads |
list_Handler | a unit struct implementing Handler — the registrable item |
The Result<T, E> to Output::Render wrap happens inside list_Handler's
Handler::handle, which calls IntoHandlerResult::into_handler_result on
whatever list__handler returned. It is not applied by list__handler itself.
Only registration of list_Handler runs that wrap. #[derive(Dispatch)] does
not reach the trait object at all: it registers the closure
handlers::list__handler through GroupBuilder::command_with, so nothing
calls Handler::handle and the annotated return type has to be a
HandlerResult shape already. That is the difference the next two tables
spell out.
The un-suffixed handlers::list is not registrable — it has the wrong
signature by design, so that a test can call it directly. Which of the other
two items you register depends on the method:
| Method | What it takes | What to pass |
|---|---|---|
AppBuilder::command_with | impl Handler | handlers::list_Handler |
GroupBuilder::command / command_with, and therefore #[derive(Dispatch)] | a closure returning HandlerResult<T> | handlers::list__handler |
That second row constrains the return type, and it is the one place the two
registration paths genuinely differ. list__handler returns the annotated type
verbatim, so it satisfies HandlerResult<T> only when the function was written
-> Result<Output<T>, E> (or -> Result<(), E>, whose wrapper returns
HandlerResult<()>). A handler annotated -> Result<T, E> cannot be
registered through #[derive(Dispatch)]: expansion fails with expected list__handler to return Result<Output<_>, Error>, but it returns Result<Items, Error>. Write it -> Result<Output<T>, E>, or register list_Handler through
AppBuilder::command_with, where Handler::handle applies the wrap for you.
The three return shapes, and the original functions still being callable:
use standout::cli::{CommandContext, Output}; use standout::handler; #[derive(serde::Serialize)] pub struct Items { pub names: Vec<String>, } /// `Handler::Output` is `Items`; `handle` wraps the value in `Output::Render`. #[handler] pub fn list(#[flag] all: bool) -> Result<Items, anyhow::Error> { let mut names = vec!["ssh".to_string()]; if all { names.push("cron".to_string()); } Ok(Items { names }) } /// `Handler::Output` is `Items`; the `Output` passes through untouched. #[handler] pub fn about(#[ctx] _ctx: &CommandContext) -> Result<Output<Items>, anyhow::Error> { Ok(Output::Render(Items { names: vec!["unitctl".to_string()] })) } /// `Handler::Output` is `()`; `handle` produces `Output::Silent`. #[handler] pub fn reload(#[flag] _force: bool) -> Result<(), anyhow::Error> { Ok(()) } fn main() { // No ArgMatches, no dispatcher: the annotated function is what a unit test calls. assert_eq!(list(true).unwrap().names, ["ssh", "cron"]); reload(false).unwrap(); }
Every #[dispatch(…)] and #[handler] attribute is listed in the
#[dispatch(…)] and #[handler] reference.
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) |
Without name = "x", the argument id is the parameter name with underscores
turned into hyphens: no_legend reads the argument id no-legend. Clap's own
derive ids an argument by the field name it comes from, so a clap-derive
no_legend field declares #[arg(id = "no-legend")] to meet the handler, or
the handler parameter takes the field's id with #[flag(name = "no_legend")].
app.verify_command(&cmd) reports the mismatch instead of leaving it to a
runtime get_flag panic. A parameter named with a raw identifier drops the
r# first, the way clap's derive drops it from a field name: r#type reads
the argument id type.
Return Type Handling: the function must return Result<T, E>; the macro
rejects anything else with handler must return Result<T, E>. What T is
decides what Handler::Output becomes and whether anything is wrapped.
| Annotated return type | Handler::Output | What handle produces |
|---|---|---|
Result<T, E> | T | Ok(value) wrapped in Output::Render(value) |
Result<Output<T>, E> (that is, HandlerResult<T>) | T | the Output you returned, unchanged |
Result<(), E> | () | Output::Silent |
Testing: The original function is preserved, so you can test directly:
list(true, Some(10)).
The Handler Trait
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
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:
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:
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:
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:
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>:
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:
pub type HandlerResult<T> = Result<Output<T>, anyhow::Error>;
The ? operator works naturally for error propagation:
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 }))
}
Owner-declared failures
Two concrete types carry a nonzero status and a stderr payload the framework
writes verbatim, through the same HandlerResult seam. Which one to return
depends on who reached the verdict:
// The application's own specification pins the status and the line.
Err(AppFailure::new(1, "ghlike: repository not found: demo/gamma\n")?.into())
// A delegated executable decided both, and the handler is relaying them.
Err(ExternalFailure::new(128, git_stderr)?.into())
Both reject status 0 at construction. Standout recognizes only these two
concrete types, preserves each diagnostic verbatim, and exposes them as
RunErrorKind::App and RunErrorKind::External. Ordinary handler errors still
use status 1; this is not a general exit-code mapping mechanism. Handlers must
not print or call process::exit themselves.
The Output Enum
Output<T> represents what a handler produces:
#[non_exhaustive]
pub enum Output<T: Serialize> {
Render(T),
Silent,
Binary { data: Vec<u8>, filename: String },
Artifact(Artifact<T>),
}
Output is #[non_exhaustive]: matches on it need a _ arm so later shapes
can be added without breaking downstream code.
Output::Render(T)
The common case. Data is passed to the render function:
#[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:
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:
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.
The filename is a hint for the caller, not permission to write. Without
--output-file-path, run() sends the bytes to stdout and touches no file. If
you want the framework to write the suggested destination, use Output::Artifact
— that opt-in is the whole difference between the two shapes.
Output::Artifact
Owned bytes plus an application-owned report, for commands that produce a file
and have something to say about it. Output::Binary cannot carry a report,
and nothing renders after its write, so a command that wants to say "exported 12
rows to /tmp/report.csv (2 warnings)" would otherwise have to write the file
itself — pulling destination policy back into the application core.
use standout::cli::{Artifact, HandlerResult, Output};
#[derive(Serialize)]
struct ExportReport {
exported: usize,
warnings: Vec<Warning>,
}
fn export_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ExportReport> {
let export = core::export_csv()?; // bytes + facts, no filesystem
Ok(Output::Artifact(
Artifact::new(export.csv)
.suggest_destination(export.suggested_filename)
.with_report(ExportReport {
exported: export.rows,
warnings: export.warnings,
}),
))
}
Who owns what:
| Concern | Owner |
|---|---|
| Artifact bytes | Application |
| Suggested destination | Application (a suggestion) |
| Semantic report and warning taxonomy | Application |
| Destination selection | Framework |
| The write and its failure | Framework |
| Receipt (completed destination) | Framework |
Destination policy
Standout selects the destination deterministically:
- the explicit
--output-file-pathoverride; - the artifact's
suggest_destination(...), if the application opted in; - stdout, if the application opted in with
allow_stdout().
If none applies, the run fails with FinalWrite(Artifact) rather than inventing
a file or dropping the bytes. All three steps share that one failure path.
Write first, report second
Standout writes, then renders the report from a fixed envelope:
{
"report": { "exported": 12, "warnings": [] },
"receipt": { "destination": "/tmp/report.csv", "stdout": false, "byte_count": 480 }
}
So a template can say what only the framework knows:
Exported {{ report.exported }} rows to {{ receipt.destination }}
The envelope shape is fixed (report + receipt) whatever the report's type,
so no application key can collide with the receipt. Structured modes serialize
the same envelope. A failed write renders nothing: success cannot outrun the
write that justifies it.
The report channel
Mixing a report into the bytes would corrupt them, so the channel follows the destination:
| Artifact destination | Report goes to |
|---|---|
| File | stdout |
Stdout (allow_stdout()) | stderr |
Hooks and artifacts
Post-dispatch hooks see the report as ordinary handler data. Post-output hooks
see RenderedOutput::Artifact and can still transform the bytes or the report
via as_artifact_mut(). Hooks never perform the write — that stays framework-
owned, which is what keeps the failure path single and the report honest.
Bytes are owned; streaming is deliberately not part of this contract.
CommandContext
CommandContext provides execution environment information and state access:
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:
App::builder()
.app_state(Database::connect()?)
.app_state(Config::load()?)
.command("list", list_handler, template)?
.build()?
Access in handlers via ctx.app_state:
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:
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:
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
// 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:
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 have explicit inputs and outputs, their adapter behavior is straightforward to test directly. Test validation, filtering, and state transitions through the CLI-free library instead:
#[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:
#[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:
#[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 keeps handler adapters decoupled from
presentation (renderers) and shell pipeline 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 CLI adapter executes. It receives ArgMatches and
CommandContext, calls the CLI-free application library, and returns a
HandlerResult<T>—either data to render, a silent marker, or binary content.
For simpler adapters, use the #[handler] macro to write typed 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 App State and Extensions for the Extensions API and 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(TextOutput), Binary(Vec<u8>, String), Artifact(ArtifactOutput), or Silent. For an artifact the bytes and report are still pre-write, so a hook may transform them via as_artifact_mut(); the framework, not the hook, performs the final write.
#![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_name("export") // Filter through jq (capture mode) .pipe_through("jq '.items'") }) .command_with("copy", handlers::copy, |cfg| { cfg.template_name("copy") // Send to clipboard (consume mode) .pipe_to_clipboard() }) .command_with("debug", handlers::debug, |cfg| { cfg.template_name("debug") // 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) }
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, 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 hooks let hooks = Hooks::new() .pre_dispatch(|_m, _ctx| { println!("Starting command..."); Ok(()) }); // 4. 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 { println!("{}", serde_json::to_string_pretty(&data)?); } } "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
- Presentation ownership — Rendering stays outside dispatch handlers
- Testable stages — Each component can be tested in isolation
App State and Extensions
CommandContext provides two mechanisms for state injection: app state (shared, immutable) and extensions (per-request, mutable). Understanding the distinction is key to building clean, testable CLI applications.
The Two State Types
| Aspect | app_state | extensions |
|---|---|---|
| Mutability | Immutable (&) | Mutable (&mut) |
| Lifetime | App lifetime | Per-request |
| Set by | AppBuilder::app_state() | Pre-dispatch hooks |
| Storage | Rc<Extensions> | Extensions |
| Use for | Database, Config, API clients | User sessions, request IDs |
App State: Shared Resources
App state is configured once at build time and shared immutably across all command dispatches. Use it for long-lived resources that are expensive to create or need to be shared.
Setup
#![allow(unused)] fn main() { use standout::cli::App; struct Database { pool: Pool } struct Config { api_url: String, debug: bool } struct ApiClient { base_url: String } let app = App::builder() .app_state(Database::connect()?) .app_state(Config::load()?) .app_state(ApiClient { base_url: "https://api.example.com".into() }) .commands(|g| g.command_with("list", list_handler, |c| c.template_name("list")))? .build()?; }
Accessing App State in Handlers
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { // Get required state (returns error if not found) let db = ctx.app_state.get_required::<Database>()?; let config = ctx.app_state.get_required::<Config>()?; // Optional state (returns None if not found) let api = ctx.app_state.get::<ApiClient>(); let items = db.list_items(&config.api_url)?; Ok(Output::Render(items)) } }
Type Safety
Each type can only be stored once. Storing a second value of the same type replaces the first:
#![allow(unused)] fn main() { App::builder() .app_state(Config { debug: false }) .app_state(Config { debug: true }) // Replaces previous Config }
If you need multiple instances of the same type, wrap them in distinct newtype wrappers:
#![allow(unused)] fn main() { struct PrimaryDb(Pool); struct AnalyticsDb(Pool); App::builder() .app_state(PrimaryDb(primary_pool)) .app_state(AnalyticsDb(analytics_pool)) }
Extensions: Per-Request State
Extensions are mutable and scoped to a single command dispatch. Pre-dispatch hooks inject state that handlers consume. Each dispatch starts with empty extensions.
Injection via Hooks
#![allow(unused)] fn main() { use standout_dispatch::{Hooks, HookError}; struct UserScope { user_id: String, permissions: Vec<String> } struct RequestId(String); let hooks = Hooks::new() .pre_dispatch(|matches, ctx| { // Parse user from args or environment let user_id = matches.get_one::<String>("user") .cloned() .unwrap_or_else(|| std::env::var("USER").unwrap_or_default()); // Look up permissions (could use app_state here!) let db = ctx.app_state.get_required::<Database>()?; let permissions = db.get_permissions(&user_id)?; // Inject per-request state ctx.extensions.insert(UserScope { user_id, permissions }); ctx.extensions.insert(RequestId(uuid::Uuid::new_v4().to_string())); Ok(()) }); }
Accessing Extensions in Handlers
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { // App state: shared database let db = ctx.app_state.get_required::<Database>()?; // Extensions: per-request user scope let scope = ctx.extensions.get_required::<UserScope>()?; // Use both let items = db.list_items_for_user(&scope.user_id)?; Ok(Output::Render(items)) } }
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
- Caches - Shared lookup tables or memoization
- Feature flags - Global toggles loaded at startup
Use Extensions For
- User context - Current user, session, permissions
- Request metadata - Request ID, timing, correlation ID
- Scoped overrides - Per-request configuration overrides
- Transient state - Data computed by one hook, used by handler
The Hook + Handler Pattern
A common pattern is using pre-dispatch hooks to set up request-scoped state that handlers consume:
#![allow(unused)] fn main() { // In builder setup App::builder() .app_state(Database::connect()?) .app_state(PermissionService::new()) .commands(|g| g.command_with("admin.delete", admin_delete_handler, |c| c.template_name("admin.delete")))? .hooks("admin.delete", Hooks::new() .pre_dispatch(|matches, ctx| { // Validate admin permissions using app state let perms = ctx.app_state.get_required::<PermissionService>()?; let user = std::env::var("USER").unwrap_or_default(); if !perms.is_admin(&user)? { return Err(HookError::pre_dispatch("Admin access required")); } // Inject validated user context ctx.extensions.insert(AdminUser { name: user }); Ok(()) })) .build()? }
#![allow(unused)] fn main() { // Handler can assume validation passed fn admin_delete_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<DeleteResult> { let db = ctx.app_state.get_required::<Database>()?; let admin = ctx.extensions.get_required::<AdminUser>()?; let id = matches.get_one::<String>("id").unwrap(); db.delete_with_audit(id, &admin.name)?; Ok(Output::Render(DeleteResult { id: id.clone() })) } }
Error Handling
get_required vs get
Use get_required when the state must be present (fail fast):
#![allow(unused)] fn main() { // Fails with clear error if Database not configured let db = ctx.app_state.get_required::<Database>()?; }
Use get when state is optional:
#![allow(unused)] fn main() { // Returns None if optional feature not configured if let Some(cache) = ctx.app_state.get::<Cache>() { if let Some(cached) = cache.get(key) { return Ok(Output::Render(cached)); } } }
Error Messages
get_required produces descriptive errors:
Extension missing: type myapp::Database not found in context
Testing with App State
App state makes handlers easily testable by allowing dependency injection:
#![allow(unused)] fn main() { #[test] fn test_list_handler() { // Create test fixtures let mock_db = MockDatabase::with_items(vec![ Item { id: "1", name: "Test" } ]); // Build context with test state 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(), }; // Test handler let cmd = Command::new("test"); let matches = cmd.get_matches_from(["test"]); let result = list_handler(&matches, &ctx); assert!(result.is_ok()); } }
Single-Threaded Design
App state is wrapped in Rc<Extensions> for cheap cloning within the single-threaded dispatch system. Since CLI apps are fundamentally single-threaded (parse → run one handler → output → exit), there are no thread-safety requirements on app state values.
#![allow(unused)] fn main() { // Both work - no Send + Sync requirements app_state(Database { pool: Pool::new() }) app_state(Wrapper { rc: Rc::new(data) }) // Works fine }
Summary
- App state = shared, immutable, configured at build time
- Extensions = per-request, mutable, set by hooks
- Use
get_requiredfor mandatory dependencies - Hooks can read app state to populate extensions
- Both types use the same
ExtensionsAPI for access
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.
Current App handoff
For a fallback that does not need parsed matches, keep the source-level
run() -> bool contract:
#![allow(unused)] fn main() { if !app.run(command, args) { legacy_dispatch(); } }
When the legacy path needs ArgMatches, capture the result instead.
Framework App::run_with returns standout::cli::CompletedRun, a wrapper
around this crate's outcome enum (re-exported as DispatchResult) plus
framework warnings. Match into_outcome():
#![allow(unused)] fn main() { let target = TargetProperties::detect(); let sources = InputSources::from_process(); let result = app.run_with(command, args, target, sources); let _ = result.warnings(); match result.into_outcome() { DispatchResult::NoMatch(matches) => legacy_dispatch(matches), DispatchResult::Handled(output) => consume_text(output), DispatchResult::Binary(bytes, filename) => consume_binary(bytes, filename), DispatchResult::Artifact(run) => consume_artifact(run), DispatchResult::Error(error) => { eprintln!("{}", error); std::process::exit(error.exit_status().code().into()); } DispatchResult::Silent => {} _ => {} } }
DispatchResult::Artifact only appears once dispatch owns the write. In a
hand-rolled dispatcher (below) an Output::Artifact handler comes back through
run_command as RenderedOutput::Artifact with the report serialized but
not written: the manual seam performs no framework write, so a caller adopting
one command at a time keeps owning that command's file placement until it moves
to App::run / App::dispatch.
NoMatch is deliberately status-free and emits nothing. It does not become a
Clap usage error; the fallback owns the command. All completed Standout paths
expose typed status/origin metadata. See Execution
Outcomes.
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, Handler, 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 ctx = CommandContext { command_path: path }; if let Ok(Output::Render(data)) = handler.handle(&matches, &ctx) { println!("{}", serde_json::to_string_pretty(&data).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(); } // `Output` is #[non_exhaustive]; a manual dispatcher owns the write // for artifacts it chooses to support. Err(e) => eprintln!("Error: {}", e), _ => {} } } }
Benefits During Migration
Immediate Benefits per Command
Each migrated command gains:
- Structured output — JSON/YAML support
- Testable adapter — Handler inputs and returned view data are explicit
- 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 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)) => { println!("{}", serde_json::to_string_pretty(&data).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 = "9"
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 = "9", default-features = false }
This gives you only non-interactive sources (~2 dependencies).
Full Feature Set
[dependencies]
standout-input = { version = "9", 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 use standout::cli::App; use standout_input::{InputChain, ArgSource, EditorSource}; App::builder() .commands(|g| { g.command_with("create", handlers::create, |cfg| { cfg.input("body", InputChain::<String>::new() .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: Handler adapters receive already-resolved data through an explicit seam
- Composability: Different commands can mix input sources
An InputChain runs as a pre-dispatch phase, before your handler executes. The handler receives the resolved value; input acquisition is transparent.
Source Types
Every source implements InputCollector<T> and composes into an InputChain<T>. See Backends for the full constructor and feature-flag reference for each one.
Non-Interactive Sources at a Glance
These work in scripts and CI pipelines:
| Source | Type | Use Case |
|---|---|---|
ArgSource | String | Short content as a CLI argument |
FlagSource | bool | A CLI flag, with an optional .inverted() |
StdinSource | String | Piped content (cat file | cmd) |
EnvSource | String | Environment variable |
ClipboardSource | String | Pre-filled content from the clipboard |
DefaultSource<T> | T | Hardcoded fallback |
Interactive Sources at a Glance
These require a terminal and are grouped by feature flag:
| Source | Feature | Type | Use Case |
|---|---|---|---|
TextPromptSource | simple-prompts (default) | String | Short text input |
ConfirmPromptSource | simple-prompts (default) | bool | Yes/no questions |
EditorSource | editor (default) | String | Long-form text (commit messages) |
InquireText | inquire | String | Rich text input with autocomplete |
InquireConfirm | inquire | bool | Polished yes/no prompt |
InquireSelect<T> | inquire | T | Pick one from a list |
InquireMultiSelect<T> | inquire | Vec<T> | Pick many from a list |
InquirePassword | inquire | String | Hidden text input |
InquireEditor | inquire | String | Editor with an inquire preview |
Building a Chain
Chain sources in priority order with InputChain:
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EditorSource}; let body = InputChain::<String>::new() .try_source(ArgSource::new("body")) // First: try the CLI argument .try_source(StdinSource::new()) // Second: try piped stdin .try_source(EditorSource::new() // Third: open the editor .extension(".md")) .resolve(&matches)?; }
The chain stops at the first source whose is_available() returns true and whose collect() returns Some(_). This is the gh pr create pattern:
gh pr create --body "text"→ uses the argumentecho "text" | gh pr create→ uses stdingh pr create→ opens the editor
Add .default(value) to fall back to a literal value instead of erroring with InputError::NoInput when every source is skipped, and .validate(f, "message") to apply a rule regardless of which source produced the value. See Introduction to Input for the full walkthrough.
Wiring a Chain to a Command
Outside the framework, a handler resolves a chain itself, passing the run's InputSources so stdin/clipboard/prompt mocks are honored in tests:
#![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_from(matches, ctx.input_sources())?; /* business logic ... */ } }
With the standout framework, CommandConfig::input(name, chain) registers the same chain to run in pre-dispatch, and the handler reads the resolved value with ctx.input::<T>(name) instead of resolving it itself. See Framework Integration for the full wiring and the CommandContextInput trait.
Skipping Interactive Sources
Some commands want a flag like --no-editor to skip interactive input entirely. Since chain construction is ordinary Rust, build the chain conditionally instead of adding sources that would prompt:
#![allow(unused)] fn main() { let no_editor = matches.get_flag("no-editor"); let mut chain = InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource::new()); if !no_editor { chain = chain.try_source(EditorSource::new()); } let body = chain.default(String::new()).resolve(&matches)?; }
Direct Use Without a Chain
For commands with input logic too specific for a declarative chain, call the primitives directly. Every interactive source also has a .prompt() shortcut that skips the chain and the &ArgMatches plumbing (see Standalone Prompts):
#![allow(unused)] fn main() { use standout_input::{read_if_piped, EditorSource}; 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_if_piped()? { // Piped input takes precedence piped } else if let Some(title) = title_arg { if no_editor { title.clone() } else { let body = EditorSource::new() .initial_content(format!("# {}\n\n", title)) .extension(".md") .prompt()?; format!("{}\n\n{}", title, body) } } else if no_editor { return Err(anyhow!("No content provided. Use --title or pipe input.")); } else { EditorSource::new().prompt()? }; // ... rest of handler } }
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, vi, nano (Unix), notepad (Windows) |
EditorSource::is_available() also requires stdin to be a terminal, so a piped invocation never blocks on an editor.
Clipboard Integration
#![allow(unused)] fn main() { use standout_input::ClipboardSource; let content = InputChain::<String>::new() .try_source(ArgSource::new("content")) .try_source(ClipboardSource::new()) .try_source(EditorSource::new()) .resolve(&matches)?; }
Platform support:
| Platform | Read Command |
|---|---|
| macOS | pbpaste |
| Linux | xclip -selection clipboard -o |
| Other | InputError::ClipboardFailed — not supported |
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-dispatch | Post-output |
| Interactive | Can be (editor, prompts) | Never |
| Purpose | Acquire content | Transform/route output |
INPUT SOURCES OUTPUT PIPING
↓ ↓
[Arg/Stdin/Editor] → Handler → Render → [jq/tee/clipboard]
Error Handling
InputError carries the failure reason so a chain-level ? produces an actionable message:
No editor found. Set VISUAL or EDITOR environment variable. // InputError::NoEditor
Editor cancelled without saving. // InputError::EditorCancelled
Failed to read stdin: <io error> // InputError::StdinFailed
Validation failed: Message cannot be empty // InputError::ValidationFailed
No input provided and no default available. // InputError::NoInput
For interactive sources, a validation failure re-prompts instead of returning an error — see Backends for the retry semantics.
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: EditorSource writes the initial content to a named temp file and hands it to the editor process; the file is removed when the collector drops it. Content may briefly exist on disk in the system temp directory.
Summary
| Feature | Method |
|---|---|
| From a CLI argument | ArgSource::new("name") |
| From a CLI flag | FlagSource::new("name") |
| From piped stdin | StdinSource::new() |
| From an environment variable | EnvSource::new("VAR") |
| From the clipboard | ClipboardSource::new() |
| From the editor | EditorSource::new() |
| Fallback value | .default(value) |
| Validation | .validate(f, "error message") |
| Chain multiple sources | InputChain::new().try_source(...).try_source(...) |
For the full constructor reference and feature flags, see Backends. For wiring a chain into a standout command, see Framework Integration.
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 } } }
name() is not only for humans: the chain turns it into the InputSourceKind a handler reads back through ctx.input_source(...), matching argument, flag, file, stdin, environment variable, clipboard, editor, prompt and default. A name outside that set reports InputSourceKind::Default, so a custom source that wants a provenance of its own registers with try_source_with_kind instead of try_source.
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.
Questionnaire commands use the same pre-dispatch integration model but get a
larger generated surface: questions, --answers, --yes, typed filling, and
the attended confirmation gate. See
Derived Questionnaires for that
path.
The Picture
Without framework integration, a handler resolves chains imperatively. Pass the run's InputSources so stdin, clipboard, and prompt responders match the invocation:
#![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_from(matches, ctx.input_sources())?; // <-- 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() .commands(|g| { g.command_with("create", create, |cfg| { cfg.template_name("create") .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
Every hook receives the deepest subcommand's ArgMatches — the same args the handler is about to see — so a hook on mycli create reads create's own flags.
.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:
- Calls
chain.resolve_from_with_source(matches, ctx.input_sources()). - Stashes the result in an
Inputsbag onctx.extensionsundername.
If resolution returns an error, dispatch stops and the framework reports Error: hook error (pre-dispatch): 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() { .commands(|g| { g.command_with("create", create, |cfg| { cfg.template_name("create") .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:
Error: hook error (pre-dispatch): 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 constructs InputSources with MockStdin / MockClipboard and passes them into App::run_with, so StdinSource::new() and ClipboardSource::new() inside a CommandConfig::input chain see the mocks at resolve time.
For lower-level tests that don't need the harness, pass sources into InputChain::resolve_from.
Re-exports and Feature Flags
standout re-exports standout-input as standout::input, so a single dependency on standout is enough:
[dependencies]
standout = "9"
#![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 = "9", 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 interactive sources is fully testable in process — no real TTY, no expectrl subprocess. Every interactive source consults a PromptResponder on the run's InputSources before it touches stdin. Production handler code calls .prompt_from(ctx.input_sources()) (or InputChain::resolve_from); tests put a ScriptedResponder on those sources with TestHarness::prompts(...).
#![allow(unused)] fn main() { use standout::cli::CommandContextInput; use standout_input::{InquireSelect, InquireText, PromptResponse, ScriptedResponder}; use standout_test::TestHarness; use std::sync::Arc; fn setup(_m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Value> { let sources = ctx.input_sources(); let pack = InquireText::new("Pack name:").prompt_from(sources)?; let env = InquireSelect::new("Environment:", vec!["dev", "staging", "prod"]) .prompt_from(sources)?; Ok(Output::Render(json!({ "pack": pack, "env": env }))) } #[test] fn setup_wizard_creates_pack_and_picks_environment() { let result = TestHarness::new() .prompts(Arc::new(ScriptedResponder::new([ PromptResponse::text("foo"), // pack name 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"); } }
.prompt() still exists for standalone flows that have no CommandContext; it uses InputSources::from_process(). Harness tests of framework handlers must call .prompt_from(ctx.input_sources()) so they see the scripted responder.
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, put the responder on InputSources:
#![allow(unused)] fn main() { use std::sync::Arc; use standout_input::{InputSources, ScriptedResponder, PromptResponse}; #[test] fn pack_name_validation_re_asks_on_invalid() { let sources = InputSources::from_process().with_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_from(&sources).unwrap(), Answer::Text("good-name".into()) ); } }
The harness places that responder on the run's InputSources 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.
Questionnaire Answer Sheets
Long questionnaires are awkward as a sequence of terminal prompts: you cannot see the whole thing before answering, edit long answers comfortably, or keep a sheet around for a repeatable workflow. The questionnaire module renders an application-defined questionnaire as a prose answer sheet — a document that reads as questions and answers — collects answers interactively or from a document, and decodes every submission through one shared validation pipeline.
#! standout-answers 1
#! questionnaire: demo.profile
#! fingerprint: sha256:2a4c…
1. What is your project called? (string) <id:project.name>
wizard-question-generator
2. License. (mit, bsd, or gpl) <id:project.license>
mit
3. Add any notes. (text, optional) <id:project.notes>
This answer may span several lines.
Internal line breaks remain part of the answer.
Who owns what
The boundary is deliberate and narrow:
standout-input owns | Your application owns |
|---|---|
| Definition validation (IDs, defaults, conditions) | The questionnaire definition itself |
| Deterministic rendering of the answer sheet | Converting decoded Answers into domain types |
Parsing edited sheets into RawAnswers | Whole-form rule content (a closure you supply) |
| Collection adapters (interactive, file, stdin) | Field-validator rule content (with a revision) |
| Shared field decoding, constraints, blank rules | Interactive flow, review, confirmation |
| Compatibility checking (version, ID, fingerprint) | All side effects (file writes, generation, etc.) |
| Diagnostics with occurrence paths and line numbers |
Every collection path stops at the same two waypoints: RawAnswers (trimmed answer text keyed by occurrence path — the stable field ID, with a zero-based index per enclosing repeatable-group occurrence, as in command.inputs[1].name) and, after decode_answers, typed Answers. The library never sees your domain model, and your domain model never leaks into the format.
The table above describes the standalone standout-input boundary. When a
questionnaire is attached to a standout command, the framework also owns the
standard command surface (questions, --answers, --yes), typed
pre-dispatch resolution, optional review callback, and attended confirmation
gate. See Derived Questionnaires
for that command-integration path.
Defining scalar fields
A ScalarField declares everything semantic about one question:
- Kind —
String(single line),Text(multiline),Bool(decodestrue/false/yes/no/y/n, case-insensitive),Path(single line, no filesystem checks at decode time). - Optionality —
.optional(): a blank answer without a default means omission rather than an error. - Default —
.with_default("mit"): rendered pre-filled as the answer text below the question line; during decoding, any blank answer resolves to the default before optionality is considered. Defaults must decode cleanly themselves. - Constraint —
.one_of(["mit", "bsd", "gpl"]): the decoded answer must be one of the choices. Enforced by the shared decoder on every path. - Conditional applicability —
.active_when("project.docker", "yes"): the field is asked and enforced only while the (earlier-declared) controller holds the expected value. - Application validator —
.with_validator(FieldValidator::new("name-rules-1", …)): your closure runs inside the shared decode stage; the revision string is its semantic identity (see fingerprinting below).
Collecting answers
Three adapters, one representation — every path normalizes to RawAnswers and uses the same decoders and validators, so equivalent answers behave identically everywhere. Sources never merge: one submission comes from exactly one source.
- Interactive —
collect_interactive()walks applicable fields through the existing prompt abstractions (and thePromptRespondertest seam, so tests need no TTY). A decode or validation failure is local and retryable: the one question re-prompts with the diagnostic, and previously accepted answers are kept. Inactive conditional fields are skipped without prompting. EOF / Ctrl+D cancels the collection. - Named file —
read_answer_sheet_file(path, format)reads one complete document. - Explicit stdin —
read_answer_sheet_stdin(reader, format)reads one complete document from piped stdin (for an--answers -style flag). An interactive terminal on stdin is an error, not a hang.
Both reading adapters take the AnswerSheetFormat that turns the bytes into RawAnswers; pass &StandoutAnswerSheet for the sheet render_answer_sheet produces.
Decoding and batch diagnostics
decode_answers (or decode_answers_with, which also runs your whole-form closure) applies one blank rule everywhere: blank → declared default → otherwise omission if optional, missing-value error if required. Conditional fields must be answered when active; an inactive field may stay blank (or keep its untouched pre-filled default), while a populated inactive field is an error — stale intent is never silently discarded.
Batch submissions accumulate every independent diagnostic — syntax and identity problems from parsing, then missing values, conversion failures, constraint violations, field-validator rejections, and your whole-form errors — so a sheet can be repaired in one editing pass. Diagnostics identify fields by occurrence path — the stable ID, indexed per repeatable-group occurrence — and never echo submitted values.
Stable identity
The line-terminal tag (<id:project.name>) is the only machine identity in a question block. Display numbers, wording, indentation, and the parenthesized type hint are cosmetic — a user may reword, renumber, or re-indent a sheet freely (hints may contain any characters), and a later release of your application may copy-edit its questions without invalidating sheets already in the wild.
Recognition is one rule: a line is a question line if and only if it ends with a schema-known <id:...> tag — the tag must be the last non-whitespace content on the line, and any trailing non-blank character (even a period) demotes the line to ordinary prose. Everything between a question line and the next question line (or end of file) is that field's answer — outer whitespace trimmed, internal line breaks preserved. Bracketed prose, -> bullets, and mid-line tag mentions inside an answer (see <id:project.name> above) are inert answer content.
Question lines that carry an unknown tag ID, or repeat a known one, are diagnostics rather than silently ignored text.
One limitation is accepted by design: an answer line that itself ends with a schema-valid <id:...> tag is read as a question line — there is no escaping mechanism, because the shape is rare in real prose. As a guard, accepted answer text containing <id: anywhere raises a warning-level diagnostic (RawAnswers::warnings), which also catches mangled or half-deleted tags, without failing the submission.
Compatibility: exact match, no migration
Every sheet's preamble pins three things:
- the answer-format version (
standout-answers 1), - the questionnaire ID,
- a semantic fingerprint of the definition.
Parsing accepts only exact matches of all three. A stale or foreign sheet gets an actionable diagnostic asking for a freshly rendered sheet — never a guessed mapping from old fields to new ones.
This is StandoutAnswerSheet's contract, and it binds a submission only while that format reads it. An application whose own spec pins the shape of the file implements AnswerSheetFormat instead — over the tagged body alone (parse_answer_sheet_body, the same parse without the preamble) or over bytes that share nothing with a rendered sheet, filling a RawAnswers through set and set_occurrence_count and returning its own diagnostics. A format decides how bytes become raw answers and nothing else; decoding, defaults, validators and whole-form rules run identically afterwards.
The fingerprint covers every semantic property that changes which answers are accepted: field IDs, kinds, optionality, defaults, constraint choices, conditions, and declared validator revisions. It ignores wording, help text, display numbers, presentation order, and choice order, so cosmetic edits keep old sheets valid while semantic changes reliably invalidate them. Because a validator closure's behavior cannot be observed, its revision string stands in for it — bump the revision whenever the validator's accepted values change.
The fingerprint is a compatibility checksum, not authentication. It does not detect tampering and does not protect the document's content.
Sensitive content
Answer sheets are plain text files holding whatever your questions ask for — possibly credentials, internal names, or personal data. Treat a saved sheet with the same care as the answers themselves: keep it out of version control and world-readable locations, and delete it when no longer needed. Diagnostics identify fields by occurrence path and line number without echoing answer values; your validator and form messages should follow the same rule.
Example
#![allow(unused)] fn main() { use standout_input::questionnaire::{ FormError, Questionnaire, ScalarField, ScalarKind, }; // Application-owned definition. The IDs are the stable contract; // the wording is yours to edit at any time. let questionnaire = Questionnaire::new( "demo.profile", vec![ ScalarField::new("project.name", "What is your project called?", ScalarKind::String), ScalarField::new("project.license", "License.", ScalarKind::String) .one_of(["mit", "bsd", "gpl"]) .with_default("mit"), ScalarField::new("project.docker", "Use Docker?", ScalarKind::Bool) .with_default("no"), ScalarField::new("project.docker_image", "Base image?", ScalarKind::String) .active_when("project.docker", "yes"), ScalarField::new("project.notes", "Add any notes.", ScalarKind::Text).optional(), ], ) .unwrap(); // Render a blank sheet for the user to edit (defaults pre-filled)… let sheet = questionnaire.render_answer_sheet(); // …and later, collect from wherever the caller chose — a file, piped // stdin, or interactive prompts — then decode through the shared pipeline // plus your whole-form rules. let edited_text = sheet.replace( "<id:project.name>\n", "<id:project.name>\nwizard-question-generator\n", ); // Stage 1: document → raw answers (syntax, identity, compatibility). let raw = match questionnaire.parse_answer_sheet(&edited_text) { Ok(raw) => raw, Err(diagnostics) => { for diagnostic in diagnostics { eprintln!("{diagnostic}"); } return; } }; // Stage 2: raw answers → typed values (defaults, kinds, constraints, // conditions, your field validators, your whole-form rules). match questionnaire.decode_answers_with(&raw, |answers| { let mut errors = Vec::new(); if answers.get_text("project.name") == Some("reserved") { errors.push(FormError::new(["project.name"], "that name is reserved")); } errors }) { Ok(answers) => { // Typed values by stable ID; domain conversion is yours. let name = answers.get_text("project.name"); let docker = answers.get_bool("project.docker"); } Err(diagnostics) => { for diagnostic in diagnostics { eprintln!("{diagnostic}"); } } } }
Rendering is deterministic: the same definition always produces the same bytes, fingerprint included, so sheets are diffable and cache-friendly.
Current scope
This topic walks scalar fields end to end: definition, rendering with defaults, interactive / file / stdin collection, shared decoding with constraints, conditions, and application validators, and accumulated batch diagnostics. Questionnaires also support nested and repeatable groups on this same compatibility model; a submitted item inside a repeatable group is addressed by its occurrence path — the stable ID with a zero-based index per enclosing occurrence, as in command.inputs[1].name — never by numbering or wording. See the questionnaire module documentation for the group format and copy-the-block editing.
standout-input Design
Overview
standout-input is a standalone crate for declarative input collection in CLI applications. It provides a unified way to acquire user input from multiple sources—CLI arguments, stdin, environment variables, editors, and interactive prompts—with automatic fallback chains.
This is the symmetric counterpart to standout-pipe:
standout-pipe: Handler → Render → [jq/tee/clipboard] (output flows OUT)
standout-input: [arg/stdin/editor/prompt] → Handler (input flows IN)
Goals
- Declarative input chains - Define fallback sequences (arg → stdin → editor) without imperative logic
- Pluggable backends - Support multiple prompt libraries via a common trait
- Minimal by default - Core has ~2 deps; heavy backends are opt-in via features
- Standalone value - Useful for any CLI, not just standout users
- Validation integration - Chain-level and collector-level validation with retry support
- Testable - Handlers receive resolved content; sources can be mocked
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ standout-input (core) │
│ │
│ InputCollector<T> trait InputChain<T> builder │
│ ArgSource, StdinSource EnvSource, ClipboardSource │
│ DefaultSource<T> Validation hooks │
│ │
│ feature = "simple-prompts" feature = "editor" │
│ ├── SimpleText └── EditorCollector │
│ └── SimpleConfirm (tempfile + which) │
│ │
│ feature = "inquire" feature = "dialoguer" (future) │
│ ├── InquireText ├── DialoguerText │
│ ├── InquireConfirm ├── DialoguerConfirm │
│ ├── InquireSelect ├── DialoguerSelect │
│ ├── InquireMultiSelect └── DialoguerEditor │
│ ├── InquirePassword │
│ └── InquireEditor │
│ │
│ feature = "validify" │
│ └── Validify rule integration │
└─────────────────────────────────────────────────────────────────┘
Core Trait
#![allow(unused)] fn main() { /// A source that can collect input of type T. pub trait InputCollector<T>: Send + Sync { /// Human-readable name for errors and debugging. fn name(&self) -> &'static str; /// Can this collector provide input in the current environment? /// /// Returns false if: /// - Interactive collector but no TTY /// - Stdin source but stdin is not piped /// - Arg source but argument not provided fn is_available(&self, matches: &ArgMatches) -> bool; /// Attempt to collect input. /// /// Returns: /// - Ok(Some(value)) if input was collected /// - Ok(None) if this source should be skipped (try next in chain) /// - Err(_) on failure (abort the chain) fn collect(&self, matches: &ArgMatches) -> Result<Option<T>, InputError>; /// Validate collected value. Called after successful collect(). /// Default implementation accepts all values. fn validate(&self, _value: &T) -> Result<(), String> { Ok(()) } /// Can this collector retry after validation failure? /// Returns true for interactive collectors (prompts, editor). fn can_retry(&self) -> bool { false } } }
Input Chain
#![allow(unused)] fn main() { /// Chain multiple input sources with fallback behavior. pub struct InputChain<T> { sources: Vec<Box<dyn InputCollector<T>>>, validators: Vec<Box<dyn Fn(&T) -> Result<(), String> + Send + Sync>>, default: Option<T>, } impl<T: Clone> InputChain<T> { pub fn new() -> Self { ... } /// Add any collector to the chain. pub fn try_source<C: InputCollector<T> + 'static>(mut self, source: C) -> Self { self.sources.push(Box::new(source)); self } /// Add a validation rule to the chain. pub fn validate<F>(mut self, f: F, error_msg: &str) -> Self where F: Fn(&T) -> bool + Send + Sync + 'static { ... } /// Use this value if no source provides content. pub fn default(mut self, value: T) -> Self { self.default = Some(value); self } /// Resolve the chain: try each source in order. pub fn resolve(&self, matches: &ArgMatches) -> Result<T, InputError> { for source in &self.sources { if !source.is_available(matches) { continue; } loop { match source.collect(matches)? { Some(value) => { // Source-level validation if let Err(msg) = source.validate(&value) { if source.can_retry() { eprintln!("Invalid: {}", msg); continue; } return Err(InputError::ValidationFailed(msg)); } // Chain-level validation for validator in &self.validators { validator(&value)?; } return Ok(value); } None => break, // Try next source } } } self.default.clone().ok_or(InputError::NoInput) } } }
Built-in Sources (Core)
Always available, no feature flags:
#![allow(unused)] fn main() { /// Read from a clap argument. pub struct ArgSource { name: String, } /// Read from stdin if piped (not a TTY). pub struct StdinSource; /// Read from environment variable. pub struct EnvSource { var_name: String, } /// Read from system clipboard. pub struct ClipboardSource; /// Provide a default value. pub struct DefaultSource<T> { value: T, } }
Simple Prompts (feature = "simple-prompts")
Minimal prompts using only std::io, no external deps:
#![allow(unused)] fn main() { /// Basic text input prompt. pub struct SimpleText { message: String, default: Option<String>, } /// Basic yes/no confirmation. pub struct SimpleConfirm { message: String, default: bool, } }
These provide bare-bones functionality for users who don't want inquire's TUI.
Editor (feature = "editor")
Opens the user's preferred editor:
#![allow(unused)] fn main() { pub struct EditorCollector { initial: Option<String>, extension: Option<String>, require_save: bool, trim_newlines: bool, env_vars: Vec<String>, // ["VISUAL", "EDITOR"] by default } impl EditorCollector { pub fn new() -> Self { ... } pub fn initial(mut self, content: impl Into<String>) -> Self { ... } pub fn extension(mut self, ext: impl Into<String>) -> Self { ... } pub fn require_save(mut self, require: bool) -> Self { ... } pub fn trim_newlines(mut self, trim: bool) -> Self { ... } pub fn env_precedence(mut self, vars: Vec<String>) -> Self { ... } } }
Editor detection follows conventions:
- Check env vars in order (default:
VISUAL, thenEDITOR) - Fall back to platform defaults (
vimon Unix,notepadon Windows) - Search PATH for common editors
Inquire Backend (feature = "inquire")
Full-featured prompts using the inquire crate:
#![allow(unused)] fn main() { /// Rich text input with autocomplete, validation display. pub struct InquireText { ... } /// Confirmation with customizable yes/no labels. pub struct InquireConfirm { ... } /// Single selection from a list. pub struct InquireSelect<T> { ... } /// Multiple selection from a list. pub struct InquireMultiSelect<T> { ... } /// Hidden password input. pub struct InquirePassword { ... } /// Editor with inquire's two-step UX (press 'e' to open). pub struct InquireEditor { ... } }
Dialoguer Backend (feature = "dialoguer") — Future
Reserved for future implementation. Dialoguer shares the console crate with standout-render, making it lightweight for existing standout users.
Error Types
#![allow(unused)] fn main() { #[derive(Debug, thiserror::Error)] pub enum InputError { #[error("No editor found. Set VISUAL or EDITOR environment variable.")] NoEditor, #[error("Editor cancelled without saving.")] EditorCancelled, #[error("Editor failed: {0}")] EditorFailed(#[source] std::io::Error), #[error("Failed to read stdin: {0}")] StdinFailed(#[source] std::io::Error), #[error("Failed to read clipboard: {0}")] ClipboardFailed(String), #[error("Prompt cancelled by user.")] PromptCancelled, #[error("Prompt failed: {0}")] PromptFailed(String), #[error("Validation failed: {0}")] ValidationFailed(String), #[error("No input provided and no default available.")] NoInput, } }
Usage Examples
Basic: Arg with Editor Fallback
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, EditorCollector}; let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) .try_source(EditorCollector::new() .initial("# Enter commit message\n\n") .extension(".md")) .resolve(&matches)?; }
The gh pr create Pattern
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EditorCollector}; // arg → stdin → editor let body = InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource) .try_source(EditorCollector::new() .initial("# PR Description\n\n")) .validate(|s| !s.trim().is_empty(), "Body cannot be empty") .resolve(&matches)?; }
Interactive Confirmation
#![allow(unused)] fn main() { use standout_input::{InputChain, FlagSource, InquireConfirm}; // -y flag skips prompt let proceed = InputChain::<bool>::new() .try_source(FlagSource::new("yes").inverted()) // -y means true .try_source(InquireConfirm::new("Delete 5 items?").default(false)) .default(false) .resolve(&matches)?; }
Selection with Validation
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, InquireSelect}; #[derive(Clone, Debug)] enum Format { Json, Yaml, Csv } let format = InputChain::<Format>::new() .try_source(ArgSource::new("format").parse()) .try_source(InquireSelect::new("Output format:") .option(Format::Json, "JSON - machine readable") .option(Format::Yaml, "YAML - human readable") .option(Format::Csv, "CSV - spreadsheet compatible")) .default(Format::Json) .resolve(&matches)?; }
Direct Library Use (Complex Logic)
For commands with intricate input logic, use primitives directly:
#![allow(unused)] fn main() { use standout_input::{editor, stdin, clipboard}; fn create_handler(matches: &ArgMatches) -> Result<Pad> { let no_editor = matches.get_flag("no-editor"); let title_arg = matches.get_one::<String>("title"); let content = if let Some(piped) = stdin::read_if_piped()? { piped } else if let Some(title) = title_arg { if no_editor { title.clone() } else { let body = editor::edit(EditorConfig::new() .initial(&format!("# {}\n\n", title)))? .unwrap_or_default(); format!("{}\n\n{}", title, body) } } else if no_editor { return Err(anyhow!("No content provided")); } else { let initial = clipboard::read().unwrap_or_default(); editor::edit(EditorConfig::new().initial(&initial))? .ok_or_else(|| anyhow!("Editor cancelled"))? }; // ... create pad } }
Cargo.toml
[package]
name = "standout-input"
version = "1.0.0"
edition = "2021"
description = "Declarative input collection for CLI applications"
license = "MIT"
keywords = ["cli", "input", "prompt", "editor", "terminal"]
categories = ["command-line-interface"]
repository = "https://github.com/arthur-debert/standout"
[features]
default = ["editor", "simple-prompts"]
editor = ["dep:tempfile", "dep:which"]
simple-prompts = []
inquire = ["dep:inquire"]
# dialoguer = ["dep:dialoguer"] # Future
# validify = ["dep:validify"] # Future
[dependencies]
thiserror = "2"
clap = { version = "4", default-features = false }
# Optional: editor support
tempfile = { version = "3", optional = true }
which = { version = "7", optional = true }
# Optional: inquire prompts
inquire = { version = "0.7", optional = true }
# Future
# dialoguer = { version = "0.11", optional = true }
# validify = { version = "...", optional = true }
Dependency Analysis
| Configuration | Unique Deps | Use Case |
|---|---|---|
default-features = false | ~2 | Minimal: just arg/stdin/env |
features = ["editor"] | ~16 | + Editor (tempfile, which) |
features = ["simple-prompts"] | ~2 | + Basic TTY prompts |
features = ["inquire"] | ~29 | + Rich TUI prompts |
features = ["dialoguer"] | ~8 new* | + dialoguer (*shares console with standout) |
Standout Integration
When used with the standout framework:
Builder API
#![allow(unused)] fn main() { let app = App::builder() .command_with("create", handlers::create, |cfg| { cfg.template_name("create") .input("body", InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource) .try_source(EditorCollector::new())) }) .build()?; }
Handler Macro (Future)
#![allow(unused)] fn main() { #[handler] pub fn create( #[input(fallback = "editor")] body: String, #[flag] verbose: bool, ) -> Result<CreateResult, Error> { // body is resolved before handler runs } }
Implementation Phases
Phase 1: Core Structure
InputCollector<T>traitInputChain<T>builderInputErrortype- Core sources:
ArgSource,StdinSource,EnvSource,ClipboardSource,DefaultSource - Basic tests
Phase 2: Backends
feature = "editor":EditorCollectorwith tempfile + whichfeature = "simple-prompts":SimpleText,SimpleConfirmfeature = "inquire": Full inquire adapter suite
Phase 3: Standout Integration
- Builder API support (
.input()method) - Pre-dispatch resolution hook
- Documentation and examples
Future Considerations
feature = "dialoguer": Dialoguer adapter (shares console with standout-render)feature = "validify": Deep validation integration#[input]attribute for handler macro
Topics
In-depth documentation for specific Standout systems and use cases.
Framework Configuration
App Configuration
The AppBuilder API for configuring your application. Covers embedding templates and styles, theme selection, command registration, hooks, context injection, flag customization, and the complete setup workflow.
Output Modes
The --output flag and OutputMode enum. Covers auto/term/text modes for terminal output, structured modes (JSON, YAML, XML, CSV), file output, and how to access the mode in handlers.
Topics System
Adding help topics to your CLI. Covers the Topic struct, TopicRegistry, loading topics from directories, help integration, pager support, and custom rendering.
Crate Documentation
For detailed documentation on the underlying libraries, see:
Rendering (standout-render)
- Introduction to Rendering — Templates, themes, output modes
- Introduction to Tabular — Column layouts and tables
- Styling System — Themes, adaptive styles, CSS syntax
- Templating — MiniJinja, style tags, processing modes
- File System Resources — Hot reload, registries, embedding
Dispatch (standout-dispatch)
- Introduction to Dispatch — Handlers, hooks, testing
- Handler Contract — Handler traits, Output enum
- Execution Model — Pipeline, hooks, command routing
- Partial Adoption — Incremental migration strategies
What Is Contract, and What Is Internal
This page answers one question: is this change breaking?
Contract does not mean permanent. This repository does not preserve backwards compatibility and ships no adapters; its users are the maintainer and downstreams who port. What contract buys is a rule about cost. Changing a contract surface is a breaking change: it takes a major version and a line in the migration notes, and it cannot ride along inside a refactor. Changing an internal surface takes neither.
Contract
Six things, and nothing else.
1. The blessed idioms
Each axis of wiring an application — registration, adaptation, declaration, template provision, theme provision, entry points — has exactly one blessed item, and a small number of secondary paths that survive because they name a capability nothing else on the same axis covers. All of them are contract, by name, signature and meaning. A secondary path is contract for exactly the capability its stated reason names.
The blessed set:
#[derive(Parser)] // declaration: clap-derive
struct Cli { /* … */ }
#[derive(Subcommand, Dispatch)] // registration
#[dispatch(handlers = handlers)]
enum Commands { /* … */ }
#[handler] // adaptation
fn list(#[flag] all: bool, #[ctx] ctx: &CommandContext) -> Result<Output<Listing>, anyhow::Error> {
/* … */
}
App::builder()
.templates(embed_templates!("src/templates")) // template provision
.styles(embed_styles!("src/styles")) // theme provision
.default_theme("myapp")
.commands(Commands::dispatch_config())? // registration
.build()?
.run(Cli::command(), std::env::args()); // entry point
Dropping a secondary path later is itself a major version. That friction is deliberate: an item kept with a stated reason cannot be quietly removed as "internal, nobody used it".
2. The structural shape of each --output mode's bytes
--output accepts auto, term, text, term-debug, json, yaml, xml
and csv. All eight are classified here.
Structured modes (json, yaml, csv, xml): the document a handler's
data produces — its field names and its nesting — is contract. Changing it
changes what a consuming script parses.
Human modes (text, term): the bytes are not contract. Themes,
wording, column widths and layout may change in any release. What is contract
is the pair of properties a script can rely on without reading words:
- The style transformation.
textremoves Standout's style tags and adds no ANSI of its own.termturns every resolved style tag into ANSI. Neither half reaches ANSI that a handler or a template writes literally — the framework does not sanitize those bytes and does not promise to, so a caller who needs them gone strips them itself. - The split between the streams. Data goes to stdout; diagnostics and warnings go to stderr.
auto is contract as a resolution rule rather than as bytes: it resolves
to term when the destination reports color capability and to text when it
does not, and a term request under a never-color policy resolves to text.
What a caller may rely on is which of the two modes it lands in, and then that
mode's own contract.
term-debug is internal. It prints style tags unresolved, as evidence
for the framework's own snapshots; both that tag vocabulary and its spelling
may change in any release.
One more byte-level rule belongs here because a script can see it: the render pipeline consumes the template's final newline and the process edge appends exactly one. See the trailing-newline contract.
3. Exit statuses
Zero means success, and each documented nonzero status keeps its documented
meaning. An application-owned status is the application's to choose, and the
framework emitting it verbatim is the contract — that is AppFailure, and
ExternalFailure for a status another operation declared. The wording of the
diagnostics the framework writes for itself is not contract; see
Error Handling.
4. The two name mappings a user types on the command line
#[handler]'s parameter name to clap argument id — underscores become hyphens, sono_legendreads the argument idno-legend.#[derive(Dispatch)]'s variant name to command name — kebab-case, soListUnitsregisterslist-units, and#[dispatch(name = "…")]renames one variant.
These are contract because they are not source-level at all. They decide the
words in a shell script, and a change to either breaks callers who never
recompile. Both rules are stated in full in the
#[dispatch(…)] and #[handler] reference.
5. Re-export from the standout crate root
An item's availability through standout is contract; its location is not.
A type may move between leaf crates in any release as long as the root
re-export still names it, and a leaf crate's own API is contract only where
standout re-exports it. That rule is what makes one standout dependency
enough, and what keeps reorganizing the crates from being a breaking change on
its own.
6. standout-test's assertion API
A downstream's test suite depends on it, so a rename there breaks a build that never touched the framework. Contract by name, signature and meaning:
TestHarness, with its injection methods and its run forms —runandrun_processon every supported platform,run_ptyon Unix only, where the pseudo-terminal it opens exists.TestResult, which an in-processrunreturns: itsoutcome, its exit status and success and error kinds, its raw and plain streams andbinary, its style-tag resolutions, itswarnings, its artifact accessors and itsassert_*methods.ProcessResult, which a spawnedrun_processorrun_ptyreturns: its process status, code and success, its raw, plain and byte streams, its tempdir and itsassert_*methods.assert_page_snapshot!withSnapshotCase, andmatrixwithMatrixCell.- The
clap_parityandinvariantsmodules.
An item behind a cfg is contract on the platforms it compiles for, so
narrowing its cfg is a breaking change on the platforms it leaves. A
snapshot's contents are not contract — a snapshot is evidence, and evidence
changing is the point of a snapshot. The serial re-export is serial_test's
API, not this repository's.
Internal
Everything else, explicitly including:
- any path the blessing deleted;
- the internals that lost their
pubin the visibility sweep; - module paths within a crate;
- rendered help layout, and the wording of diagnostics and warnings;
- the framework's own template and style names, beyond the fact that
include_framework_templates(false)andinclude_framework_styles(false)decline them; - the leaf crates' APIs where
standoutdoes not re-export them.
One boundary worth naming
A machine-readable schema — a versioned envelope a consumer can validate against — is not part of this statement. What this statement establishes is that structured output has a contract shape at all, not that the shape is published as a schema.
Where this comes from
ADR-0033 decides what this page says, including the alternatives that were rejected: declaring the whole public API contract, declaring nothing contract, and declaring the human-mode bytes contract. ADR-0032 carries the blessed set and the capability map behind item 1.
The #[dispatch(…)] and #[handler] Reference
Two macros carry the blessed idiom. #[derive(Dispatch)] on an enum binds
command names to handler functions; #[handler] on a function turns it into
something the dispatcher can call. This page is the complete list of what each
one accepts.
Both name mappings on this page — a variant name to a command name, and a parameter name to a clap argument id — are contract, because they decide the words a user types in a shell script. See What Is Contract.
#[derive(Dispatch)]
The container attribute
One attribute goes on the enum, and it is required:
#[derive(Dispatch)]
#[dispatch(handlers = handlers)]
enum Commands { /* … */ }
handlers = <module path> names the module the derive looks each variant's
handler up in. Without it, expansion fails with missing #[dispatch(handlers = path)] attribute.
From a variant name to a command name
A variant registers under its kebab-case name — ListUnits becomes
list-units — which is the spelling clap's own derive gives the subcommand.
#[dispatch(name = "…")] renames one variant.
A name may not be empty and may not contain .: dispatch splits registration
paths on ., so such a name would register a nested path no clap subcommand
declares. Nesting is #[dispatch(nested)], below.
A registered path that no clap subcommand can reach is a loud error naming the
path, raised by App::run and App::verify_command before dispatch.
From a variant name to a handler function
The handler is looked up under the variant's snake_case name in the
handlers module — ListUnits calls handlers::list_units. Two attributes
change that:
#[dispatch(pure)]appends__handler, so the derive callshandlers::list_units__handler— the wrapper#[handler]generated. Use it whenever the handler function carries#[handler], which is the blessed style. The derive registers through a closure takingHandlerResult<T>, so apurehandler must be annotated-> Result<Output<T>, E>or-> Result<(), E>; a plain-> Result<T, E>fails expansion. See what#[handler]generates.#[dispatch(handler = <path>)]names the function outright and ignores the inferred name.
The two are mutually exclusive, and expansion rejects the pair with a message
naming both. Without pure, the derive calls the named function directly, so
that function must already have the dispatch signature.
Every variant attribute
Several #[dispatch(…)] attributes may sit on one variant; they merge before
anything reads them.
| Attribute | Form | What it does |
|---|---|---|
name | name = "…" | Registers the command under this name instead of the variant's kebab-case name. Not empty, no .. |
handler | handler = <path> | Calls this function instead of the inferred one. Excludes pure. |
pure | flag | The handler carries #[handler]: append __handler to the inferred name. Excludes handler and simple. |
simple | flag | The handler takes only &ArgMatches, with no &CommandContext. Excludes pure. |
nested | flag | The variant is a subcommand group. Requires a single-field tuple variant wrapping another Dispatch enum. |
skip | flag | Registers nothing for this variant. |
default | flag | Runs this command on a naked invocation. At most one variant per enum. |
template_name | template_name = "…" | Names the template registry entry instead of using the convention. Excludes silent, binary and structured_only. |
silent | flag | The command renders nothing. Excludes binary, structured_only, template_name. |
binary | flag | The command writes bytes rather than a rendered page. Same exclusions. |
structured_only | flag | The command has output only in the structured modes. Same exclusions. |
list_view | flag | Renders through the framework's built-in standout/list-view template, unless template_name also appears, and attaches a tabular spec to the handler's Output::Render. |
item_type | item_type = "…" | Names the Tabular-implementing item type list_view builds its spec from. |
questionnaire | questionnaire = <path> | Resolves this questionnaire type before the handler runs. |
pre_dispatch | pre_dispatch = <path> | Runs this hook before the handler. |
post_dispatch | post_dispatch = <path> | Runs this hook after the handler, before rendering. |
post_output | post_output = <path> | Runs this hook after the output is produced. |
pipe_to | pipe_to = "…" | Sends the output to this command and keeps the original. |
pipe_through | pipe_through = "…" | Replaces the output with this command's stdout. |
pipe_to_clipboard | flag | Sends the output to the clipboard. |
An unrecognized key fails expansion with a message listing every key above.
Nested commands
#[dispatch(nested)] marks a variant that wraps another Dispatch enum:
#[derive(Dispatch)]
#[dispatch(handlers = handlers)]
enum Commands {
#[dispatch(nested)]
Pr(PrCommands),
}
The derive registers the group rather than a command, and the inner enum's own
variants register beneath it. Registration paths are dot-joined as the
recursion descends, so pr checks list registers the path pr.checks.list.
How a command finds its template
With no template_name and no absence marker (silent, binary,
structured_only), the template name is the registration path with each
. replaced by /, and no extension appended:
| Registration path | Template name |
|---|---|
list | list |
pr.checks.list | pr/checks/list |
The registry then resolves that name against .jinja, .jinja2, .j2,
.stpl and .txt, in that priority order — so a nested pr checks list
command is rendered by src/templates/pr/checks/list.jinja. A name may be
looked up with or without an extension; the extension is stripped and the base
name retried.
#[dispatch(template_name = "…")] replaces that name with a registry entry you
choose, which is how two commands share one template.
#[handler]
Parameter attributes
Every typed parameter carries one of four attributes, references included: the
macro reads the value out of ArgMatches (or hands over the dispatcher's own
reference) by the attribute, never by the type, so a &CommandContext still
needs #[ctx] and an &ArgMatches still needs #[matches].
| Attribute | Type | Where the value comes from |
|---|---|---|
#[flag] | bool | matches.get_flag(id) |
#[arg] | T | a required argument |
#[arg] | Option<T> | an optional argument |
#[arg] | Vec<T> | a repeated argument |
#[ctx] | &CommandContext | the dispatcher |
#[matches] | &ArgMatches | the dispatcher, unparsed |
From a parameter name to a clap argument id
Underscores become hyphens. A parameter named no_legend reads the
argument whose id is no-legend.
Clap's own derive ids an argument by the field name it comes from, so the two disagree unless one of them says so. Either side can:
#[arg(id = "no-legend")] // on the clap-derive field
no_legend: bool,
#[flag(name = "no_legend")] // on the handler parameter
no_legend: bool,
app.verify_command(&cmd) reports the mismatch at build time rather than
leaving it to a runtime get_flag panic.
A parameter named with a raw identifier drops the r# first, the way clap's
derive drops it from a field name: r#type reads the argument id type.
What #[handler] generates
For fn list, four items:
| Item | What it is |
|---|---|
list | the original function, unchanged apart from the parameter attributes being removed — still directly callable from a test |
list__handler(&ArgMatches, &CommandContext) | the wrapper that extracts the arguments and calls list. It returns the function's own annotated return type, verbatim |
list__expected_args() -> Vec<ExpectedArg> | what verify_command reads |
list_Handler | a unit struct implementing Handler — the registrable item |
The Result<T, E> to Output::Render wrap happens inside Handler::handle,
not inside list__handler, and that is what makes the two registration methods
accept different things. AppBuilder::command_with takes an impl Handler, so
handlers::list_Handler works for any of the three return shapes.
GroupBuilder::command_with — which is what #[derive(Dispatch)] reaches —
takes a closure returning HandlerResult<T>, so it accepts
handlers::list__handler only when the function was annotated
-> Result<Output<T>, E> or -> Result<(), E>. The un-suffixed
handlers::list is registrable through neither.
See the handler contract for the return types themselves.
A worked example
Every attribute below is exercised by
crates/standout-fixtures/src/derive_surface.rs, which compiles under
deny(warnings) against a single standout dependency.
use clap::{Arg, ArgAction, ArgMatches, Command}; use standout::cli::{App, CommandContext, Dispatch, Output}; use standout::{handler, EmbeddedTemplates}; #[derive(serde::Serialize)] pub struct Units { pub names: Vec<String>, } pub mod handlers { use super::*; #[handler] pub fn list_units(#[flag] all: bool) -> Result<Output<Units>, anyhow::Error> { let mut names = vec!["ssh".to_string()]; if all { names.push("cron".to_string()); } Ok(Output::Render(Units { names })) } #[handler] pub fn about(#[ctx] _ctx: &CommandContext) -> Result<Output<Units>, anyhow::Error> { Ok(Output::Render(Units { names: vec!["unitctl".to_string()] })) } #[handler] pub fn reload(#[matches] _matches: &ArgMatches) -> Result<(), anyhow::Error> { Ok(()) } } #[derive(Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { #[dispatch(pure, default)] ListUnits, #[dispatch(pure, name = "about-this")] About, #[dispatch(pure, silent)] Reload, } const TEMPLATES: &[(&str, &str)] = &[ ("list-units", "{{ names | join(', ') }}"), ("about-this", "{{ names | join(', ') }}"), ]; /// The clap surface `Commands` is registered against. `Dispatch` connects a /// variant to a handler; declaring the handler's arguments stays clap's job, /// so `list-units` carries the `all` its handler reads. fn command() -> Command { Command::new("unitctl") .subcommand( Command::new("list-units") .arg(Arg::new("all").long("all").action(ArgAction::SetTrue)), ) .subcommand(Command::new("about-this")) .subcommand(Command::new("reload")) } fn main() -> Result<(), Box<dyn std::error::Error>> { let app: App = App::builder() .templates(EmbeddedTemplates::new(TEMPLATES, "")) .commands(Commands::dispatch_config())? .build()?; app.verify_command(&command())?; Ok(()) }
ListUnits registers the command list-units, calls
handlers::list_units__handler, renders the registry entry list-units, and
runs on a naked invocation. Its #[flag] all parameter reads the clap argument
all that command() declares on that subcommand; verify_command is what
reports the two drifting apart, instead of leaving it to a get_flag panic on
the first invocation. About registers about-this and renders the
entry of the same name, because name changes the registration path and the
convention follows it. Reload registers reload, calls
handlers::reload__handler and renders nothing, so it needs no template.
Execution Outcomes
Standout carries shell semantics as typed data from argument parsing through
dispatch, rendering, hooks, output files, and final writes. Applications can let
App::run own output without losing the distinction between a usage error and a
runtime failure.
Status and streams
| Outcome | Stream used by run() | Status |
|---|---|---|
| Help or version | stdout | 0 |
| Successful rendered text or binary | stdout | 0 |
Output::Silent | none | 0 |
--output-file-path success | file only | 0 |
| Artifact written to a file | bytes to the file, report to stdout | 0 |
| Artifact written to stdout | bytes to stdout, report to stderr | 0 |
| Artifact with no selectable destination | stderr | 1 |
| Clap usage error | stderr | 2 |
| Handler, hook, render, pipe, or write failure | stderr | 1 |
| Application-declared external failure | stderr | exact declared nonzero status |
Framework warning flushing happens after the primary output and does not replace
its status. Warnings cover non-fatal framework-owned setup, resource-loading,
and accepted-input diagnostics, including answer-sheet parse warnings that do
not reject a questionnaire submission. A final text or binary write failure does
replace a successful status with 1, except that BrokenPipe while writing
final rendered command text to stdout is successful early consumer termination.
Capturing typed metadata
run_with keeps output in-process and returns CompletedRun: the dispatch
outcome plus any framework warnings collected during the run. Deref keeps
string-oriented accessors and typed methods (exit_status(), success_kind(),
error_kind()) working on the wrapper. Pattern matching needs outcome() or
into_outcome(), because CompletedRun is not the variant enum.
#![allow(unused)] fn main() { use standout::cli::{ CompletedRun, DispatchResult, ExitStatus, OutputKind, RunError, RunErrorKind, SuccessKind, }; use standout::{InputSources, TargetProperties}; let result = app.run_with( command, args, TargetProperties::detect(), InputSources::from_process(), ); let _ = result.warnings(); match result.outcome() { DispatchResult::Handled(output) => println!("{}", output), DispatchResult::Binary(bytes, filename) => consume(bytes, filename), DispatchResult::Artifact(run) => { use std::io::{self, Write}; if run.destination().is_stdout() { let mut stdout = io::stdout(); stdout.write_all(run.bytes()).and_then(|()| stdout.flush()).map_err(|error| { RunError::new( format!("Error writing artifact stdout: {}", error), RunErrorKind::FinalWrite(OutputKind::Artifact), ) })?; if let Some(report) = run.report().filter(|r| !r.is_empty()) { let mut stderr = io::stderr(); writeln!(stderr, "{}", report).and_then(|()| stderr.flush()).map_err(|error| { RunError::new( format!("Error writing artifact report: {}", error), RunErrorKind::FinalWrite(OutputKind::Artifact), ) })?; } } else if let Some(report) = run.report().filter(|r| !r.is_empty()) { let mut stdout = io::stdout(); writeln!(stdout, "{}", report).and_then(|()| stdout.flush()).map_err(|error| { RunError::new( format!("Error writing artifact report: {}", error), RunErrorKind::FinalWrite(OutputKind::Artifact), ) })?; } } DispatchResult::Error(error) => eprintln!("{}", error), DispatchResult::NoMatch(_matches) => {} DispatchResult::Silent => {} _ => {} } assert_eq!(result.exit_status(), Some(ExitStatus::SUCCESS)); assert_eq!(result.success_kind(), Some(SuccessKind::Command)); assert_eq!(result.error_kind(), None); }
Use into_outcome() when the fallback needs owned ArgMatches
(DispatchResult::NoMatch(matches)).
RunOutput and RunError dereference to str, implement Display, and expose
as_str() / into_string() for callers that used the tuple payloads as text.
RunOutput::kind() names a success the same way: Command for a handler's
output, ClapHelp / ClapVersion for a help or version display, and
PagedHelp for a help --page display — the text is identical, but the kind
tells a printing caller the user asked for a pager.
RunError::kind() identifies ClapUsage, Handler, Hook(phase), Render, or
FinalWrite(Text|Binary|Artifact). External identifies the narrow
application-declared external path; its exit_status() is the exact declared
nonzero status and its text is the verbatim diagnostic payload.
No-match is a handoff, not an error
DispatchResult::NoMatch retains the parsed ArgMatches for partial adoption. It has
no framework exit status: exit_status() returns None, run() returns
false, and Standout emits nothing. The fallback dispatcher still owns that
command and its eventual status.
The reverse direction never hands off. Before parsing, run and
run_with check every registered path against the clap Command: a
handler registered under a path the CLI declares no subcommand for is
unreachable — no invocation can name it and no fallback owns it, since the app
did register a handler. That returns DispatchResult::Error naming the
registered path, and the clap spelling too when the two differ only by -
versus _ (list_units registered against a CLI declaring list-units).
App::verify_command reports the same mismatch at setup time.
That check reads canonical command names only. Clap resolves an alias to the
command it names before ArgMatches reports it, so dispatch never sees the
alias: a handler registered as ls against Command::new("list").alias("ls")
is reached by neither spelling and is reported as unreachable. Registering
list is what makes both list and ls run the handler.
Framework-owned final writes
run() writes successful text and binary bytes to stdout, diagnostics to
stderr, and exits with the typed non-zero status when execution fails. The one
exception is a paged help display (SuccessKind::PagedHelp), which goes to the
pager instead; if no pager is available it falls back to stdout, so help is
never lost. A closed
downstream pipe is not an error only for final rendered command text:
BrokenPipe there means the consumer stopped reading early. Binary stdout
writes and artifact report writes keep their typed final-write failures. The
suggested filename on binary output remains available to capture callers; use
--output-file-path when the framework should write either text or binary to a
file instead of stdout.
Capture APIs do not perform the final stdout/stderr write, but file redirection
is part of dispatch and therefore reports typed FinalWrite failures directly.
External failures are never redirected to an output file: they remain stderr
diagnostics when run() performs the final write.
Capture-mode runs drain framework warnings instead of rendering them. The raw
capture path stores the batch in standout-render's warning collector, and
standout-test::TestHarness exposes the batch through TestResult::warnings()
so tests can assert warning content deterministically.
Compound artifacts
Output::Artifact extends the framework-owned write to commands that also have
something to say about it. The application returns bytes, an optional suggested
destination, and an optional report; Standout selects the destination, writes,
and only then renders the report with a receipt naming where the bytes landed.
Ordering is the guarantee: a failed write produces FinalWrite(Artifact) and no
report at all, so a success message can never promise a file that never
appeared.
See Handler Contract for the destination policy, the report envelope, and the artifact-to-stdout report channel.
Error Handling
Standout owns the shell adapter: handlers and hooks return errors as data, and
App::run performs the final stderr write and process exit. Ordinary handler,
hook, render, pipe, and final-write failures use status 1; Clap usage failures
use status 2.
The handler diagnostic framing
One framing covers every diagnostic Standout writes on an application's behalf:
a fixed Error: prefix, then the error's own Display text, then a newline.
Handler failures and hook failures both use it, so a reader sees one shape:
Error: could not read /etc/myapp.toml
Error: hook error (pre-dispatch): input `body`: Validation failed: body must not be empty
A hook's own Display names its phase, which is why a hook line carries
hook error ({phase}): inside the framing. The wording of these diagnostics is
internal and may change in any release (ADR-0033); an application that must pin
its stderr bytes writes them itself through AppFailure, below.
Ordinary application errors
Return ordinary errors through HandlerResult with ?. Standout applies the
handler diagnostic framing, reports the failure under RunErrorKind::Handler,
and exits with status 1:
#![allow(unused)] fn main() { fn handler(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<View> { let view = load_view()?; Ok(Output::Render(view)) } }
Do not print or call process::exit from handlers. This keeps capture APIs,
TestHarness, output ownership, and real process behavior on the same seam.
An application-owned status and diagnostic
AppFailure is the seam for a domain error whose exit status and stderr bytes
the application's own specification pins. It carries any nonzero u8 and a
verbatim stderr payload: Standout adds no Error: prefix and no trailing
newline, and the status rides to the process exit. Construction rejects status
0, so a domain error can never report shell success.
#![allow(unused)] fn main() { use standout::cli::{AppFailure, HandlerResult}; fn handler(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<View> { let Some(repo) = find_repo()? else { return Err(AppFailure::new(1, "ghlike: repository not found: demo/gamma\n")?.into()); }; Ok(Output::Render(to_view(repo))) } }
A pre-dispatch guard reaches the same seam through
HookError::pre_dispatch_app. Capture callers see RunErrorKind::App.
AppFailure carries a status and bytes, and nothing else. It is not a
structured error type: the machine-readable error envelope belongs to the
parity program's machine contract, which will version the envelope this seam
feeds (ADR-0035).
Preserving an authoritative external failure
Use ExternalFailure when another operation owns the status and diagnostic
contract, such as a delegated Git invocation — the application is relaying a
verdict rather than reaching one, which is the whole difference from
AppFailure. Construction rejects status 0, and the diagnostic is a verbatim
stderr payload: Standout adds no Error: prefix and no trailing newline.
#![allow(unused)] fn main() { use standout::cli::{ExternalFailure, HandlerResult}; fn handler(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<View> { let output = run_git()?; if !output.status.success() { let status = output.status.code().and_then(|code| u8::try_from(code).ok()).unwrap_or(1); let diagnostic = String::from_utf8_lossy(&output.stderr).into_owned(); return Err(ExternalFailure::new(status, diagnostic)?.into()); } Ok(Output::Render(to_view(output))) } }
A supported pre-dispatch check uses the same ExternalFailure interface:
#![allow(unused)] fn main() { Hooks::new().pre_dispatch(|_matches, _ctx| { let failure = ExternalFailure::new(128, "fatal: repository not found\n") .expect("128 is nonzero"); Err(HookError::pre_dispatch_external(failure)) }) }
Neither escape hatch is an error-mapping registry. Wrapping an ordinary error
does not change its status, and neither declaration is recognized from
post-dispatch or post-output hooks. Attach an underlying cause with
with_source when one exists.
Capture callers match result.outcome() / into_outcome() as
DispatchResult::Error, then inspect the kind (RunErrorKind::App or
RunErrorKind::External), error.exit_status(), and error.as_str(). See
Execution Outcomes and Testing.
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 when --output is absent, and an application can change
that default with
output_mode_fallback(mode) — an
explicit --output still outranks it. Auto 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: turns every resolved style tag into ANSI escape codes, including when the destination is a pipe rather than a terminal:
myapp list --output=term > colored.txt
Useful when you want to preserve colors for later display (e.g., less -R).
A term request is unconditional, and the environment's color conventions do
not override it: NO_COLOR=1 myapp list --output=term still emits ANSI, the
same way CLICOLOR_FORCE=1 myapp list --output=text still emits none. auto
is the only mode the environment reaches, and it reaches it through one value:
the destination's reported color capability. auto resolves to term when
that capability is reported and to text when it is not. NO_COLOR and
TERM=dumb suppress the capability, so they turn auto plain;
CLICOLOR_FORCE is not part of that capability probe, so it never turns auto
into term.
Text: removes Standout's own style tags and adds no ANSI of its own:
myapp list --output=text
Useful for clean output regardless of terminal capabilities, or when processing output with other tools.
Neither term nor text touches ANSI bytes that a handler or template
writes literally into the rendered text — the framework does not sanitize
those bytes and does not promise to. A caller that needs them gone strips
them itself.
term-debug (which shows tags as [name]...[/name] rather than resolving
them) is internal: its tag vocabulary and exact spelling may change in any
release, so don't build automation against its output the way you might
against term or text.
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.
TermDebug shows tag placement; it does not check whether a tag has a matching
style definition. Use validate_template when validation is required.
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
Normal App dispatch flattens the serializable handler data automatically for
CSV. That is the same handler data used by the other structured modes; handlers
should not inspect the requested mode or return a CSV-specific shape.
The standalone rendering API also supports direct FlatDataSpec rendering when
a caller needs explicit columns and headers:
#![allow(unused)] fn main() { use standout::tabular::{Column, FlatDataSpec, Width}; use standout::OutputMode; use standout_render::render_auto_with_spec; 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"]).
When a command's canonical response is an object containing the CSV rows,
attach a presentation-layer projection through CommandConfig:
#![allow(unused)] fn main() { use serde_json::json; use standout::cli::FnHandler; use standout::tabular::{Column, Width}; use standout::{CsvProjection, StructuredOutputProjection}; let projection = StructuredOutputProjection::csv( CsvProjection::builder("items") .column(Column::new(Width::default()).key("language").header("LANGUAGE")) .column(Column::new(Width::default()).key("code").header("CODE")) .derived_column( Column::new(Width::default()).header("NET"), |row, _root| json!( row["code"].as_i64().unwrap_or(0) - row["comments"].as_i64().unwrap_or(0) ), ) .synthetic_row(|root| json!({ "language": "TOTAL", "code": root["totals"]["code"], "comments": root["totals"]["comments"] })) .conditional_row(|root| { (root["skipped"].as_u64().unwrap_or(0) > 0) .then(|| json!({ "language": "SKIPPED" })) }) .build(), ); App::builder().command_with("summary", FnHandler::new(summary_handler), |config| { config.structured_output_projection(projection) })?; }
Direct-column dot paths are resolved against each selected row. Derived
columns receive both the current row and the root response. Synthetic-row
callbacks receive the root response and run in registration order. Column
ordering, headers, and null_repr use the existing FlatDataSpec behavior.
The projection applies only to CSV. Text and terminal modes still use the
template, while JSON, YAML, and XML serialize the canonical response. In the
pipeline, post-dispatch hooks run before projection and post-output hooks run
after it; run, run_with, output-file handling, and final emission
therefore all observe the same projected CSV.
See Introduction to Tabular for tabular specifications and layout.
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 the requested file instead of stdout
- 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()? }
Keep Output Mode Out of Handlers
Output mode is a rendering concern and is deliberately absent from
CommandContext. A handler should return the same serializable data regardless
of whether the caller selected terminal, text, or structured output. If a
command's behavior genuinely differs, model that as an explicit command or
argument rather than an implicit presentation-mode branch.
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:
- Templating and Styling System for templates and styles.
- Topics System for help topics.
Basic Setup
#![allow(unused)] fn main() { use standout::cli::{App, FnHandler}; 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_with("list", FnHandler::new(list_handler), |config| config.template_name("list"))? .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.
Resources Read at Run Time
templates_dir and styles_dir add a directory read at run time, for
resources that live outside the crate source tree and so cannot be embedded:
#![allow(unused)] fn main() { App::builder() .templates(embed_templates!("src/templates")) .templates_dir("~/.myapp/templates") // Adds names the binary does not embed .styles(embed_styles!("src/styles")) .styles_dir("~/.myapp/themes") // Likewise for themes }
These directories add names; they do not replace them. A registry resolves
an embedded name before it looks at any directory registered this way, so a
~/.myapp/templates/list.jinja sitting beside an embedded list never
renders — see Resolution Priority.
To let a user directory win, register only the directory, without the
embed_templates! call, when that directory exists.
Theme Selection
From Stylesheet Registry
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) .default_theme("dark") }
.default_theme(name) names the theme build() loads from the stylesheet
registry; if that name isn't found, build() returns
SetupError::ThemeNotFound. With no .default_theme(...) call, build()
does not fall back to any conventional name — the application resolves to no
application theme, leaving the framework's own base styling.
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) }
.theme(...) sets the theme directly, bypassing the stylesheet registry.
Calling both .styles(...) and .theme(...) on the same builder is a
SetupError that names both calls — configure one path or the other, not
both.
Command Registration
Simple Commands
#![allow(unused)] fn main() { App::builder() .command_with("list", FnHandler::new(list_handler), |cfg| cfg)? .command_with("add", FnHandler::new(add_handler), |cfg| cfg)? }
AppBuilder::command_with takes an impl Handler, not a bare function, so a
plain fn(&ArgMatches, &CommandContext) -> HandlerResult<T> is wrapped in
FnHandler::new(...) first; a #[handler]-annotated function registers as the
name_Handler struct the macro generates instead. (GroupBuilder::command_with
— the entry .commands(...) and #[derive(Dispatch)] reach — takes the bare
closure and wraps it for you, which is why the nested-group example below does
not name FnHandler.)
With no .template_name(...) set on the CommandConfig, the template
resolves by convention: the command path with . replaced by / (list,
add), matched against the registered templates using the extension list
.jinja, .jinja2, .j2, .stpl, .txt.
With Configuration
#![allow(unused)] fn main() { App::builder() .command_with("delete", FnHandler::new(delete_handler), |cfg| cfg .template_name("delete") .pre_dispatch(require_confirmation) .post_dispatch(log_deletion))? }
Inline command configuration can also attach a
StructuredOutputProjection for CSV shaping. The projection stays at the
presentation boundary: it sees post-dispatch data, and handlers remain
independent of the selected output mode. See Output Modes.
Nested Groups
#![allow(unused)] fn main() { App::builder() .commands(|g| g .group("db", |g| g .command("migrate", migrate_handler) .command("status", status_handler) .group("backup", |b| b .command("create", backup_create) .command("restore", backup_restore))))? }
Creates command paths: db.migrate, db.status, db.backup.create,
db.backup.restore. Each resolves, by convention, to a template named after
its path (db/migrate, db/status, db/backup/create,
db/backup/restore); attach a CommandConfig to a group entry with
command_with instead of command when one needs .template_name(...) or
another CommandConfig setting.
From Dispatch Macro
#![allow(unused)] fn main() { #[derive(Dispatch)] #[dispatch(handlers = handlers)] enum Commands { List, Add, #[dispatch(nested)] Db(DbCommands), } App::builder() .commands(Commands::dispatch_config())? }
#[dispatch(handlers = <module path>)] on the enum is required: it names the
module Dispatch looks up each variant's handler function in (handlers::list
for List, handlers::add for Add). A nested variant's own type
(DbCommands here) needs its own #[derive(Dispatch)] with its own
#[dispatch(handlers = ...)] — the macro generates registration for all
variants, but a container attribute is scoped to the enum it's on.
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, "{{ items | length }} items") .command("add", add_handler, "Added {{ name }}") }
With this configuration:
myappbecomesmyapp listmyapp --output=jsonbecomesmyapp list --output=jsonmyapp add foostays asmyapp add foo(explicit command takes precedence)
Default resolution applies to both the integrated dispatch path (run, run_with) and configured parsing (get_matches_from). If you parse first and build dispatch state afterwards, the matches you get back already name the resolved command.
Invocation-Aware Defaults
A fixed name can't express "it depends". default_command_with chooses the default per invocation:
#![allow(unused)] fn main() { App::builder() .default_command_with(|ctx| { Some(if ctx.stdin_is_piped() { "add" } else { "list" }.to_string()) }) .command("list", list_handler, "{{ items | length }} items") .command("add", add_handler, "Added {{ name }}") }
myappat a terminal becomesmyapp listcat notes.txt | myappbecomesmyapp add, which reads the pipemyapp done 3stays asmyapp done 3
The resolver receives a DefaultCommandContext exposing only the facts needed to pick a command:
| Method | Fact |
|---|---|
matches() | The parsed root ArgMatches — globals and root flags |
app_state::<T>() | Read-only app state registered via .app_state(...) |
stdin_is_terminal() / stdin_is_piped() | Whether stdin is redirected |
Plus std::env for env-derived facts. The matches are the root's, so global flags and root arguments are all there; there is no subcommand, because that is what makes the invocation naked.
Stdin is never read during resolution. The terminal check is the same non-consuming StdinReader::is_terminal seam the input system uses, so a handler's InputChain still consumes the pipe normally afterwards. This also means piped-but-empty stdin is a pipe, not a terminal — emptiness is only knowable by reading, which resolution never does. If empty input should be an error, that's the receiving command's InputChain policy, not the resolver's.
Ordering guarantees
Clap decides which command a line named, and resolution reads that decision. A parse that selected a subcommand is not naked; a parse that selected none is.
- Explicit and nested commands short-circuit resolution — the resolver never runs.
--help/--versionare Clap's own displays: no default is inserted, somyapp --helprenders the root's help rather than a default command's.- Invalid syntax stays a Clap usage error (exit 2). If a default command is configured, a refused line is offered to it —
myapp --allis a naked line at a root that has no--all, and becomesmyapp list --allwhen--allbelongs tolist— and whatever the amended line parses to, success or failure, is what you get. --, option values, aliases, and short clusters mean exactly what they mean everywhere else, because the same parser reads them.
A root that requires a subcommand — what #[command(subcommand)] command: Commands produces — still accepts a naked invocation: the line is refused, the default is substituted, and the amended line parses. The field does not have to be Option<Commands>. See ADR-0018.
Combining both
Both may be configured together. The resolver is consulted first; returning None declines to the static default:
#![allow(unused)] fn main() { App::builder() // Pipes mean `add`; everything else falls back to `list`. .default_command("list") .default_command_with(|ctx| ctx.stdin_is_piped().then(|| "add".to_string())) }
Returning a name that isn't a command of your clap::Command fails the run with RunErrorKind::DefaultCommand (exit 1), carrying a diagnostic that names the offending resolver output and lists the valid commands. A resolver naming a command the CLI doesn't have is an application bug, so it's reported as one rather than reaching Clap as a usage error blaming the user. Return None to decline.
Validation is against your clap::Command's names, not Standout's registered handlers — so partial adoption stays coherent: resolving to a Clap command Standout doesn't handle yields NoMatch, exactly as typing it explicitly would.
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_with("db.migrate", FnHandler::new(migrate_handler), |cfg| cfg)? .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.
Hook order, and where a questionnaire sits in it
Pre-dispatch hooks run in the order they were registered — Hooks keeps them
in a list and run_pre_dispatch walks it front to back. The same holds for
post-dispatch and post-output.
CommandConfig::questionnaire::<T>() is a pre-dispatch hook, so it takes its
place in that same order: a .pre_dispatch(f) written before it runs before
the answers are resolved and cannot read them, and one written after it runs
with ctx.questionnaire::<T>() already populated.
// `check_permissions` runs first, then the questionnaire resolves,
// then `audit` runs and can read the answers.
CommandConfig::new(handler)
.pre_dispatch(check_permissions)
.questionnaire::<ProvisionAnswers>()
.pre_dispatch(audit)
One trap goes with that: CommandConfig::hooks(hooks) replaces the config's
hook set rather than appending to it, so calling .hooks(…) after
.questionnaire::<T>() discards the questionnaire's own hook and the answers
never resolve. Register per-phase with .pre_dispatch(…) when a questionnaire
is involved.
Registering the same phase for one path through both CommandConfig and
AppBuilder::hooks is a configuration error naming the path and the phase,
rather than one hook set silently replacing the other.
Stating a single ordering rule for pre-dispatch hooks — including which matches they receive — is issue #352 in the adopter-seams epic. What is written above is today's behavior.
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.
They also receive ctx.ambiguous_width(), the application's explicit
East Asian Ambiguous character-width policy. Configure it at the rendering
seam; narrow is the compatibility default and Standout does not infer a locale:
#![allow(unused)] fn main() { use standout::{AmbiguousWidth, cli::App}; let app = App::builder() .ambiguous_width(AmbiguousWidth::Wide) .build()?; }
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.
Version
Application version metadata belongs on the builder, next to the rest of the app's configuration:
#![allow(unused)] fn main() { App::builder() .version(env!("CARGO_PKG_VERSION")) }
Standout applies the value to the root command wherever it augments and parses
it, so every entry point — run, run_with, get_matches_from, and
TestHarness — answers myapp --version the same way:
Clap's own display, on stdout, exit status 0, typed as
SuccessKind::ClapVersion (see Execution
Outcomes).
Clap keeps owning the spelling and formatting of that output and the display
short-circuit; the builder only says what the version is. Leave .version()
unset and the supplied clap::Command is untouched, including a version
configured on Clap directly.
This is separate from .context("version", …), which puts a value in templates
({{ version }}); an app that wants both says both.
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 }
Output Mode Fallback
The mode used when the flag is absent from the command line. It defaults to
OutputMode::Auto; an application that decides its own default — from its own
environment variable, a config file, or anything else it reads at build time —
sets it here:
#![allow(unused)] fn main() { App::builder() .output_mode_fallback(OutputMode::Term) }
Precedence is --output first, then the fallback. An explicit --output always
wins, so this sets the default rather than overriding the user. Forcing color
regardless of mode is a separate axis and is not what this call does.
Every path that renders without an --output on the command line uses the
fallback: command output, both help spellings (app help and app --help), the
--output help entry's advertised default, and the diagnostics of errors raised
before parsing. app --help renders in the fallback even when the command line
does carry an --output — the help flags never read the flag (Help).
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. The theme build() merged is always present
(theme: Theme); get_default_theme() returns &Theme.
#![allow(unused)] fn main() { pub struct App { registry: TopicRegistry, output_flag: Option<String>, output_mode_fallback: OutputMode, output_file_flag: Option<String>, theme: Theme, command_hooks: HashMap<String, Hooks>, template_registry: Option<TemplateRegistry>, stylesheet_registry: Option<StylesheetRegistry>, } }
Running the App
Standard Execution
#![allow(unused)] fn main() { if !app.run(Cli::command(), std::env::args()) { // Standout did not handle this command; fall back to legacy dispatch. legacy_dispatch(); } }
Parses args, dispatches to a handler, and performs the final write. It returns
true when Standout handled the command and false for an unmatched fallback.
Help/version and successes use stdout/status 0, usage errors use stderr/status
2, and runtime/write failures use stderr/status 1. The two owner-declared
failures are the exceptions: AppFailure carries the application's own nonzero
status and verbatim stderr payload, and ExternalFailure preserves an
authoritative external operation's. See Error Handling.
Capture Output
For tests, reach for standout_test::TestHarness (see Testing).
For post-processing, or any other embedding caller that needs the output
string, pass destination properties and input sources in explicitly:
#![allow(unused)] fn main() { let target = TargetProperties::detect(); let sources = InputSources::from_process(); let result = app.run_with(cmd, args, target, sources); let _ = result.warnings(); match result.into_outcome() { DispatchResult::Handled(output) => { /* use output string */ } DispatchResult::Binary(bytes, filename) => { /* handle binary */ } DispatchResult::Error(error) => { /* inspect error.kind() */ } DispatchResult::NoMatch(matches) => { /* fallback dispatch */ } _ => {} } }
Returns CompletedRun instead of printing: a wrapper around DispatchResult
plus framework warnings. Use exit_status(), success_kind(), and
error_kind() for typed assertions; see Execution
Outcomes.
Parse Only
#![allow(unused)] fn main() { match app.get_matches_from(cmd, std::env::args(), &InputSources::from_process()) { HelpResult::Matches(matches) => { /* use matches for manual dispatch */ } HelpResult::Help(text) | HelpResult::PagedHelp(text) => { /* the invocation asked for help */ } HelpResult::Error(e) => { /* a clap::Error: usage failure, or --version display */ } } }
Parses with Standout's augmented command, intercepting help display; returns matches only when the invocation didn't trigger a help/usage/version display.
Build Validation
build() validates:
- a theme registry exists and contains the theme named by
.default_theme(...) - named templates resolve through
.templates(...)or.templates_dir(...) - convention templates resolve when application templates are configured; without application templates, human-mode rendering reports the missing convention template at runtime
- registered templates compile
- framework templates only use tags defined by the resolved theme
command_groups, topics, andhelp_word(true)are not combined with.help_handling(false)- commands do not collide with the
helpword standout installs when help handling is on - the same command path and hook phase are not configured through both
CommandConfigandAppBuilder::hooks
What's NOT validated at build time:
- Command handlers
- Hook signatures (verified at registration)
Complete Example
use standout::cli::{App, CommandContext, FnHandler, HandlerResult, Output}; use standout_macros::{embed_templates, embed_styles}; use clap::{Command, ArgMatches}; 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") .version(env!("CARGO_PKG_VERSION")) .context("version", env!("CARGO_PKG_VERSION").into()) .command_with("list", FnHandler::new(list_handler), |config| { config.template_name("list") })? .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, TargetProperties injection, InputSources).
Four levels, four tools
A production-shaped Standout app has four testing layers, each appropriate to a different kind of change:
| Level | What it covers | Tool | Speed |
|---|---|---|---|
| Core | Library validation, filtering, transitions, persistence | Plain #[test] through the library interface | Microseconds |
| Adapter | CLI-to-core mapping and returned view DTOs | Direct typed handler 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 the
core library. A bug mapping --all to that filter belongs in a direct handler
test. A bug in "does this command actually read piped stdin?" belongs in the
harness. A bug in raw-mode TUI redraw belongs in an end-to-end test.
What each layer gives you for free
The library owns behavior; handlers are adapters
Keep the reusable application library free of Clap, Standout, command contexts, environment lookup, templates, and output. Test filtering, validation, state transitions, and persistence through that library's interface.
The handler then maps CLI input to a library call and maps the result to a
CLI-owned serializable view model. It does not touch stdout or render. With
#[handler], test this mapping by calling the preserved typed function and
asserting on Output::Render data.
For the canonical example, see the production-shaped application. The key invariant is stronger than terminal independence: nothing in the reusable library depends on the CLI.
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_with with fluent setup for every injectable piece of state:
Its TestResult also exposes exit_status(), success_kind(), and
error_kind(), with assertions for typed status and failure origin. NoMatch
returns no framework status because the fallback dispatcher still owns the
command. For an AppFailure or an ExternalFailure, stdout() is empty,
error() and stderr() are the verbatim diagnostic, error_kind() is
RunErrorKind::App or RunErrorKind::External, and exit_status() retains the
declared value (including values such as 128).
- 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) - Destination facts on
TargetProperties: width, color capability, color-scheme, icon mode, ambiguous-width (injected, never detected) - Stdin, clipboard, and prompt responder as an
InputSourcesvalue passed intoApp::run_with(not process-global overrides) - Interactive prompt responder on those sources, so wizard handlers that call
.prompt_from(ctx.input_sources())are testable in process — see Interactive Flows → Testing Wizards - Forced
OutputMode(injected as--output=<mode>into argv) - Framework warnings captured from the run boundary, including accepted answer-sheet parse warnings queued by questionnaire commands
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." - Destination facts are injected on
TargetPropertiesfor that run; the harness does not install detector overrides. Stdin, clipboard, and the prompt responder are not process-global: they live on theInputSourcesvalue for that run.
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.
Captured warnings
App::run renders framework warnings to stderr after the primary command
output, styled from stderr color capability on TargetProperties. TestHarness
reads them from the run result: each TestResult owns the warnings produced by
that run and exposes them through warnings() plus assertion helpers such as
assert_warning_contains(...). There is no thread-local warning collector.
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.
TargetProperties (standout-render)
Detection is TargetProperties::detect() at the crate edge. Convenience wrappers and App::run call it there, then pass the result into render_request. Tests do not call detect(); they construct TargetProperties or inject facts through TestHarness (terminal_width, with_color / no_color, color_scheme, icon_mode, ambiguous_width). Unset facts take fixed defaults — width: None, ColorMode::Dark, IconMode::Classic, AmbiguousWidth::Narrow — so $COLUMNS, $NERD_FONT, and the OS appearance setting cannot change an in-process run.
The detector override APIs are removed: set_terminal_width_detector, set_color_capability_detector, set_ambiguous_width_detector, set_theme_detector, set_icon_detector, DetectorGuard, and the public detect_* cluster they served.
There is no TTY detector: one existed, nothing in production ever read it, and it was removed rather than left as a seam that answers only about stdout (docs/adr/0022-delete-the-in-process-tty-seam.md). Terminal-dependent behavior is tested against a real process via TestHarness::run_process.
standout-input InputSources
Stdin, clipboard, and the prompt responder are arguments to input collection,
carried on InputSources.
Production App::run constructs them from the real process.
TestHarness constructs mocks and passes them into App::run_with.
StdinSource::new() / ClipboardSource::new() bind to those sources at
resolve time; handlers that resolve a chain themselves call
InputChain::resolve_from(matches, ctx.input_sources()).
#![allow(unused)] fn main() { use standout_input::{InputSources, MockStdin}; let sources = InputSources::from_process().with_stdin(MockStdin::piped("hello")); let value = chain.resolve_from(&matches, &sources)?; }
Handlers that need a source-local mock keep using
StdinSource::with_reader(MockStdin::piped(...)) as before.
Testing invocation-aware default commands
default_command_with reads the stdin terminal fact from InputSources, so TestHarness drives it with no extra wiring:
#![allow(unused)] fn main() { // Piped stdin resolves the naked invocation to the piped entry point. TestHarness::new() .piped_stdin("ship the docs\n") .run(&app, cli::command(), ["tdoo"]) .assert_stdout_contains("Added"); // A terminal resolves it to the interactive one. TestHarness::new() .interactive_stdin() .run(&app, cli::command(), ["tdoo"]) .assert_stdout_contains("Your Todos"); }
piped_stdin("") covers the piped-but-empty case: the resolver sees a pipe, not a terminal, because emptiness is only knowable by reading. Use it to assert that a receiving command's InputChain rejects empty input, rather than expecting resolution to route around it.
interactive_stdin() is required for the terminal branch — without it the harness inherits the real stdin, which is not a terminal under a test runner, so a naked invocation would take the piped branch and the test would pass or fail depending on how it was launched.
For the parse-only path, get_matches_from takes sources explicitly:
#![allow(unused)] fn main() { use standout_input::{InputSources, MockStdin}; let sources = InputSources::from_process().with_stdin(MockStdin::terminal()); match app.get_matches_from(cli::command(), ["tdoo"], &sources) { HelpResult::Matches(m) => assert_eq!(m.subcommand_name(), Some("list")), other => panic!("expected matches, got {other:?}"), } }
standout-input prompt responder
The .prompt_from(&sources) shortcut on every interactive source (InquireText, InquireSelect, TextPromptSource, EditorSource, …) consults the PromptResponder on [InputSources] before opening any real prompt. Put a ScriptedResponder on those sources — or use TestHarness::prompts(...) — to make wizard handlers testable in-process:
#![allow(unused)] fn main() { use std::sync::Arc; use standout_input::{InputSources, ScriptedResponder, PromptResponse}; let sources = InputSources::from_process().with_responder(Arc::new(ScriptedResponder::new([ PromptResponse::text("BadName!"), // rejected by validator PromptResponse::text("good-name"), // accepted on re-ask ]))); let name = TextPromptSource::new("Pack name: ").prompt_from(&sources)?; }
Most tests should reach for TestHarness::prompts(...) instead; handlers then call .prompt_from(ctx.input_sources()).
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
Env vars and cwd remain process-global. Parallel tests that mutate them will interfere with each other. Destination facts (width, color, color-scheme, icon mode) are injected on TargetProperties and no longer need #[serial] for detector reasons.
Use #[serial] from the serial_test crate (re-exported as standout_test::serial) on every in-process TestHarness::run test while those env/cwd overrides exist: serial_test only orders annotated tests against each other, so an unannotated run can race with one that mutates env or cwd. Input sources and warning capture no longer require #[serial]. Within a test binary, serial execution is automatic among annotated tests; 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) .ambiguous_width(standout::AmbiguousWidth::Narrow) .no_color() .run(&app(), command(), ["todo", "list"]); assert_snapshot!(result.stdout()); } }
Use TestHarness::ambiguous_width(AmbiguousWidth::Narrow) and
AmbiguousWidth::Wide to assert the same rendering fixture under both explicit
policies. The override crosses the same App/Renderer width seam as production
configuration and is restored when the TestResult drops.
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))); } }
Testing a delegated process failure
Use a direct typed-handler test for the adapter mapping, TestHarness for the
captured metadata, and one process test when exact OS status and stream bytes
are part of the application's contract:
#![allow(unused)] fn main() { let result = TestHarness::new().run(&app, command, ["myapp", "fetch"]); result.assert_error_kind(RunErrorKind::External); assert_eq!(result.exit_status().unwrap().code(), 128); assert_eq!(result.error(), Some("fatal: repository not found\n")); result.assert_stdout_eq(""); }
The harness does not perform final writes, so the process-level test remains
the proof that run() writes only the declared payload to stderr and exits with
the same status.
This path has no #[serial] requirement — nothing global is touched.
Asserting a compound artifact
For Output::Artifact, the harness observes the whole framework-owned
transaction: the bytes, what the application suggested, where the framework
actually wrote, the rendered report, and the typed failure when no destination
can be selected.
#![allow(unused)] fn main() { let dir = tempfile::tempdir().unwrap(); let out = dir.path().join("todos.csv"); let result = TestHarness::new().run( &app, command, ["myapp", "export", "--output-file-path", out.to_str().unwrap()], ); result.assert_success(); result.assert_artifact_bytes(b"id,title,done\n1,buy milk,false\n"); result.assert_artifact_suggested_destination("todos.csv"); // the app's suggestion result.assert_artifact_written_to(&out); // where it actually went result.assert_artifact_report_contains("Exported 1 todos"); }
assert_artifact_to_stdout() covers the allow_stdout() destination, and
artifact_report() returns the rendered (or, in structured mode, serialized)
report for deeper assertions. A write that cannot pick a destination — or that
fails — is a typed error the harness asserts like any other:
#![allow(unused)] fn main() { result.assert_error_kind(RunErrorKind::FinalWrite(OutputKind::Artifact)); assert!(result.artifact().is_none()); // a failed write reports nothing }
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 — typed handler adapter contract
- Output Modes — forcing deterministic output
- Introduction to Input — input sources and their mock variants
Styled Help
Standout can replace clap's built-in help with themed, template-driven output. Instead of clap's fixed format, your --help renders through the same MiniJinja + style-tag pipeline as the rest of your CLI.
This gives you bold headers, consistent alignment, and a "Learn More" section linking to help topics. For CLIs with many commands, you can organize subcommands into named groups with section headers, help text, and visual separators.
Help Handling
Help interception is on by default. An app that configures nothing renders
themed help; .help_handling(false) gives clap's own help back:
#![allow(unused)] fn main() { // Themed help, no call needed. App::builder().build()?; // Clap's own help instead. App::builder().help_handling(false).build()?; }
With help handling on, standout:
- Disables clap's default
helpsubcommand and registers its own (with--pagefor pager support), subject to the install policy below - Keeps clap's native
--help/-hflag, on purpose: clap's flag short-circuits argument validation, somyapp build --helprenders even when required arguments are missing - Intercepts all help requests and renders them through a MiniJinja template with style tags — the
helpword, which clap routes like any other subcommand, and clap'sDisplayHelp(from--help/-h, at root and subcommand level)
Every form that is available renders the same help, through the same template and theme — with one exception, which is about the form, not the entry point: --output reaches the help word but not the flags. myapp help --output text renders in text mode; myapp --help --output text renders in the app's output-mode fallback (Auto unless the app sets one) and the typed mode is ignored. The reason is where each form is answered: the word is a subcommand, so clap parses its line in full, globals included, while --help short-circuits inside clap before the parse completes — so there are no matches to read a mode from when its DisplayHelp is rendered.
Subcommand-level help (e.g. myapp build --help) also works, rendering that subcommand's help through standout.
Incompatible with the opt-out: command_groups and topics need help interception to render. Configuring either alongside .help_handling(false) returns a SetupError from build().
Whichever entry point you use
Help is answered the same way through both parse paths — run() / run_with() and get_matches_from(). Same install policy for the word, same interception of --help / -h, same rendering: an application's entry point is not a fact about what myapp help means.
The one thing the two paths cannot share is --page, because paging is a terminal side effect and only a printing entry point may perform it. run() hands the text to the pager; the capture APIs return it instead — run_with() marks it SuccessKind::PagedHelp, get_matches_from() returns HelpResult::PagedHelp — and leave the decision to you.
The help Word
--help and -h are flags: they are always available and can never collide with your data. A bare help is different — at the root of a CLI with no subcommands, a bare word is data. echo help, grep help, and ls help all treat it as such, and a tool whose positional is a revision range or a file name would be wrong to swallow it.
So standout only installs the word where it knows nothing else can claim it:
| Root shape | help word |
|---|---|
| Has subcommands | Installed — a bare word there is already a command |
| Flat, no positionals | Installed — nothing to collide with |
| Flat, with positionals | Opt-in only — see below |
For the third shape, only your application knows whether its positional domain excludes the word. Opt in with .help_word(true):
#![allow(unused)] fn main() { // `mytool <RANGE>` — a revision range is never the word "help". App::builder().help_word(true).build()?; }
Opting in accepts the cost: the literal word help can no longer reach the positional, and -- becomes the escape for it — mytool -- help passes the string through. Without the opt-in, --help / -h remain the only spelling, and they still render themed help.
help_word(true) only ever adds the word; it is not a way to suppress help on a CLI that has subcommands. It cannot be combined with .help_handling(false) — the word is standout's own subcommand, so build() returns a SetupError without interception.
On a flat CLI, the word describes a flat CLI
Clap's help is worded for a CLI with subcommands — "Print this message or the
help of the given subcommand(s)". On a flat CLI that sentence points at a
namespace that cannot exist, and the flat shape is exactly the one
help_word(true) serves. So the word describes the shape it is installed on:
| Root shape | help about |
|---|---|
| Has subcommands | Print this message or the help of the given subcommand(s) |
| Flat | Print this message |
A flat CLI also drops the COMMANDS section entirely when help would be its
only entry. The word is machinery standout installs, not part of your surface,
and a section listing nothing but the command that printed it is noise:
COMMANDS
help Print this message
Registered topics earn the section back, because help <topic> is then a real
destination and the word is how a reader reaches it. A root with commands of its
own always keeps its section, help included.
If your CLI already has a help
Where standout installs the word, the name is standout's. An application that claims it too — a clap subcommand called help (or aliased to it), or a registration whose first path segment is help (.command_with("help", …), .command_with("help.topic", …), a .commands(|g| g.group("help", …))) — is a configuration standout refuses rather than serves:
duplicate command: help — this application's clap `Command` declares `help` (as a
subcommand name or alias), and standout installs a `help` word of its own, since
help handling is on by default. Rename the application's command, or call
.help_handling(false) to keep the name (help is then clap's own, and
command_groups and topics become unavailable)
Each spelling is caught the moment it becomes visible: a registration under the root help fails build(), while a clap-declared one is only visible when your Command reaches a parse entry point, so it comes back as HelpResult::Error / DispatchResult::Error before anything is parsed. Neither reaches clap, whose answer to two subcommands of one name is a debug assertion — a panic on a configuration, which is what SetupError exists to prevent.
Standing down — letting your help win and rendering nothing itself — is deliberately not offered. myapp help would then run your handler while myapp --help rendered standout's themed help: one CLI answering the same question two ways.
Unaffected: a help deeper in the tree (myapp db help is yours, at a path the word is never installed on), and any root that never gets the word — a flat CLI with positionals and no .help_word(true), or any CLI built with .help_handling(false).
Why the word is reachable at all
On a flat CLI whose root arguments are required, an injected help subcommand used to be advertised in help output and impossible to run: clap validates the root's requirements before routing, so myapp help failed with "the following required arguments were not provided" instead of printing help.
The fix is a declaration, not a parser of standout's own. Where standout installs the word, it also sets clap's subcommand_negates_reqs, which suspends the root's requirements once a command is named — so myapp help routes to the word, while myapp on its own still reports its missing arguments and myapp <RANGE> still parses as data. The word's arguments (myapp help topics, myapp help --page, myapp help --output text) are clap's to parse, like any other subcommand's.
The cost is worth naming: subcommand_negates_reqs applies to your subcommands too, so a root that declares required arguments stops requiring them once any command is named. That is why standout sets it only where it installs the word, and never on a CLI that did not get one. See ADR-0018.
Short and Long Help
Clap gives a command a terse about and an optional full long_about, and its
convention is that -h shows the first while --help shows the second. Themed
help keeps that distinction:
| Invocation | Renders |
|---|---|
-h | about |
--help | long_about, falling back to about |
help | long_about, falling back to about — the spelled-out request reads like --help |
Standout has to recover the spelling itself: --help short-circuits inside clap
and arrives as a DisplayHelp error that names neither the flag that raised it
nor the command it was raised for. Both are recovered from a parse, not from a
scan of the argument list — the two flags are re-declared as ordinary global
arguments on a throwaway clone whose own help flag is disabled, and clap
answers. That keeps -- termination, --flag=value, short-option clusters, and
option-value consumption (-o h is not a help request) the parser's business,
per ADR-0018.
Rendering help yourself with render_help has no
invocation to classify, so it defaults to HelpLength::Short. Ask for the full
text with length:
#![allow(unused)] fn main() { let config = HelpConfig { length: HelpLength::Long, ..Default::default() }; }
What an Option Row Shows
Option rows carry the information clap surfaces about an argument — its value syntax, description, default, and accepted values:
OPTIONS
--staged Diff the staged changes
--threshold <RATIO> Move/rename similarity threshold
-c, --color <BOOL> Enable ANSI color
--output Output format
default: auto
possible values: auto, term, text, term-debug, json, yaml, xml, csv
--output-file-path Write output to file instead of stdout
The default and possible-value lines hang under the description column and
carry their own [default] and [values] tags, so a stylesheet can dim or
recolor them independently. Standout writes them as words rather than clap's
[default: auto] brackets: literal [ would have to be escaped through the
style-tag parser, and the emphasis belongs to the theme. Hidden possible values
(PossibleValue::hide) are left out.
Clap's own flags are rows like any other. The extractor reads the command
after clap builds it, which is when -h/--help and — for an application
that sets a version — -V/--version come into existence, so the page names
the flags it accepts instead of listing only what the application declared.
They sort last, after the application's own options.
Options that take values render their metavar alongside the spelling, using an
explicit value_name when present and clap's fallback display otherwise. Pure
presence flags such as ArgAction::SetTrue and SetFalse do not render a
metavar and do not show parser-derived true, false values, because those words
are not valid command-line values for the flag.
Positionals get their own section
Positionals render in an ARGUMENTS section ahead of OPTIONS, the way clap orders them, rather than filed among the flags:
ARGUMENTS
RANGE Git range to diff, e.g. main..HEAD
OPTIONS
--staged Diff the staged changes
A positional is listed under its value_name when it declares one, else its
argument id, and is tagged [metavar]. The two sections size their columns
independently, so a long flag name does not push the ARGUMENTS column out with
it.
Styling User-Provided Strings
When help interception is enabled, your clap about and help strings are rendered through standout's BBCode parser, so they can use any tag defined in your stylesheet:
#![allow(unused)] fn main() { #[command(name = "myapp", about = "[bold]myapp[/bold] — a small CLI")] struct Cli { /* ... */ } }
To emit a literal [ or ] in help text, escape it with a backslash: \[ and \]. Other backslashes (file paths, regex examples like \d+) pass through unchanged. To emit a literal \[, write \\[.
#![allow(unused)] fn main() { #[command(about = "Match pattern \\[regex: \\d+\\]")] // renders as: Match pattern [regex: \d+] }
Default Behavior
Without any group configuration, all subcommands appear in a single "Commands" section:
My application
USAGE
myapp <COMMAND>
COMMANDS
init Initialize the project
list List all items
delete Delete an item
config Manage configuration
OPTIONS
--output Output format
default: auto
possible values: auto, term, text, term-debug, json, yaml, xml, csv
-h, --help Print help
Command Groups
CLIs with many commands (20+) benefit from organized help. The CommandGroup struct lets you split subcommands into named sections:
#![allow(unused)] fn main() { use standout::cli::{App, CommandGroup}; App::builder() .command_groups(vec![ CommandGroup { title: "Commands".into(), help: None, commands: vec![ Some("init".into()), Some("create".into()), Some("list".into()), Some("search".into()), ], }, CommandGroup { title: "Per Pad(s)".into(), help: Some( "These commands accept one or more pad ids: <id> or ranges <id>-<id>\n\ ex: $ padz view 3 5 7-9 # views pads 3, 5, 7, 8 and 9".into() ), commands: vec![ Some("open".into()), Some("view".into()), Some("peek".into()), None, // blank line separator Some("pin".into()), Some("unpin".into()), None, Some("complete".into()), Some("reopen".into()), ], }, CommandGroup { title: "Misc".into(), help: None, commands: vec![ Some("completions".into()), Some("help".into()), Some("config".into()), ], }, ]) .build()?; }
This produces:
COMMANDS
init Initialize the store
create Create a new pad
list List pads
search Search pads
PER PAD(S)
These commands accept one or more pad ids: <id> or ranges <id>-<id>
ex: $ padz view 3 5 7-9 # views pads 3, 5, 7, 8 and 9
open Open a pad in the editor
view View one or more pads
peek Peek at pad content previews
pin Pin one or more pads
unpin Unpin one or more pads
complete Mark pads as done
reopen Reopen pads
MISC
completions Generate shell completions
help Print this message
config Get or set configuration
Blank Line Separators
Use None entries in the commands vec to insert blank lines within a group. This creates visual sub-clusters without introducing nested group hierarchy:
#![allow(unused)] fn main() { commands: vec![ Some("open".into()), Some("view".into()), None, // blank line Some("pin".into()), Some("unpin".into()), ], }
Ungrouped Commands
Commands that exist in your clap definition but don't appear in any CommandGroup are automatically appended to an "Other" section. This is a safety net: if you add a new subcommand but forget to add it to the group config, it still shows up in help. Silently hiding commands would be worse than slightly messy help.
Group Help Text
Each group can include optional help text displayed between the section header and the command list. Use this to explain shared arguments, conventions, or usage patterns that apply to all commands in the group.
Standalone Rendering
You can render help without App using render_help directly:
#![allow(unused)] fn main() { use standout::cli::{render_help, CommandGroup, HelpConfig}; use standout::OutputMode; let config = HelpConfig { output_mode: Some(OutputMode::Text), command_groups: Some(vec![ CommandGroup { title: "Main".into(), help: None, commands: vec![Some("init".into()), Some("list".into())], }, ]), ..Default::default() }; let output = render_help(&cmd, Some(config))?; println!("{}", output); }
Validation
The group config is static — it should be validated at test time, not when a user runs --help. Use validate_command_groups in a #[test]:
#![allow(unused)] fn main() { use standout::cli::{validate_command_groups, CommandGroup}; use clap::CommandFactory; #[test] fn test_help_groups_match_commands() { let cmd = Cli::command(); let groups = my_command_groups(); validate_command_groups(&cmd, &groups).unwrap(); } }
What it checks:
- Phantom reference — a group names a command that doesn't exist in the clap definition (catches typos and stale configs)
What it allows:
- Ungrouped commands — commands not in any group are OK; they auto-append to "Other" at render time
This follows the same pattern as app.verify_command(&cmd) for handler/argument validation.
Themes
Help rendering uses a theme to style output. The default theme applies bold to headers and command names:
#![allow(unused)] fn main() { pub fn default_help_theme() -> Theme { Theme::new() .add("header", Style::new().bold()) // COMMANDS, OPTIONS, etc. .add("item", Style::new().bold()) // Command/option names .add("metavar", Style::new().bold()) // Argument names in ARGUMENTS .add("desc", Style::new()) // Descriptions .add("default", Style::new().dim()) // "default: auto" .add("values", Style::new().dim()) // "possible values: ..." .add("usage", Style::new()) // Usage line .add("example", Style::new()) // Examples section .add("about", Style::new()) // About text } }
A configured theme overlays this default rather than replacing it: per style name, an entry the configured theme defines wins, and every tag it leaves out keeps its default styling. Restyle only what you mean to change:
#![allow(unused)] fn main() { let config = HelpConfig { theme: Some( Theme::new() .add("header", Style::new().bold().cyan()) .add("item", Style::new().green()) ), ..Default::default() }; }
Or when using AppBuilder, set the theme with .theme() — it applies to both help and command output. Because of the overlay, an application theme does not need to define the help vocabulary at all: a theme that only declares the app's own output styles leaves help rendered entirely by the default help theme.
Custom Templates
The default template renders about, usage, grouped commands, options, examples, and learn-more topics. Override it via HelpConfig::template:
#![allow(unused)] fn main() { let config = HelpConfig { template: Some(my_custom_template.into()), ..Default::default() }; }
Template Variables
The template receives a HelpData struct with these fields:
| Variable | Type | Description |
|---|---|---|
about | String | The command's about, or its long_about — see Short and long help |
usage | String | Usage line (without "Usage: " prefix) |
subcommands | Vec | Command groups (each with title, help, items) |
subcommands_width | usize | Width of the COMMANDS name column |
arguments | Vec | Positional groups (each with title, help, items) |
arguments_width | usize | Width of the ARGUMENTS name column |
options | Vec | Flag groups (each with title, help, items) |
options_width | usize | Width of the OPTIONS name column |
examples | String | Examples text |
learn_more | Vec | Topic list items (each with name, title) |
learn_more_width | usize | Width of the LEARN MORE name column |
Alignment is the template's job
There are no padding fields. A row aligns itself by padding its name to its
section's width with pad_right, one of standout-render's tabular
filters:
{%- set opt_label = "[item]" ~ opt.name ~ "[/item]" -%}
{%- if opt.value_name %}
{%- set opt_label = opt_label ~ " [metavar]" ~ opt.value_name ~ "[/metavar]" -%}
{%- endif %}
{{ opt_label | pad_right(options_width) }} [desc]{{ opt.help }}[/desc]
Two properties of that filter are the point of doing it this way. It measures display width, so a CJK name counts the terminal columns it really occupies and the style tags around it count for nothing — byte length gets both wrong. And it never truncates: a name wider than the column keeps its full text and its separator, which is the failure the fixed-width column used to produce.
The widths themselves are resolved from the data, as a Width::Bounded column
that is at least 12 columns wide and otherwise as wide as the section's longest
name. One width per section, not per group, is what keeps a grouped command
list aligned down the whole page instead of realigning at each header.
Group Fields in Templates
Each subcommand group has:
group.title— section header (rendered asgroup.title | upperin the default template)group.help— optional help text for the groupgroup.items— list of command entries
Each command entry has:
cmd.name— command namecmd.about— command descriptioncmd.separator— true for blank-line separator entries
Each argument and option entry has:
opt.name—range/RANGEfor a positional,-o, --outputfor a flagopt.value_name— rendered value syntax for a value-taking flag, such as<MODE>or[<PATH>]; empty for positionals and presence flagsopt.help— descriptionopt.short/opt.long— the flag's spellings (both empty for a positional)opt.default— the declared default, or nothingopt.possible_values— the selectable values, hidden ones left out
Example Custom Template
[about]{{ about }}[/about]
[header]USAGE[/header]
[usage]{{ usage }}[/usage]
{%- for group in subcommands %}
[header]{{ group.title | upper }}[/header]
{%- if group.help %}
[desc]{{ group.help }}[/desc]
{% endif %}
{%- for cmd in group.items %}
{%- if cmd.separator %}
{%- else %}
{{ ("[item]" ~ cmd.name ~ "[/item]") | pad_right(subcommands_width) }} [desc]{{ cmd.about }}[/desc]
{%- endif %}
{%- endfor %}
{%- endfor %}
Style tags like [header]...[/header] are resolved against the theme. Unknown tags pass through or show a ? indicator depending on the output mode.
Output Modes
The help word respects the --output flag, but only as far as styling. Help is always the rendered template; the mode decides what happens to its style tags — applied in Term, stripped in Text, left visible as [header]…[/header] in TermDebug:
myapp help --output text
--help / -h do not take the flag with them (see above): they render in the app's output-mode fallback, which is Auto — styling for the terminal it finds — unless the app set another one. Spell the mode with the word when you need it.
The structured modes (json, yaml, xml, csv) strip the tags exactly as Text does. None of them serializes HelpData, so help is themed prose in every mode, not a machine-readable document. If you need help as data, render it yourself: HelpData is what a custom template receives, and a template that emits JSON is the seam for it.
The Topics System
Standout provides a dedicated help topics system because command help (--help) is a poor place for conceptual documentation.
Arguments and flags describe mechanics, but complex applications need longer-form guides for concepts—configuration formats, authentication flows, or troubleshooting. The Topics system integrates these directly into your CLI, accessible via myapp help <topic>, keeping users in the terminal.
What Topics Are For
Command help describes flags and arguments. Topics explain broader concepts:
- Configuration file format
- Authentication setup
- Workflow guides
- Troubleshooting
myapp help # Shows commands + available topics
myapp help auth # Shows the "auth" topic
myapp help config-format # Shows the "config-format" topic
myapp help auth --page # Shows topic in a pager
The Topic Struct
#![allow(unused)] fn main() { pub struct Topic { pub title: String, // Display title: "Authentication Setup" pub content: String, // Full content pub topic_type: TopicType, // Text or Markdown pub name: String, // URL-safe slug: "authentication-setup" } pub enum TopicType { Text, Markdown, Unknown, } }
The name is a URL-safe slug used in help <name>. If not provided, it's auto-generated from the title:
- "Hello World" →
hello-world - "Café Setup" →
cafe-setup
Adding Topics
Programmatically
#![allow(unused)] fn main() { use standout::topics::{Topic, TopicType}; let topic = Topic::new( "Configuration Format", "The config file uses YAML format...", TopicType::Text, None, // Auto-generate name from title ); App::builder() .add_topic(topic) .build()? }
From a Directory
#![allow(unused)] fn main() { App::builder() .topics_dir("docs/topics") .build()? }
Standout scans the directory for .txt and .md files. File format:
Configuration Format
The config file uses YAML format.
Place it in ~/.myapp/config.yaml.
Supported keys:
- theme: color theme name
- output: default output mode
First non-blank line becomes the title. Everything after becomes content. The filename (without extension) becomes the topic name.
Directory structure:
docs/topics/
config-format.txt # Topic name: config-format
authentication.md # Topic name: authentication
getting-started.txt # Topic name: getting-started
TopicRegistry
TopicRegistry stores and retrieves topics:
#![allow(unused)] fn main() { let mut registry = TopicRegistry::new(); registry.add_topic(topic1); registry.add_topic(topic2); // Retrieve if let Some(topic) = registry.get_topic("config-format") { println!("{}", topic.content); } // List all (sorted by name) for topic in registry.list_topics() { println!("{}: {}", topic.name, topic.title); } }
Duplicate topic names cause a panic—each name must be unique.
Help Integration
Topics automatically appear in help output:
myapp help
USAGE
myapp <COMMAND>
COMMANDS
list List items
add Add an item
config Manage configuration
LEARN MORE
auth Authentication Setup
config-format Configuration Format
getting-started Getting Started
The "LEARN MORE" section lists all registered topics. Users run myapp help <topic-name> to view the full content.
Pager Support
For long topics, the --page flag displays content through a pager:
myapp help getting-started --page
Standout tries pagers in order:
$PAGERenvironment variablelessmore- Falls back to printing directly if none available
Rendering Topics
For custom topic rendering outside the help system:
#![allow(unused)] fn main() { use standout::topics::{render_topic, render_topics_list, TopicRenderConfig}; // Render single topic let output = render_topic(&topic, None)?; // Render list of all topics let list = render_topics_list(®istry, "myapp help <topic>", None)?; // With custom config let config = TopicRenderConfig { theme: Some(my_theme), output_mode: Some(OutputMode::Text), ..Default::default() }; let output = render_topic(&topic, Some(config))?; }
Topic Templates
Topics are rendered through templates with style tags:
Single topic template:
[header]{{ title | upper }}[/header]
{{ content }}
Topic list template:
[about]Available Topics[/about]
[header]USAGE[/header]
[usage]{{ usage }}[/usage]
[header]TOPICS[/header]
{%- for topic in topics %}
{{ ("[item]" ~ topic.name ~ "[/item]") | pad_right(name_width) }} [desc]{{ topic.title }}[/desc]
{%- endfor %}
Override via TopicRenderConfig:
#![allow(unused)] fn main() { let config = TopicRenderConfig { topic_template: Some(my_template.into()), list_template: Some(my_list_template.into()), ..Default::default() }; }
Markdown Topics
Topics with .md extension or TopicType::Markdown can contain Markdown formatting. Standout renders Markdown appropriately for the terminal when displaying.
# Getting Started
Install the application:
cargo install myapp
Then create a configuration file...
The topic type is inferred from file extension when loading from directories.
List Views
Most CLI commands that print a collection share the same shape: an optional
intro line, the items, an optional list of messages (warnings, info), and an
optional summary of how many items were filtered out of a larger total. The
list_view builder in standout::views captures that shape once so handlers
stop reassembling it by hand, and #[dispatch(list_view)] wires the result to
the framework's built-in list template without a project-owned template file.
Reach for it whenever a handler returns "here are N things" — a list,
search, or status command — and the result needs more than a bare Vec<T>:
an intro line, a filter summary, or per-item messages.
Where it lives
list_view, ListViewBuilder, and ListViewResult live in
standout::views, a module (pub mod views) rather than a crate-root
re-export:
#![allow(unused)] fn main() { use standout::views::{list_view, ListViewBuilder, ListViewResult}; }
The builder
#![allow(unused)] fn main() { pub fn list_view<T>(items: impl IntoIterator<Item = T>) -> ListViewBuilder<T>; }
ListViewBuilder<T> accepts items in any order and returns itself, so calls
chain:
| Method | Effect |
|---|---|
.intro(text) | A line shown before the items |
.ending(text) | A line shown after the items |
.message(level, text) | Attaches a Message at the given MessageLevel |
.info(text) | Shortcut for .message(MessageLevel::Info, text) |
.success(text) | Shortcut for .message(MessageLevel::Success, text) |
.warning(text) | Shortcut for .message(MessageLevel::Warning, text) |
.error(text) | Shortcut for .message(MessageLevel::Error, text) |
.total_count(n) | Records the unfiltered total, for a "showing X of Y" summary |
.filter_summary(text) | A human-readable description of the active filter |
.tabular_spec(spec) | Attaches a TabularSpec directly (usually left to #[dispatch(list_view, item_type = "...")] instead) |
.build() | Consumes the builder and returns a ListViewResult<T> |
The result
#![allow(unused)] fn main() { pub struct ListViewResult<T> { pub items: Vec<T>, pub intro: Option<String>, pub ending: Option<String>, pub messages: Vec<Message>, pub total_count: Option<usize>, pub filter_summary: Option<String>, pub tabular_spec: Option<TabularSpec>, } }
ListViewResult<T> implements Serialize (fields that are None or empty
are skipped, so --output json stays uncluttered), Default (an empty list
with every optional field unset), and carries .is_empty() and .len()
methods that read items directly.
Because it derives Serialize rather than requiring one, ListViewResult<T>
is itself a valid handler return type: a handler can return
HandlerResult<ListViewResult<Task>> and wrap it in Output::Render like any
other structured output. See
Handler Contract for the
Output enum and the render pipeline it feeds.
Connecting to #[dispatch(list_view)]
The Dispatch derive has a list_view variant attribute that does two
things to a variant's handler:
- It sets the command's template to the framework-provided
standout/list-viewtemplate, unless the variant also sets#[dispatch(template_name = "...")], in which case that name wins. - If
item_type = "..."names a type implementingTabular, the derive wraps the handler so that, on a successfulOutput::Render(list_view_result), it stampslist_view_result.tabular_specwith<ItemType as Tabular>::tabular_spec()before rendering. The handler itself never has to know about the column layout.
Handlers under #[dispatch(list_view)] can be either the two-argument shape
(fn(&ArgMatches, &CommandContext) -> HandlerResult<ListViewResult<T>>) or,
with #[dispatch(simple)] added, a single-argument shape
(fn(&ArgMatches) -> HandlerResult<ListViewResult<T>>). Both are wrapped the
same way.
Worked example
#![allow(unused)] fn main() { use clap::{ArgMatches, Subcommand}; use serde::Serialize; use standout::cli::{CommandContext, Dispatch, HandlerResult, Output}; use standout::views::list_view; use standout::{Tabular, TabularRow}; #[derive(Serialize, Tabular, TabularRow, Clone)] struct Task { #[col(width = 5)] id: u32, #[col(width = 20)] name: String, } mod handlers { use super::*; pub fn list( _matches: &ArgMatches, _ctx: &CommandContext, ) -> HandlerResult<standout::views::ListViewResult<Task>> { let tasks = vec![Task { id: 1, name: "Write docs".to_string(), }]; Ok(Output::Render(list_view(tasks).build())) } } #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] enum Commands { #[dispatch(list_view, item_type = "Task")] List, } }
Running list renders the framework's list template with Task's column
widths already attached; --output json serializes the same
ListViewResult<Task>, tabular_spec included, with no template involved.
An empty items vec renders the template's "No items found" branch rather
than an empty table.
Disabling the framework template
App::builder().include_framework_templates(false) refuses to build if a
command names standout/list-view (or any other framework template) without
supplying a replacement — a project can opt out of the built-in list layout,
but only by registering its own template under the same name.
Seeker: Filtering, Ordering, and Query Strings
Commands that list something almost always grow filter flags: --status,
--name-contains, --priority-gte, --order-by. Seeker is the crate behind
standout::seeker that gives those filters a single implementation: a
Query built either programmatically or by parsing a flat set of key/value
pairs (typically --filter key=value flags, or a raw query string), run
against a slice of items through a per-type accessor function.
Reach for it when a command needs to filter, sort, or paginate an in-memory
collection and you want the filter vocabulary (eq, contains, gte,
before, in, ordering, limit/offset) to be consistent across commands
instead of each one inventing its own flags.
Where it lives
#![allow(unused)] fn main() { use standout::seeker::{ Dir, Op, OrderBy, Query, SeekType, SeekerEnum, SeekerSchema, Seekable, Value, parse_query, }; use standout::Seekable; // #[derive(Seekable)] }
standout::seeker is a re-export of the standout-seeker crate
(pub use standout_seeker as seeker;); the derive macro is
standout_macros::Seekable, re-exported as standout::Seekable.
Deriving Seekable
#[derive(Seekable)] on a struct with named fields generates:
- An implementation of the
Seekabletrait:seeker_field_value(&self, field: &str) -> Value<'_>, the trait's one required method. TheSelf::accessor(item, field)functionQuery::filterand friends expect is a provided method on the trait, so it comes with any implementation, derived or written by hand. - An implementation of
SeekerSchema, providingfield_type(field)andfield_names()— this is whatparse_queryuses to validate a query string against the struct's actual fields. It does not provideresolve_enum_variant(field, variant), which keeps the trait's defaultNone; see Enum fields for what that costs. - One
pub constper seekable field, upper-cased (NAME,CREATED_AT), so callers writeTask::PRIORITYinstead of the string literal"priority".
Each field opts in with a #[seek(...)] attribute; unannotated fields are
skipped entirely (not queryable, no constant, absent from field_names()).
#[seek(...)] key | Effect |
|---|---|
String / string | Field type is SeekType::String |
Number / number | Field type is SeekType::Number |
Timestamp / timestamp | Field type is SeekType::Timestamp (the field type must implement SeekerTimestamp; i64 and u64 do out of the box, read as milliseconds) |
Enum / enumeration | Field type is SeekType::Enum (the field type must implement SeekerEnum) |
Bool / boolean / bool | Field type is SeekType::Bool |
ty = "string" (etc.) | Same five types, spelled as a string literal instead of a bare identifier |
skip | Field is not queryable and gets no constant |
rename = "name" | The query-facing field name differs from the Rust field name |
String/Number/etc. and rename/ty combine on the same attribute:
#[seek(String, rename = "title")]. A field with no #[seek(...)] attribute
at all is treated the same as #[seek(skip)].
Building a Query by hand
#![allow(unused)] fn main() { let query = Query::new() .and_gte(Task::PRIORITY, 4i32) .not_eq(Task::DONE, true) .order_desc(Task::PRIORITY) .limit(20) .build(); let results = query.filter(&tasks, Task::accessor); }
Query holds three clause groups — and, or, not — combined with fixed
semantics: an item matches when all and clauses match, and (any or
clause matches, or there are none), and no not clause matches. Each group
builds with .and(field, op, value) / .or(...) / .not(...), or a
per-operator shortcut (.and_eq, .and_gt, .and_contains,
.and_before, .and_in, and their or_/not_ counterparts). .order_by,
.order_asc, .order_desc, .limit, and .offset configure the rest.
.filter returns matching references in order; .filter_cloned,
.filter_mut, .count, .any, .all, .find, and .position cover the
other common shapes.
The query string grammar
parse_query::<S>(pairs) turns an ordered sequence of (key, value) string
pairs into a Query, validating every clause field and operator against S: SeekerSchema. This is the shape a --filter flag or a raw query string
typically produces.
Field clauses — a key is either a bare field name (using the field's
default operator: Eq for everything except Bool, which defaults to Is)
or field-operator:
name=docs # name eq "docs"
name-contains=docs # name contains "docs"
priority-gte=4 # priority >= 4
created-at-before=2024-01-01
status-in=1,2 # enum field, comma-separated discriminants
# (variant names too, on a hand-written schema)
done # bare boolean flag => done eq true
Recognized operators (case-insensitive) and their aliases:
eq, ne/neq, gt, gte, lt, lte, startswith/prefix,
endswith/suffix, contains, regex/re/match, before, after,
in, is. A compound field name like created-at still parses correctly
when combined with an operator (created-at-before), because only the last
hyphen-separated segment is checked against the operator list.
Group markers — the bare keys AND, OR, and NOT (case-insensitive)
switch which clause group subsequent pairs join, starting from AND:
name-contains=a&OR&name-contains=b&NOT&done=true
Ordering, limit, offset — reserved keys, not field clauses:
order=priority-desc # also: orderby, order-by, sort
limit=10
offset=5 # also: skip
order's value is field (ascending) or field-asc/field-desc. The
ordering field is not checked against the schema: parse_query reads the
reserved order/sort key before it consults S::field_type, so
order=does-not-exist parses without error and then sorts every item on
Value::None, leaving the input order. Validate an ordering field yourself
against S::field_names() if a typo there should be an error.
Value parsing per field type:
- String: taken verbatim, unless the operator is
regex(compiled as a regular expression). - Number: tried as
i64, thenu64, thenf64. - Timestamp: a Unix timestamp in milliseconds,
YYYY-MM-DD, an ISO datetime (YYYY-MM-DDTHH:MM:SS[.fff]Z), or a bare four-digit year. - Enum: a numeric discriminant, or a variant name resolved through
SeekerSchema::resolve_enum_variant;inaccepts a comma-separated list of either. See Enum fields — a derived schema takes discriminants only. - Bool:
true/1/yes/onorfalse/0/no/off(case-insensitive); an empty value defaults totrue.
An unknown clause field, an operator invalid for the field's type
(name-gt, since strings don't support Gt), or an unparsable value each
produce a ParseError naming the field and, for unknown fields, the schema's
actual field_names(). The reserved order/sort key is the exception noted
above.
Enum fields
SeekerSchema::resolve_enum_variant(field, variant) is what turns
status=pending into a discriminant, and it defaults to returning None.
#[derive(Seekable)] does not override it, so a derived schema matches enum
fields on numeric discriminants only — status=pending fails with a parse
error, status=0 works. Because the derive owns the whole SeekerSchema
implementation, a second impl cannot add the method beside it.
To match on variant names, drop the derive for that struct and write both
traits by hand — Seekable for the values, SeekerSchema for the schema plus
the variant mapping. You give up the generated field constants along with it — accessor still
comes from the trait:
#![allow(unused)] fn main() { #[derive(Clone, Copy)] enum Status { Pending = 0, Active = 1, } impl SeekerEnum for Status { fn seeker_discriminant(&self) -> u32 { *self as u32 } } struct Task { name: String, status: Status, } impl Seekable for Task { fn seeker_field_value(&self, field: &str) -> Value<'_> { match field { "name" => Value::String(&self.name), "status" => Value::Enum(self.status.seeker_discriminant()), _ => Value::None, } } } impl SeekerSchema for Task { fn field_type(field: &str) -> Option<SeekType> { match field { "name" => Some(SeekType::String), "status" => Some(SeekType::Enum), _ => None, } } fn field_names() -> &'static [&'static str] { &["name", "status"] } fn resolve_enum_variant(field: &str, variant: &str) -> Option<u32> { match (field, variant) { ("status", "pending") => Some(Status::Pending as u32), ("status", "active") => Some(Status::Active as u32), _ => None, } } } }
parse_query::<Task> now accepts status=pending and status-in=pending,active
as well as the discriminants.
Example
#![allow(unused)] fn main() { #[derive(Seekable)] struct Task { #[seek(String)] name: String, #[seek(Number)] priority: i32, #[seek(Bool)] done: bool, } let pairs = vec![ ("priority-gte".to_string(), "4".to_string()), ("NOT".to_string(), "".to_string()), ("done".to_string(), "true".to_string()), ]; let query = parse_query::<Task>(pairs)?; let results = query.filter(&tasks, Task::accessor); }
Passthrough Commands
Every other way of registering a command — the Dispatch derive,
command_with, a #[handler] function — assumes the handler hands back
something serializable that Standout can render or serialize. A passthrough
command is the one registration shape without that assumption: the handler
writes its own bytes and returns Result<(), anyhow::Error>. ADR-0032 keeps
it as a secondary path for that reason: nothing else on the registration axis
accepts a signature with no serializable output and no render.
Reach for it when a command's job is genuinely "run this external process and let it own the terminal" — streaming a subprocess's output live, driving an interactive prompt library that writes ANSI itself, or wrapping a tool that already produces its own formatted output — not for handlers that happen to print instead of returning data.
API
#![allow(unused)] fn main() { impl AppBuilder { pub fn command_passthrough<F>(self, path: &str, handler: F) -> Result<Self, SetupError> where F: FnMut(&ArgMatches, &CommandContext) -> Result<(), anyhow::Error> + 'static; } }
For a command declared inside .commands(|g| ...), GroupBuilder has the
matching entry point:
#![allow(unused)] fn main() { impl GroupBuilder { pub fn passthrough<F>(self, name: &str, handler: F) -> Self where F: FnMut(&ArgMatches, &CommandContext) -> Result<(), anyhow::Error> + 'static; } }
Both take the same closure shape and register the command with no template: there is nothing for a template to render.
What it does not get
A passthrough command has no Output enum to return, so it gets none of the
things that come from having one:
- No template. The command is registered with its template reference set to "absent, silently" — there is no configured or conventional template to resolve, by design, not by omission.
- No render pass, so no theme, no style tags, no
Tabularcolumns. - No structured output modes.
Output::Renderis what--output json(andyaml/xml/csv) serializes; a passthrough handler never produces anOutputvalue, so those modes have nothing to act on. - No post-dispatch hooks. The passthrough dispatch closure is handed the
command's
Hooksand ignores them, so nothing runs between the handler returning and the empty result leaving dispatch.
App-level hooks registered for the command's path do still run, because they
sit outside the dispatch closure: a pre-dispatch hook runs before the handler
as it would for any command, and a post-output hook runs after it, receiving
RenderedOutput::Silent. A post-output hook attached to a passthrough command
is therefore not inert — it observes and can act, it just has no rendered text
to transform.
Concretely, under --output json (or any other output mode) a passthrough
command runs exactly the same as under the default: the dispatch closure
backing command_passthrough/passthrough takes the output mode as a
parameter and ignores it. Standout's own output-writing pipeline resolves the
command to an empty string every time — whatever the handler wrote, it wrote
directly (to stdout, stderr, a file, wherever), outside that pipeline, and
--output has no way to reach it.
What the handler is responsible for
Because nothing downstream will format or emit anything on the handler's behalf, the handler owns:
- Writing everything it wants seen — to stdout, stderr, or elsewhere.
- Its own error reporting for anything it prints before returning
Err(...); theanyhow::Errorbecomes the command's failure, but text already written stays written. - Respecting (or explicitly ignoring) the user's terminal — colors, width, paging — since none of the framework's rendering machinery runs.
Worked example
#![allow(unused)] fn main() { use anyhow::Context; use clap::ArgMatches; use standout::cli::{App, CommandContext}; use std::process::Command; fn run_migrations(_matches: &ArgMatches, _ctx: &CommandContext) -> Result<(), anyhow::Error> { let status = Command::new("./migrate.sh") .status() .context("failed to launch migrate.sh")?; if !status.success() { anyhow::bail!("migrate.sh exited with {status}"); } Ok(()) } let app = App::builder() .command_passthrough("migrate", run_migrations)? .build()?; }
migrate.sh's own stdout and stderr reach the terminal unchanged; Standout
neither captures nor reformats them.
When not to use it
The blessed path is a #[handler] function returning Result<T, E>, wrapped
in Output::Render and rendered through a template — see
Handler Contract. Reach for
that whenever the command has any data a caller might want as JSON, any
output a template could format, or any reason to support --output. Use
passthrough only when the handler's whole job is to hand control to
something else that already owns its own bytes.
Output Piping
standout-pipe provides a way to send your CLI's rendered output to external commands. This enables post-processing workflows like filtering with jq, logging with tee, or copying to the clipboard—without polluting your handler logic.
Why Piping?
Shell commands excel at composition: ls | grep foo | head -5. Your CLI's output should participate in this ecosystem, but piping logic doesn't belong in handlers:
- Separation of concerns: Handlers produce data, piping is an output concern
- User choice: Let users decide what to do with output
- Testability: Handler adapters keep returning data through an explicit seam
Standout's piping integrates as a post-output hook, running after rendering completes. Your handler and template are unchanged—piping is purely additive.
Three Modes
Piping has three modes, each for different use cases:
| Mode | Returns | Use Case |
|---|---|---|
| Passthrough | Original output | Side effects (logging, clipboard) while still displaying output |
| Capture | Command's stdout | Filters (jq, grep, sort) that transform output |
| Consume | Empty string | Clipboard-only, no terminal display |
Passthrough Mode
The output goes to the command's stdin, but your original output is preserved:
#![allow(unused)] fn main() { .pipe_to("tee /tmp/output.log") }
Use this when you want both: display the output and send it somewhere else.
Capture Mode
The command's stdout becomes the new output:
#![allow(unused)] fn main() { .pipe_through("jq '.items[]'") }
Use this for filters that transform output. Whatever jq prints is what the user sees.
Consume Mode
The output goes to the command, and nothing is printed:
#![allow(unused)] fn main() { .pipe_to_clipboard() // Uses pbcopy/xclip depending on platform }
Use this when piping is the final destination.
Quick Start
The simplest integration uses the derive macro:
#![allow(unused)] fn main() { use standout::cli::Dispatch; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { /// List items, filtered through jq #[dispatch(pipe_through = "jq '.items'")] List, /// Export to clipboard #[dispatch(pipe_to_clipboard)] Export, } }
Or use the builder API for more control. GroupBuilder::command_with takes a
function shaped FnMut(&ArgMatches, &CommandContext) -> HandlerResult<T>,
which is the wrapper #[handler] generates (list__handler), not the
annotated function (list) itself:
#![allow(unused)] fn main() { let app = App::builder() .commands(|g| { g.command_with("list", handlers::list__handler, |cfg| { cfg.template_name("list") .pipe_through("jq '.items'") }) }) .build()?; }
API Reference
Macro Attributes
| Attribute | Mode | Example |
|---|---|---|
pipe_to = "cmd" | Passthrough | #[dispatch(pipe_to = "tee log.txt")] |
pipe_through = "cmd" | Capture | #[dispatch(pipe_through = "jq .data")] |
pipe_to_clipboard | Consume | #[dispatch(pipe_to_clipboard)] |
Builder Methods
#![allow(unused)] fn main() { // Passthrough: run command, return original output .pipe_to("tee /tmp/output.log") // Capture: use command's stdout as new output .pipe_through("jq '.items[]'") // Clipboard (platform-aware, consume mode) .pipe_to_clipboard() // With custom timeout (default is 30 seconds) .pipe_to_with_timeout("slow-command", Duration::from_secs(120)) .pipe_through_with_timeout("jq .", Duration::from_secs(60)) // Custom PipeTarget implementation .pipe_with(MyCustomPipe::new()) }
Low-Level API
For standalone use without the framework:
#![allow(unused)] fn main() { use standout_pipe::{SimplePipe, PipeMode, PipeTarget}; // Create a pipe let pipe = SimplePipe::new("jq '.items'") .capture() // Use command's output .with_timeout(Duration::from_secs(30)); // Execute let output = pipe.pipe("{ \"items\": [1,2,3] }")?; // output = "[1, 2, 3]" }
Chaining Pipes
Multiple pipes execute in sequence:
#![allow(unused)] fn main() { .pipe_through("jq '.items'") // First: extract items .pipe_to("tee /tmp/items.json") // Second: log to file (passthrough) }
Order matters: the second pipe receives the first pipe's output.
Platform-Specific Clipboard
pipe_to_clipboard() automatically selects the right command:
| Platform | Command |
|---|---|
| macOS | pbcopy |
| Linux | xclip -selection clipboard |
| Other | Error (use pipe_to with explicit command) |
If the platform isn't supported, the hook returns an error. Use pipe_to("your-clipboard-cmd") for unsupported platforms.
Error Handling
Pipe errors propagate as hook errors:
#![allow(unused)] fn main() { // Command failed // Error: Command `jq` failed with status 1 // Timeout // Error: Command `slow-process` timed out after 30s }
The error includes the command name for debugging when multiple pipes are chained.
Custom Pipe Targets
Implement PipeTarget for custom processing:
#![allow(unused)] fn main() { use standout_pipe::{PipeTarget, PipeError}; struct UppercasePipe; impl PipeTarget for UppercasePipe { fn pipe(&self, input: &str) -> Result<String, PipeError> { Ok(input.to_uppercase()) } } // Use it .pipe_with(UppercasePipe) }
This is useful for transformations that don't need a shell command.
ANSI Code Handling
Piped content is always plain text. This matches standard shell behavior where command | other_command receives unformatted output because stdout is not a TTY.
When you pipe output:
- The piped content has all ANSI escape codes stripped automatically
- Terminal display still shows rich formatting (colors, bold, etc.)
- Clipboard operations receive clean, pasteable text
#![allow(unused)] fn main() { // The "report" template (registered separately) renders styled output: // "[bold]{{ title }}[/bold]: [green]{{ count }}[/green]" cfg.template_name("report") .pipe_through("jq .") // Terminal sees: "\x1b[1mReport\x1b[0m: \x1b[32m42\x1b[0m" (formatted) // jq receives: "Report: 42" (plain text) }
This is implemented using the framework's two-pass rendering:
- Template engine produces output with
[style]...[/style]tags apply_style_tagsis called twice: once with ANSI codes for terminal, once stripped for piping
Custom pipe targets also receive plain text via the PipeTarget::pipe(&self, input: &str) method.
Limitations
Text output only: Piping only operates on RenderedOutput::Text. Binary and silent outputs pass through unchanged.
Memory buffering: The entire output is buffered in memory before and after piping. For multi-megabyte outputs, consider streaming alternatives.
Shell execution: Commands run through sh -c (Unix) or cmd /C (Windows). Be careful when constructing commands from untrusted input—see Security below.
Security Considerations
Commands are passed to the shell, so constructing them from user input requires care:
#![allow(unused)] fn main() { // DANGEROUS if user_input is untrusted: .pipe_through(&format!("grep {}", user_input)) // User could pass: "; rm -rf /" // SAFE: use fixed commands .pipe_through("grep pattern") // Or validate/sanitize input first }
This is a general shell injection concern, not specific to standout-pipe. If you need to pass user input to commands, sanitize it or use a command that accepts arguments safely.
Integration with Hooks
Piping runs as a post-output hook, after all rendering is complete:
Handler → Post-dispatch → Render → Post-output (piping here) → Final Output
You can combine piping with other post-output hooks:
#![allow(unused)] fn main() { .post_output(add_footer) // Runs first .pipe_through("jq .") // Receives footer-added output }
Tip: For details on the full pipeline, see Execution Model.
Summary
| Feature | Method/Attribute |
|---|---|
| Log while displaying | pipe_to("tee file") |
| Filter output | pipe_through("jq .data") |
| Copy to clipboard | pipe_to_clipboard |
| Custom timeout | pipe_to_with_timeout(cmd, duration) |
| Custom logic | pipe_with(impl PipeTarget) |
| Chain pipes | Call multiple methods |
Standout Command Flow Diagram
This diagram illustrates how a shell command input string is transformed through the standout framework.
flowchart TB
subgraph Entry["Entry Point"]
CLI["Shell Command<br/>Vec<String>"]
end
subgraph Parsing["Clap Parsing Stage"]
AUG["augment_command()<br/>Injects --output, --output-file-path"]
CLAP["clap::Command::try_get_matches_from()"]
SEL["Default command<br/>No subcommand selected?<br/>Insert it and parse again"]
AM["ArgMatches"]
AUG --> CLAP --> SEL --> AM
end
subgraph Routing["Dispatch Routing"]
ECP["extract_command_path()<br/>→ Vec<String>"]
JOIN["path.join('.')<br/>→ String"]
LOOKUP["commands.get(&path_str)<br/>→ DispatchFn"]
ECP --> JOIN --> LOOKUP
end
subgraph PreHook["Pre-Dispatch Hooks"]
PRE["PreDispatchFn<br/>(ArgMatches, &mut CommandContext)"]
CTX["CommandContext<br/>{path, app_state, extensions}"]
PRE --> CTX
end
subgraph Handler["Handler Execution"]
HANDLER["Handler Function<br/>(ArgMatches, CommandContext) → HandlerResult<T>"]
OUTPUT["Output<T: Serialize><br/>Render(T) | Silent | Binary"]
HANDLER --> OUTPUT
end
subgraph Serialize["Serialization"]
SER["serde_json::to_value()"]
JSON["serde_json::Value"]
SER --> JSON
end
subgraph PostHook["Post-Dispatch Hooks"]
POST["PostDispatchFn<br/>(ArgMatches, CommandContext, Value) → Value"]
end
subgraph RenderDispatch["Render Dispatch"]
MODE{"OutputMode?"}
subgraph Structured["Structured Modes"]
STRUCT_SER["Direct Serialization"]
JSON_OUT["Json: serde_json::to_string_pretty()"]
YAML_OUT["Yaml: serde_yaml::to_string()"]
OTHER_OUT["Xml/Csv: respective serializers"]
end
subgraph TextModes["Text Modes (Auto/Term/Text)"]
subgraph Pass1["Pass 1: Template Engine"]
JINJA["MiniJinjaEngine::render_template()<br/>Template + Value → String"]
TAGS["String with [style]tags[/style]"]
JINJA --> TAGS
end
subgraph Pass2["Pass 2: Style Processing"]
BB["BBParser::parse()"]
TRANSFORM{"TagTransform?"}
ANSI["Apply → ANSI escape codes"]
PLAIN["Remove → Plain text"]
KEEP["Keep → Tags visible"]
end
end
end
subgraph Result["Render Result"]
RR["RenderResult<br/>{formatted: String, raw: String}"]
DO["DispatchOutput<br/>Text{formatted, raw} | Binary | Silent"]
RR --> DO
end
subgraph PostOutput["Post-Output Hooks"]
PO["PostOutputFn<br/>(ArgMatches, CommandContext, RenderedOutput) → RenderedOutput"]
end
subgraph Final["Final Output"]
RUN["DispatchResult<br/>Handled(String) | Binary | Silent"]
PRINT["println!() or file write"]
RUN --> PRINT
end
CLI --> AUG
AM --> ECP
LOOKUP --> PRE
CTX --> HANDLER
OUTPUT --> SER
JSON --> POST
POST --> MODE
MODE -->|"Json/Yaml/Xml/Csv"| STRUCT_SER
STRUCT_SER --> JSON_OUT
STRUCT_SER --> YAML_OUT
STRUCT_SER --> OTHER_OUT
JSON_OUT & YAML_OUT & OTHER_OUT --> DO
MODE -->|"Auto/Term/Text"| JINJA
TAGS --> BB
BB --> TRANSFORM
TRANSFORM -->|Term| ANSI
TRANSFORM -->|Text| PLAIN
TRANSFORM -->|TermDebug| KEEP
ANSI & PLAIN & KEEP --> RR
DO --> PO
PO --> RUN
style Entry fill:#e1f5fe
style Parsing fill:#f3e5f5
style Routing fill:#fff3e0
style PreHook fill:#e8f5e9
style Handler fill:#fce4ec
style Serialize fill:#fff8e1
style PostHook fill:#e8f5e9
style RenderDispatch fill:#e3f2fd
style Result fill:#f1f8e9
style PostOutput fill:#e8f5e9
style Final fill:#ffebee
Type Flow Summary
| Stage | Input Type | Output Type |
|---|---|---|
| Entry | Vec<String> (CLI args) | - |
| Parsing | Vec<String> + clap::Command | ArgMatches |
| Routing | ArgMatches | DispatchFn lookup |
| Pre-Hooks | (&ArgMatches, &mut CommandContext) | Modified CommandContext |
| Handler | (&ArgMatches, &CommandContext) | Result<Output<T>, Error> |
| Serialization | Output<T> | serde_json::Value |
| Post-Hooks | Value | Transformed Value |
| Render (Structured) | Value + OutputMode | Formatted string (JSON/YAML/etc) |
| Render (Text Pass 1) | Template + Value | String with style tags |
| Render (Text Pass 2) | Tagged string + TagTransform | ANSI/plain/debug string |
| Result | RenderResult | DispatchOutput |
| Post-Output | RenderedOutput | Transformed RenderedOutput |
| Final | DispatchResult | Terminal output or file |
Key Components
- standout/cli/app.rs - Entry point (
App::dispatch_from) - standout/cli/core.rs - Command augmentation
- standout/cli/dispatch.rs - Dispatch logic and render orchestration
- standout-render/template/engine.rs - MiniJinja template engine
- standout-render/template/functions.rs -
apply_style_tags(), render functions - standout-bbparser - Style tag to ANSI conversion
Upgrading from Standout 3.8.0 to 6.0.0
Standout went through three major version bumps since 3.8.0 (4.0, 5.0, 6.0). This guide covers everything you need to change to get your code compiling and working on 6.0.0. Standout has since moved past 6.0.0; check the current API against the crate you depend on rather than treating this guide as a description of today's surface.
Quick Summary
| Version | What Changed |
|---|---|
| 4.0.0 | App/LocalApp unified into single-threaded App |
| 5.0.0 | New standout-input crate (additive, no breakage) |
| 6.0.0 | Internal dispatch fix for theme ordering (transparent for most users) |
The only version that requires code changes for most users is 4.0.0.
Step 1: Update Cargo.toml
[dependencies]
- standout = "3.8"
+ standout = "6"
Step 2: Remove LocalApp / ThreadSafe / Local types (v4.0.0)
The dual App/LocalApp architecture has been removed. CLI apps are single-threaded, so the thread-safety distinction was unnecessary.
Removed types
These types no longer exist:
LocalApp,LocalAppBuilderLocalHandlerLocal,ThreadSafemarker typesHandlerModetrait
Update imports
- use standout::cli::{App, ThreadSafe, LocalApp, LocalHandler};
+ use standout::cli::{App, Handler};
Update App::builder() calls
App::builder() no longer takes a generic type parameter:
- App::<ThreadSafe>::builder()
+ App::builder()
.command("list", handler, template)?
.build()?
If you were using LocalApp:
- LocalApp::builder()
+ App::builder()
.command("list", handler, template)?
.build()?
Update Handler implementations
Handler::handle() now takes &mut self instead of &self:
impl Handler for MyHandler {
- fn handle(&self, m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<T> {
+ fn handle(&mut self, m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<T> {
// ...
}
}
This means you can mutate handler state directly, without Arc<Mutex<_>> wrappers.
Update closure handlers
Handler closures are now FnMut instead of Fn:
- let handler: Box<dyn Fn(&ArgMatches, &CommandContext) -> HandlerResult<T>> = ...;
+ let handler: Box<dyn FnMut(&ArgMatches, &CommandContext) -> HandlerResult<T>> = ...;
In practice, most closures work without changes. The difference matters only if you were explicitly annotating types.
Update CommandContext.app_state references
- use std::sync::Arc;
- let state: Arc<Extensions> = ctx.app_state.clone();
+ use std::rc::Rc;
+ let state: Rc<Extensions> = ctx.app_state.clone();
Arc has been replaced with Rc throughout since thread-safety is no longer needed.
Step 3: Check for custom DispatchFn (v6.0.0)
This only affects you if you wrote custom dispatch functions using the internal DispatchFn type. The signature changed to accept &Theme at runtime:
- type DispatchFn = Box<dyn Fn(ArgMatches, &CommandContext) -> RunResult>;
+ type DispatchFn = Box<dyn Fn(ArgMatches, &CommandContext, &Theme) -> RunResult>;
If you only use the public API (.command(), .commands(), #[derive(Dispatch)]), this change is transparent. The benefit is that .theme() and .commands() can now be called in any order.
Common Compiler Errors and Fixes
cannot find type LocalApp in module cli
Replace LocalApp with App. See Step 2.
cannot find type ThreadSafe
Remove the type parameter from App::<ThreadSafe>::builder(). See Step 2.
method handle has an incompatible type for trait
Change &self to &mut self in your Handler impl. See Step 2.
expected Rc, found Arc
Replace Arc with Rc for app_state references. See Step 2.
New Features Available After Upgrading
These are additive and don't require changes, but you may want to take advantage of them:
standout-input (v5.0.0)
Declarative input collection from multiple sources with fallback chains:
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EditorSource}; let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) .try_source(StdinSource::new()) .try_source(EditorSource::new()) .resolve(&matches)?; }
Sources include: CLI args, stdin, environment variables, clipboard, editor, and interactive prompts. See Introduction to Input for the full guide.
#[handler] macro (v3.6.1)
If you're still writing handlers with manual ArgMatches extraction, the #[handler] macro eliminates boilerplate:
#![allow(unused)] fn main() { // Before fn list(m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let all = m.get_flag("all"); let items = storage::list(all)?; Ok(Output::Render(items)) } // After #[handler] fn list(#[flag] all: bool) -> Result<Vec<Item>, Error> { storage::list(all) } }
Auto-wrap Result<T> (v3.6.1)
Handlers can return Result<T, E> directly instead of Ok(Output::Render(...)):
#![allow(unused)] fn main() { fn list(m: &ArgMatches, ctx: &CommandContext) -> Result<Vec<Item>, Error> { storage::list() // no more Ok(Output::Render(...)) wrapping } }
Output piping (v3.6.1)
Pipe handler output to external commands or the clipboard:
#![allow(unused)] fn main() { App::builder() .commands(|g| { g.command_with("list", handlers::list, |cfg| { cfg.template("list.jinja") .pipe_through("jq '.data'") }) }) }
Upgrading from Standout 5.0.0 to 6.0.0
This is a minor upgrade. Only one internal change landed, and it is transparent for most users.
Quick Summary
| Version | What Changed |
|---|---|
| 6.0.0 | Internal dispatch fix: theme resolved at runtime instead of build time |
Step 1: Update Cargo.toml
[dependencies]
- standout = "5"
+ standout = "6"
Step 2: Check for custom DispatchFn (unlikely)
This only affects you if you wrote custom dispatch functions using the internal DispatchFn type directly. The signature changed to accept &Theme at runtime:
- type DispatchFn = Box<dyn Fn(ArgMatches, &CommandContext) -> RunResult>;
+ type DispatchFn = Box<dyn Fn(ArgMatches, &CommandContext, &Theme) -> RunResult>;
If you only use the public API (.command(), .commands(), #[derive(Dispatch)]), no code changes are needed. The benefit is that .theme() and .commands() can now be called in any order without the theme being silently ignored.
That's It
If you weren't using internal dispatch types, upgrading is just a version bump. All public APIs are unchanged.