Standout How To
This is a small, focused guide for adopting Standout in a working shell application. Each step is self-sufficient, takes a positive step towards a sane CLI design, and can be incrementally merged. This can be done for one command (probably a good idea), then replicated to as many as you'd like.
Note that only 2 out of 8 steps are Standout related. The others are generally good practices and clear designs for maintainable shell programs. This is not an accident, as Standout's goal is to allow your app to keep a great structure effortlessly, while providing testability, rich and fast output design, and more.
For explanation's sake, we will show a hypothetical list command for tdoo, a todo list manager.
See Also:
- Handler Contract - detailed handler API
- App State and Extensions - dependency injection patterns
- Styling System - themes and styles in depth
- Output Modes - all output format options
- Partial Adoption - migrating incrementally
- Input Collection - declarative input from multiple sources
1. Start: The Argument Parsing
Arg parsing is insanely intricate and deceptively simple. In case you are not already: define your application's interface with clap. Nothing else is worth doing until you have a sane starting point.
If you don't have clap set up yet, here's a minimal starting point:
use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "tdoo")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// List all todos List { #[arg(short, long)] all: bool, }, /// Add a new todo Add { title: String, }, } fn main() { let cli = Cli::parse(); match cli.command { Commands::List { all } => list_command(all), Commands::Add { title } => add_command(&title), } }
(If you are using a non-clap-compatible crate, for now, you'd have to write an adapter for clap.)
Verify: Run
cargo build- it should compile without errors.
2. Hard Split Logic and Formatting
Now, your command should be split into two functions: the logic handler and its rendering. Don't worry about the specifics, do the straightest path from your current code.
This is the one key step, the key design rule. And that's not because Standout requires it, rather the other way around: Standout is designed on top of it, and keeping it separate and easy to iterate on both logic and presentation under this design is Standout's key value.
If your CLI is in good shape this will be a small task, otherwise you may find yourself patching together print statements everywhere, tidying up the data model and centralizing the processing. The silver lining here being: if it takes considerable work, there will be considerable gain in doing so.
Before (tangled logic and output):
#![allow(unused)] fn main() { fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); println!("-----------"); for (i, todo) in todos.iter().enumerate() { if show_all || todo.status == Status::Pending { let marker = if todo.status == Status::Done { "[x]" } else { "[ ]" }; println!("{}. {} {}", i + 1, marker, todo.title); } } if todos.is_empty() { println!("No todos yet!"); } } }
After (clean separation):
#![allow(unused)] fn main() { use clap::ArgMatches; // Data types for your domain #[derive(Clone)] pub enum Status { Pending, Done } #[derive(Clone)] pub struct Todo { pub title: String, pub status: Status, } pub struct TodoResult { pub message: Option<String>, pub todos: Vec<Todo>, } // This is your core logic handler, receiving parsed clap args // and returning a pure Rust data type. // // Note: This example uses immutable references. If your handler needs // mutable state (&mut self), see the "Mutable Handlers" section below. pub fn list(matches: &ArgMatches) -> TodoResult { let show_done = matches.get_flag("all"); let todos = storage::list().unwrap(); let filtered: Vec<Todo> = if show_done { todos } else { todos.into_iter() .filter(|t| matches!(t.status, Status::Pending)) .collect() }; TodoResult { message: None, todos: filtered, } } // This will take the Rust data type and print the result to stdout pub fn render_list(result: TodoResult) { if let Some(msg) = result.message { println!("{}", msg); } for (i, todo) in result.todos.iter().enumerate() { let status = match todo.status { Status::Done => "[x]", Status::Pending => "[ ]", }; println!("{}. {} {}", i + 1, status, todo.title); } } // And the orchestrator: pub fn list_command(matches: &ArgMatches) { render_list(list(matches)) } }
Verify: Run
cargo buildand thentdoo list- output should look identical to before.
Intermezzo A: Milestone - Logic and Presentation Split
What you achieved: Your command logic is now a pure function that returns data. What's now possible:
- All of your app's logic can be unit tested as any code, from the logic inwards.
- You can test by feeding input strings and verifying your logic handler gets called with the right parameters.
- The rendering can also be tested by feeding data inputs and matching outputs (though this is brittle).
What's next: Making the return type serializable for automatic JSON/YAML output. Your files now:
src/
├── main.rs # clap setup + orchestrators
├── handlers.rs # list(), add() - pure logic
└── render.rs # render_list(), render_add() - output formatting
3. Fine Tune the Logic Handler's Return Type
While any data type works, Standout's renderer takes a generic type that must implement Serialize. This enables automatic JSON/YAML output modes and template rendering through MiniJinja's context system. This is likely a small change, and beneficial as a baseline for logic results that will simplify writing renderers later.
Add serde to your Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
Update your types:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] pub struct Todo { pub title: String, pub status: Status, } #[derive(Serialize)] pub struct TodoResult { pub message: Option<String>, pub todos: Vec<Todo>, } }
Verify: Run
cargo build- it should compile without errors.
4. Replace Imperative Print Statements With a Template
Reading a template of an output next to the substituting variables is much easier to reason about than scattered prints, string concats and the like.
This step is optional - if your current output is simple, you can skip to step 5. If you want an intermediate checkpoint, use Rust's format strings:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output = format!( "{header}\n{todos}", header = result.message.unwrap_or_default(), todos = result.todos.iter().enumerate() .map(|(i, t)| format!("{}. [{}] {}", i + 1, t.status, t.title)) .collect::<Vec<_>>() .join("\n") ); println!("{}", output); } }
Verify: Run
tdoo list- output should still work.
5. Use a MiniJinja Template String
Rewrite your std::fmt or imperative prints into a MiniJinja template string, and add minijinja to your crate. If you're not familiar with it, it's a Rust implementation of Jinja, pretty much a de-facto standard for more complex templates.
Resources:
Add minijinja to your Cargo.toml:
[dependencies]
minijinja = "2"
And then you call render in MiniJinja, passing the template string and the data to use. So now your rendering function looks like this:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output_tmpl = r#" {% if message %} {{ message }} {% endif %} {% for todo in todos %} {{ loop.index }}. [{{ todo.status }}] {{ todo.title }} {% endfor %} "#; let env = minijinja::Environment::new(); let tmpl = env.template_from_str(output_tmpl).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should match (formatting may differ slightly).
6. Use a Dedicated Template File
Now, move the template content into a file (say src/templates/list.jinja), and load it in the rendering module. Dedicated files have several advantages: triggering editor/IDE support for the file type, more descriptive diffs, less risk of breaking the code/build and, in the event that you have less technical people helping out with the UI, a much cleaner and simpler way for them to contribute.
Create src/templates/list.jinja:
{% if message %}{{ message }} {% endif %}
{% for todo in todos %}
{{ loop.index }}. [{{ todo.status }}] {{ todo.title }}
{% endfor %}
Update your render function to load from file:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let template_content = include_str!("templates/list.jinja"); let env = minijinja::Environment::new(); let tmpl = env.template_from_str(template_content).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should be identical.
Intermezzo B: Declarative Output Definition
What you achieved: Output is now defined declaratively in a template file, separate from Rust code. What's now possible:
- Edit templates without recompiling (with minor changes to loading)
- Non-Rust developers can contribute to UI
- Clear separation in code reviews: "is this a logic change or display change?"
- Use partials, filters, and macros for complex outputs (see Templating)
What's next: Hooking up Standout for automatic dispatch and rich output. Also, notice we've yet to do anything Standout-specific. This is not a coincidence—the framework is designed around this pattern, making testability, fast iteration, and rich features natural outcomes of the architecture. Your files now:
src/
├── main.rs
├── handlers.rs
├── render.rs
└── templates/
└── list.jinja
7. Standout: Offload the Handler Orchestration
And now the Standout-specific bits finally show up.
7.1 Add Standout to your Cargo.toml
[dependencies]
standout = "2"
Verify: Run
cargo build- dependencies should download and compile.
7.2 Create Handlers with the #[handler] Macro
The #[handler] macro transforms pure Rust functions into Standout-compatible handlers. Write your logic as a simple function, annotate parameters, and the macro generates the wrapper that extracts CLI arguments.
See Handler Contract for full handler API details.
#![allow(unused)] fn main() { use standout_macros::handler; mod handlers { use super::*; // Pure function - easy to test, no boilerplate #[handler] pub fn list(#[flag] all: bool) -> Result<TodoResult, anyhow::Error> { let todos = storage::list()?; let filtered = if all { todos } else { todos.into_iter().filter(|t| !t.done).collect() }; Ok(TodoResult { message: None, todos: filtered }) } #[handler] pub fn add(#[arg] title: String) -> Result<TodoResult, anyhow::Error> { let todo = storage::add(&title)?; Ok(TodoResult { message: Some(format!("Added: {}", title)), todos: vec![todo], }) } } }
The #[handler] macro:
- Extracts CLI arguments automatically from
ArgMatches - Converts
#[flag]toboolflags,#[arg]to required/optional args - Auto-wraps
Result<T, E>inOutput::Renderfor you
Parameter Annotations:
| Annotation | Type | What it extracts |
|---|---|---|
#[flag] | bool | Boolean flag (--verbose) |
#[arg] | T | Required argument |
#[arg] | Option<T> | Optional argument |
#[arg] | Vec<T> | Multiple values |
#[ctx] | &CommandContext | Access to context (when needed) |
7.2.1 Connect Commands to Handlers
#![allow(unused)] fn main() { use clap::Subcommand; use standout::cli::Dispatch; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { List, Add, } }
The derive macro matches command variants to handler functions by name (List → handlers::list).
Verify: Run
cargo build- it should compile without errors.
7.2.2 Accessing App State (Optional)
When your handler needs shared resources like databases, use the #[ctx] annotation:
#![allow(unused)] fn main() { #[handler] pub fn list(#[flag] all: bool, #[ctx] ctx: &CommandContext) -> Result<TodoResult, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(TodoResult { message: None, todos }) } }
Note: For the full handler signature (without macros) and advanced patterns, see Handler Contract.
7.3 Configure AppBuilder
Use AppBuilder to configure your app. Instantiate the builder, add the path for your templates. See App Configuration for all configuration options.
#![allow(unused)] fn main() { use standout::cli::App; use standout::{embed_templates, embed_styles}; let app = App::builder() .templates(embed_templates!("src/templates")) // Embeds all .jinja/.j2 files .commands(Commands::dispatch_config()) // Register handlers from derive macro .build()?; }
Verify: Run
cargo build- it should compile without errors.
7.3.1 Injecting Shared State (Optional)
If your handlers need access to shared resources like database connections or configuration, use app_state:
#![allow(unused)] fn main() { let app = App::builder() .app_state(Database::connect()?) // Shared across all handlers .app_state(Config::load()?) .templates(embed_templates!("src/templates")) .commands(Commands::dispatch_config()) .build()?; }
Handlers retrieve app state via #[ctx]:
#![allow(unused)] fn main() { #[handler] pub fn list(#[flag] all: bool, #[ctx] ctx: &CommandContext) -> Result<TodoResult, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(TodoResult { message: None, todos }) } }
Note: For per-request state (user sessions, request IDs), use pre-dispatch hooks with
ctx.extensions. See App State and Extensions for the full story.
7.4 Wire up main()
The final bit: handling the dispatching off to Standout:
use standout::cli::App; use standout::embed_templates; fn main() -> anyhow::Result<()> { let app = App::builder() .templates(embed_templates!("src/templates")) .commands(Commands::dispatch_config()) .build()?; // Run with auto dispatch - handles parsing and execution app.run(Cli::command(), std::env::args()); Ok(()) }
If your app has other clap commands that are not managed by Standout, check for unhandled commands. See Partial Adoption for details on incremental migration.
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy legacy_dispatch(matches); } }
Verify: Run
tdoo list- it should work as before. Verify: Runtdoo list --output json- you should get JSON output for free!
And now you can remove the boilerplate: the orchestrator (list_command) and the rendering (render_list). You're pretty much at global optima: a single line of derive macro links your app logic to a command name, a few lines configure Standout, and auto dispatch handles all the boilerplate.
For the next commands you'd wish to migrate, this is even simpler. Say you have a "create" logic handler: add a "create.jinja" to that template dir, add the derive macro for the create function and that is it. By default the macro will match the command's name to the handlers and to the template files, but you can change these and map explicitly to your heart's content.
Intermezzo C: Welcome to Standout
What you achieved: Full dispatch pipeline with zero boilerplate.
What's now possible:
- Alter the template and re-run your CLI, without compilation, and the new template will be used
- Your CLI just got multiple output modes via
--output(see Output Modes):- term: rich shell formatting (more about this on the next step)
- term-debug: print formatting info for testing/debugging
- text: plain text, no styling
- auto: the default, rich term that degrades gracefully
- json, csv, yaml: automatic serialization of your data
- Pipe output to external commands (jq, clipboard, tee) via
pipe_through,pipe_to, orpipe_to_clipboard
What's next: Adding rich styling to make the output beautiful.
Your files now:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() with #[handler] - pure functions returning Result<T, E>
└── templates/
├── list.jinja
└── add.jinja
8. Make the Output Awesome
Let's transform that mono-typed, monochrome string into a richer and more useful UI. Borrowing from web apps setup, we keep the content in a template file, and we define styles in a stylesheet file.
See Styling System for full styling documentation.
8.1 Create the stylesheet
Create src/styles/default.css:
/* Styles for completed todos */
.done {
text-decoration: line-through;
color: gray;
}
/* Style for todo index numbers */
.index {
color: yellow;
}
/* Style for pending todos */
.pending {
font-weight: bold;
color: white;
}
/* Adaptive style for messages */
.message {
color: cyan;
}
@media (prefers-color-scheme: light) {
.pending { color: black; }
}
@media (prefers-color-scheme: dark) {
.pending { color: white; }
}
Or if you prefer YAML (src/styles/default.yaml):
done: strikethrough, gray
index: yellow
pending:
bold: true
fg: white
light:
fg: black
dark:
fg: white
message: cyan
Verify: The file exists at
src/styles/default.cssorsrc/styles/default.yaml.
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 or default.yaml .commands(Commands::dispatch_config()) .build()?; }
Verify: Run
cargo build- it should compile without errors. Verify: Runtdoo list- you should see colored, styled output! Verify: Runtdoo list --output text- plain text, no colors.
Now you're leveraging the core rendering design of Standout:
- File-based templates for content, and stylesheets for styles
- Custom template syntax with BBCode for markup styles
[style][/style] - Live reload: iterate through content and styling without recompiling
Intermezzo D: The Full Setup Is Done
What you achieved: A fully styled, testable, multi-format CLI.
What's now possible:
- Rich terminal output with colors, bold, strikethrough
- Automatic light/dark mode adaptation
- JSON/YAML/CSV output for scripting and testing
- Hot reload of templates and styles during development
- Unit testable logic handlers
Your final files:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() with #[handler] - pure functions
├── templates/
│ ├── list.jinja # with [style] tags
│ └── add.jinja
└── styles/
└── default.css # or default.yaml
For brevity's sake, we've ignored a bunch of finer and relevant points:
- The derive macros can set name mapping explicitly:
#[dispatch(handler = custom_fn, template = "custom.jinja")] - There are pre-dispatch, post-dispatch and post-render hooks (see Execution Model)
- Output piping to external commands like
jq,tee, or clipboard (see below and Output Piping) - Standout exposes its primitives as standalone crates (see standout-render, standout-dispatch)
- Powerful tabular layouts via the
colfilter (see Tabular Layout) - A help topics system for rich documentation (see Topics System)
Bonus: Pipe Output to External Commands
Need to filter output through jq, log to a file with tee, or copy to clipboard? Standout supports piping rendered output to external commands:
#![allow(unused)] fn main() { #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { /// List todos, extracting just titles with jq #[dispatch(pipe_through = "jq '.todos[].title'")] List, /// Export todos to clipboard #[dispatch(pipe_to_clipboard)] Export, } }
Or via the builder API:
#![allow(unused)] fn main() { let app = App::builder() .commands(|g| { g.command_with("list", handlers::list, |cfg| { cfg.template("list.jinja") .pipe_through("jq '.todos'") // Filter JSON output }) .command_with("export", handlers::export, |cfg| { cfg.template("export.jinja") .pipe_to("tee /tmp/export.log") // Log while displaying .pipe_to_clipboard() // Then copy to clipboard }) }) .build()?; }
Three piping modes:
pipe_through("cmd"): Use command's stdout as new output (filters like jq, sort)pipe_to("cmd"): Run command but keep original output (side effects like tee)pipe_to_clipboard(): Send to system clipboard (pbcopy on macOS, xclip on Linux)
See Output Piping for the full API.
Bonus: Declarative Input Collection
Need to accept input from CLI arguments, piped stdin, environment variables, or interactive prompts? standout-input provides declarative input chains:
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EnvSource, EditorSource}; // Try each source in order until one provides input let 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") .resolve(&matches)?; }
Features:
- Declarative priority: Source order is explicit in the chain
- Testable: All sources accept mocks for CI-safe testing
- Validated: Chain-level validation with retry support for interactive sources
- Feature-gated: Control dependencies (editor, prompts, inquire TUI)
See Introduction to Input for the full guide.
Aside from exposing the library primitives, Standout leverages best-in-breed crates like MiniJinja and console::Style under the hood. The lock-in is really negligible: you can use Standout's BB parser or swap it, manually dispatch handlers, and use the renderers directly in your clap dispatch.
Mutable Handlers
App supports FnMut closures and mutable handler state directly—no wrappers needed. This is common with database connections, file caches, or in-memory indices:
use standout::cli::{App, Output}; use standout::embed_templates; struct PadStore { index: HashMap<Uuid, Metadata>, } impl PadStore { fn complete(&mut self, id: Uuid) -> Result<()> { // This needs &mut self self.index.get_mut(&id).unwrap().completed = true; Ok(()) } } fn main() -> anyhow::Result<()> { let mut store = PadStore::load()?; App::builder() .app_state(Config::load()?) .templates(embed_templates!("src/templates")) .command("complete", |m, ctx| { let id = m.get_one::<Uuid>("id").unwrap(); store.complete(*id)?; // &mut store works! Ok(Output::Silent) }, "")? .command("list", |m, ctx| { Ok(Output::Render(store.list())) }, "{{ items }}")? .build()? .run(Cli::command(), std::env::args()); Ok(()) }
Handler capabilities:
App::builder()acceptsFnMutclosures- Handlers can capture
&mutreferences to state - The
Handlertrait uses&mut selffor struct-based handlers - No
Send + Syncrequirements—CLI apps are single-threaded
Appendix: Common Errors and Troubleshooting
- Template not found
- Error:
template 'list' not found - Cause: The template path in
embed_templates!doesn't match your file structure. - Fix: Ensure the path is relative to your
Cargo.toml, e.g.,embed_templates!("src/templates")and that the file is namedlist.jinja,list.j2, orlist.txt.
- Error:
- Style not applied
- Symptom: Text appears but without colors/formatting.
- Cause: Style name in template doesn't match stylesheet.
- Fix: Check that
[mystyle]in your template matches.mystylein CSS ormystyle:in YAML. Run with--output term-debugto see style tag names.
- Handler not called
- Symptom: Command runs but nothing happens or wrong handler runs.
- Cause: Command name mismatch between clap enum variant and handler function.
- Fix: Ensure enum variant
Listmaps to functionhandlers::list(snake_case conversion). Or use explicit mapping:#[dispatch(handler = my_custom_handler)]
- JSON output is empty or wrong
- Symptom:
--output jsonproduces unexpected results. - Cause:
Serializederive is missing or field names don't match template expectations. - Fix: Ensure all types in your result implement
Serialize. Use#[serde(rename_all = "lowercase")]for consistent naming.
- Symptom:
- Styles not loading
- Error:
theme not found: default - Cause: Stylesheet file missing or wrong path.
- Fix: Ensure
src/styles/default.cssordefault.yamlexists. Checkembed_styles!path matches your file structure.
- Error: