Standout
Test your data. Render your view.
Standout is a CLI framework for Rust that enforces separation between logic and presentation. Your handlers return structs, not strings—making CLI logic as testable as any other code.
The Problem
CLI code that mixes logic with println! statements is impossible to unit test:
#![allow(unused)] fn main() { // You can't unit test this—it writes directly to stdout fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); for todo in todos.iter() { if show_all || todo.status == Status::Pending { println!(" {} {}", if todo.done { "[x]" } else { "[ ]" }, todo.title); } } } }
The only way to test this is regex on captured stdout. That's fragile, verbose, and couples your tests to presentation details.
The Solution
With Standout, handlers return data. The framework handles rendering:
#![allow(unused)] fn main() { // This is unit-testable—it's a pure function that returns data fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let show_all = matches.get_flag("all"); let todos = storage::list()? .into_iter() .filter(|t| show_all || t.status == Status::Pending) .collect(); Ok(Output::Render(TodoResult { todos })) } #[test] fn test_list_filters_completed() { let matches = /* mock ArgMatches with all=false */; let result = list_handler(&matches, &ctx).unwrap(); assert!(result.todos.iter().all(|t| t.status == Status::Pending)); } }
Because your logic returns a struct, you test the struct. No stdout capture, no regex, no brittleness.
Standing Out
What Standout provides:
- Enforced architecture splitting data and presentation
- Logic is testable as any Rust code
- Boilerplateless: declaratively link your handlers to command names and templates, Standout handles the rest
- Autodispatch: save keystrokes with auto dispatch from the known command tree
- Free output handling: rich terminal with graceful degradation, plus structured data (JSON, YAML, CSV)
- Finely crafted output:
- File-based templates for content and CSS for styling
- Rich styling with adaptive properties (light/dark modes), inheritance, and full theming
- Powerful templating through MiniJinja, including partials (reusable, smaller templates for models displayed in multiple places)
- Hot reload: changes to templates and styles don't require compiling
- Declarative layout support for tabular data
Quick Start
1. Define Your Commands and Handlers
Use the Dispatch derive macro to connect commands to handlers. Handlers receive parsed arguments and return serializable data.
#![allow(unused)] fn main() { use standout::cli::{Dispatch, CommandContext, HandlerResult, Output}; use clap::{ArgMatches, Subcommand}; use serde::Serialize; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] // handlers are in the `handlers` module pub enum Commands { List, Add { title: String }, } #[derive(Serialize)] struct TodoResult { todos: Vec<Todo>, } mod handlers { use super::*; // HandlerResult<T> wraps your data; Output::Render tells Standout to render it pub fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let todos = storage::list()?; Ok(Output::Render(TodoResult { todos })) } pub fn add(m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> { let title: &String = m.get_one("title").unwrap(); let todo = storage::add(title)?; Ok(Output::Render(TodoResult { todos: vec![todo] })) } } }
2. Define Your Presentation
Templates use MiniJinja with semantic style tags. Styles are defined separately in CSS or YAML.
{# list.jinja #}
[title]My Todos[/title]
{% for todo in todos %}
- {{ todo.title }} ([status]{{ todo.status }}[/status])
{% endfor %}
/* styles/default.css */
.title { color: cyan; font-weight: bold; }
.status { color: yellow; }
3. Wire It Up
use standout::cli::App; use standout::{embed_templates, embed_styles}; fn main() -> Result<(), Box<dyn std::error::Error>> { let app = App::builder() .commands(Commands::dispatch_config()) // Register handlers from derive macro .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .build()?; app.run(Cli::command(), std::env::args()); Ok(()) }
Run it:
myapp list # Rich terminal output with colors
myapp list --output json # JSON for scripting
myapp list --output yaml # YAML for config files
myapp list --output text # Plain text, no ANSI codes
Features
Architecture
- Logic/presentation separation enforced by design
- Handlers return data; framework handles rendering
- Unit-testable CLI logic without stdout capture
Output Modes
- Rich terminal output with colors and styles
- Automatic JSON, YAML, CSV serialization from the same handler
- Graceful degradation when terminal lacks capabilities
Rendering
- MiniJinja templates with semantic style tags
- CSS or YAML stylesheets with light/dark mode support
- Hot reload during development—edit templates without recompiling
- Tabular layouts with alignment, truncation, and Unicode support
Integration
- Clap integration with automatic dispatch
- Declarative command registration via derive macros
Installation
cargo add standout
Migrating an Existing CLI
Already have a CLI? Standout supports incremental adoption. Standout handles matched commands automatically; unmatched commands return ArgMatches for your existing dispatch:
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy your_existing_dispatch(matches); } }
See the Partial Adoption Guide for the full migration path.
Next Steps
- Introduction to Standout — Adopting Standout in a working CLI. Start here.
- Introduction to Rendering — Creating polished terminal output
- Introduction to Tabular — Building aligned, readable tabular layouts
- All Topics — In-depth documentation for specific systems
Fast Paced intro to your First Standout Based Command
This is a terse and direct how to for more experienced developers or at least the ones in a hurry. It skimps rationale, design and other useful bits you can read from the longer form version
Prerequisites
A cli app, that uses clap for arg parsing. A command function that is pure logic, that is, returns the result, and does not print to stdout or format output.
For this guide's purpose we'll use a fictitious "list" command of our todo list manager
The Core: A pure function logic handler
The logic handler: receives parsed cli args, and returns a serializable data structure:
#![allow(unused)] fn main() { pub fn list(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<TodoResult> {} }
Making it outstanding
1. The File System
Create a templates/list.jinja and styles/default.css:
src/
├── handlers.rs # where list it
├── templates/ # standout will match templates name against rel. paths from temp root, here
├── list.jinja # the template to render list, name matched against the command name
├── styles/ # likewise for themes, this sets a theme called "default"
├── default.css # the default style for the command, filename will be the theme name
2. Define your styles
.done {
text-decoration: line-through;
color: gray;
}
.pending {
font-weight: bold;
color: white;
}
.index {
color: yellow;
}
3. Write your template
{% if message %}
[message]{{ message }} [/message]
{% endif %}
{% for todo in todos %}
[index]{{ loop.index }}.[/index] [{{ todo.status }}]{{ todo.title }}[/{{ todo.status }}]
{% endfor %}
4. Putting it all together
Configure the app:
#![allow(unused)] fn main() { let app = App::builder() .app_state(Database::connect()?) // Optional: shared state for handlers .templates(embed_templates!("src/templates")) // Sets the root template path .styles(embed_styles!("src/styles")) // Likewise the styles root .default_theme("default") // Use styles/default.css or default.yaml .commands(Commands::dispatch_config()) // Register handlers from derive macro .build()?; }
Handlers access shared state via
ctx.app_state.get_required::<Database>()?. See App State and Extensions for details.
Connect your logic to a command name and template :
#![allow(unused)] fn main() { #[dispatch(handlers = handlers)] pub enum Commands { ... list, } }
And finally, run in main, the autodispatcher:
#![allow(unused)] fn main() { match app.run(Cli::command(), std::env::args()) { // If you've got other commands on vanilla manual dispatch, call it for unported commands RunResult::NoMatch(matches) => legacy_dispatch(matches), // Your existing handler } }
Standout How To
This is a small, focused guide for adopting Standout in a working shell application. Each step is self-sufficient, takes a positive step towards a sane CLI design, and can be incrementally merged. This can be done for one command (probably a good idea), then replicated to as many as you'd like.
Note that only 2 out of 8 steps are Standout related. The others are generally good practices and clear designs for maintainable shell programs. This is not an accident, as Standout's goal is to allow your app to keep a great structure effortlessly, while providing testability, rich and fast output design, and more.
For explanation's sake, we will show a hypothetical list command for tdoo, a todo list manager.
See Also:
- Handler Contract - detailed handler API
- App State and Extensions - dependency injection patterns
- Styling System - themes and styles in depth
- Output Modes - all output format options
- Partial Adoption - migrating incrementally
- Input Collection - declarative input from multiple sources
1. Start: The Argument Parsing
Arg parsing is insanely intricate and deceptively simple. In case you are not already: define your application's interface with clap. Nothing else is worth doing until you have a sane starting point.
If you don't have clap set up yet, here's a minimal starting point:
use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "tdoo")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// List all todos List { #[arg(short, long)] all: bool, }, /// Add a new todo Add { title: String, }, } fn main() { let cli = Cli::parse(); match cli.command { Commands::List { all } => list_command(all), Commands::Add { title } => add_command(&title), } }
(If you are using a non-clap-compatible crate, for now, you'd have to write an adapter for clap.)
Verify: Run
cargo build- it should compile without errors.
2. Hard Split Logic and Formatting
Now, your command should be split into two functions: the logic handler and its rendering. Don't worry about the specifics, do the straightest path from your current code.
This is the one key step, the key design rule. And that's not because Standout requires it, rather the other way around: Standout is designed on top of it, and keeping it separate and easy to iterate on both logic and presentation under this design is Standout's key value.
If your CLI is in good shape this will be a small task, otherwise you may find yourself patching together print statements everywhere, tidying up the data model and centralizing the processing. The silver lining here being: if it takes considerable work, there will be considerable gain in doing so.
Before (tangled logic and output):
#![allow(unused)] fn main() { fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); println!("-----------"); for (i, todo) in todos.iter().enumerate() { if show_all || todo.status == Status::Pending { let marker = if todo.status == Status::Done { "[x]" } else { "[ ]" }; println!("{}. {} {}", i + 1, marker, todo.title); } } if todos.is_empty() { println!("No todos yet!"); } } }
After (clean separation):
#![allow(unused)] fn main() { use clap::ArgMatches; // Data types for your domain #[derive(Clone)] pub enum Status { Pending, Done } #[derive(Clone)] pub struct Todo { pub title: String, pub status: Status, } pub struct TodoResult { pub message: Option<String>, pub todos: Vec<Todo>, } // This is your core logic handler, receiving parsed clap args // and returning a pure Rust data type. // // Note: This example uses immutable references. If your handler needs // mutable state (&mut self), see the "Mutable Handlers" section below. pub fn list(matches: &ArgMatches) -> TodoResult { let show_done = matches.get_flag("all"); let todos = storage::list().unwrap(); let filtered: Vec<Todo> = if show_done { todos } else { todos.into_iter() .filter(|t| matches!(t.status, Status::Pending)) .collect() }; TodoResult { message: None, todos: filtered, } } // This will take the Rust data type and print the result to stdout pub fn render_list(result: TodoResult) { if let Some(msg) = result.message { println!("{}", msg); } for (i, todo) in result.todos.iter().enumerate() { let status = match todo.status { Status::Done => "[x]", Status::Pending => "[ ]", }; println!("{}. {} {}", i + 1, status, todo.title); } } // And the orchestrator: pub fn list_command(matches: &ArgMatches) { render_list(list(matches)) } }
Verify: Run
cargo buildand thentdoo list- output should look identical to before.
Intermezzo A: Milestone - Logic and Presentation Split
What you achieved: Your command logic is now a pure function that returns data. What's now possible:
- All of your app's logic can be unit tested as any code, from the logic inwards.
- You can test by feeding input strings and verifying your logic handler gets called with the right parameters.
- The rendering can also be tested by feeding data inputs and matching outputs (though this is brittle).
What's next: Making the return type serializable for automatic JSON/YAML output. Your files now:
src/
├── main.rs # clap setup + orchestrators
├── handlers.rs # list(), add() - pure logic
└── render.rs # render_list(), render_add() - output formatting
3. Fine Tune the Logic Handler's Return Type
While any data type works, Standout's renderer takes a generic type that must implement Serialize. This enables automatic JSON/YAML output modes and template rendering through MiniJinja's context system. This is likely a small change, and beneficial as a baseline for logic results that will simplify writing renderers later.
Add serde to your Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
Update your types:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] pub struct Todo { pub title: String, pub status: Status, } #[derive(Serialize)] pub struct TodoResult { pub message: Option<String>, pub todos: Vec<Todo>, } }
Verify: Run
cargo build- it should compile without errors.
4. Replace Imperative Print Statements With a Template
Reading a template of an output next to the substituting variables is much easier to reason about than scattered prints, string concats and the like.
This step is optional - if your current output is simple, you can skip to step 5. If you want an intermediate checkpoint, use Rust's format strings:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output = format!( "{header}\n{todos}", header = result.message.unwrap_or_default(), todos = result.todos.iter().enumerate() .map(|(i, t)| format!("{}. [{}] {}", i + 1, t.status, t.title)) .collect::<Vec<_>>() .join("\n") ); println!("{}", output); } }
Verify: Run
tdoo list- output should still work.
5. Use a MiniJinja Template String
Rewrite your std::fmt or imperative prints into a MiniJinja template string, and add minijinja to your crate. If you're not familiar with it, it's a Rust implementation of Jinja, pretty much a de-facto standard for more complex templates.
Resources:
Add minijinja to your Cargo.toml:
[dependencies]
minijinja = "2"
And then you call render in MiniJinja, passing the template string and the data to use. So now your rendering function looks like this:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let output_tmpl = r#" {% if message %} {{ message }} {% endif %} {% for todo in todos %} {{ loop.index }}. [{{ todo.status }}] {{ todo.title }} {% endfor %} "#; let env = minijinja::Environment::new(); let tmpl = env.template_from_str(output_tmpl).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should match (formatting may differ slightly).
6. Use a Dedicated Template File
Now, move the template content into a file (say src/templates/list.jinja), and load it in the rendering module. Dedicated files have several advantages: triggering editor/IDE support for the file type, more descriptive diffs, less risk of breaking the code/build and, in the event that you have less technical people helping out with the UI, a much cleaner and simpler way for them to contribute.
Create src/templates/list.jinja:
{% if message %}{{ message }} {% endif %}
{% for todo in todos %}
{{ loop.index }}. [{{ todo.status }}] {{ todo.title }}
{% endfor %}
Update your render function to load from file:
#![allow(unused)] fn main() { pub fn render_list(result: TodoResult) { let template_content = include_str!("templates/list.jinja"); let env = minijinja::Environment::new(); let tmpl = env.template_from_str(template_content).unwrap(); let output = tmpl.render(&result).unwrap(); println!("{}", output); } }
Verify: Run
tdoo list- output should be identical.
Intermezzo B: Declarative Output Definition
What you achieved: Output is now defined declaratively in a template file, separate from Rust code. What's now possible:
- Edit templates without recompiling (with minor changes to loading)
- Non-Rust developers can contribute to UI
- Clear separation in code reviews: "is this a logic change or display change?"
- Use partials, filters, and macros for complex outputs (see Templating)
What's next: Hooking up Standout for automatic dispatch and rich output. Also, notice we've yet to do anything Standout-specific. This is not a coincidence—the framework is designed around this pattern, making testability, fast iteration, and rich features natural outcomes of the architecture. Your files now:
src/
├── main.rs
├── handlers.rs
├── render.rs
└── templates/
└── list.jinja
7. Standout: Offload the Handler Orchestration
And now the Standout-specific bits finally show up.
7.1 Add Standout to your Cargo.toml
[dependencies]
standout = "2"
Verify: Run
cargo build- dependencies should download and compile.
7.2 Create Handlers with the #[handler] Macro
The #[handler] macro transforms pure Rust functions into Standout-compatible handlers. Write your logic as a simple function, annotate parameters, and the macro generates the wrapper that extracts CLI arguments.
See Handler Contract for full handler API details.
#![allow(unused)] fn main() { use standout_macros::handler; mod handlers { use super::*; // Pure function - easy to test, no boilerplate #[handler] pub fn list(#[flag] all: bool) -> Result<TodoResult, anyhow::Error> { let todos = storage::list()?; let filtered = if all { todos } else { todos.into_iter().filter(|t| !t.done).collect() }; Ok(TodoResult { message: None, todos: filtered }) } #[handler] pub fn add(#[arg] title: String) -> Result<TodoResult, anyhow::Error> { let todo = storage::add(&title)?; Ok(TodoResult { message: Some(format!("Added: {}", title)), todos: vec![todo], }) } } }
The #[handler] macro:
- Extracts CLI arguments automatically from
ArgMatches - Converts
#[flag]toboolflags,#[arg]to required/optional args - Auto-wraps
Result<T, E>inOutput::Renderfor you
Parameter Annotations:
| Annotation | Type | What it extracts |
|---|---|---|
#[flag] | bool | Boolean flag (--verbose) |
#[arg] | T | Required argument |
#[arg] | Option<T> | Optional argument |
#[arg] | Vec<T> | Multiple values |
#[ctx] | &CommandContext | Access to context (when needed) |
7.2.1 Connect Commands to Handlers
#![allow(unused)] fn main() { use clap::Subcommand; use standout::cli::Dispatch; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] pub enum Commands { List, Add, } }
The derive macro matches command variants to handler functions by name (List → handlers::list).
Verify: Run
cargo build- it should compile without errors.
7.2.2 Accessing App State (Optional)
When your handler needs shared resources like databases, use the #[ctx] annotation:
#![allow(unused)] fn main() { #[handler] pub fn list(#[flag] all: bool, #[ctx] ctx: &CommandContext) -> Result<TodoResult, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(TodoResult { message: None, todos }) } }
Note: For the full handler signature (without macros) and advanced patterns, see Handler Contract.
7.3 Configure AppBuilder
Use AppBuilder to configure your app. Instantiate the builder, add the path for your templates. See App Configuration for all configuration options.
#![allow(unused)] fn main() { use standout::cli::App; use standout::{embed_templates, embed_styles}; let app = App::builder() .templates(embed_templates!("src/templates")) // Embeds all .jinja/.j2 files .commands(Commands::dispatch_config()) // Register handlers from derive macro .build()?; }
Verify: Run
cargo build- it should compile without errors.
7.3.1 Injecting Shared State (Optional)
If your handlers need access to shared resources like database connections or configuration, use app_state:
#![allow(unused)] fn main() { let app = App::builder() .app_state(Database::connect()?) // Shared across all handlers .app_state(Config::load()?) .templates(embed_templates!("src/templates")) .commands(Commands::dispatch_config()) .build()?; }
Handlers retrieve app state via #[ctx]:
#![allow(unused)] fn main() { #[handler] pub fn list(#[flag] all: bool, #[ctx] ctx: &CommandContext) -> Result<TodoResult, anyhow::Error> { let db = ctx.app_state.get_required::<Database>()?; let todos = db.list()?; Ok(TodoResult { message: None, todos }) } }
Note: For per-request state (user sessions, request IDs), use pre-dispatch hooks with
ctx.extensions. See App State and Extensions for the full story.
7.4 Wire up main()
The final bit: handling the dispatching off to Standout:
use standout::cli::App; use standout::embed_templates; fn main() -> anyhow::Result<()> { let app = App::builder() .templates(embed_templates!("src/templates")) .commands(Commands::dispatch_config()) .build()?; // Run with auto dispatch - handles parsing and execution app.run(Cli::command(), std::env::args()); Ok(()) }
If your app has other clap commands that are not managed by Standout, check for unhandled commands. See Partial Adoption for details on incremental migration.
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy legacy_dispatch(matches); } }
Verify: Run
tdoo list- it should work as before. Verify: Runtdoo list --output json- you should get JSON output for free!
And now you can remove the boilerplate: the orchestrator (list_command) and the rendering (render_list). You're pretty much at global optima: a single line of derive macro links your app logic to a command name, a few lines configure Standout, and auto dispatch handles all the boilerplate.
For the next commands you'd wish to migrate, this is even simpler. Say you have a "create" logic handler: add a "create.jinja" to that template dir, add the derive macro for the create function and that is it. By default the macro will match the command's name to the handlers and to the template files, but you can change these and map explicitly to your heart's content.
Intermezzo C: Welcome to Standout
What you achieved: Full dispatch pipeline with zero boilerplate.
What's now possible:
- Alter the template and re-run your CLI, without compilation, and the new template will be used
- Your CLI just got multiple output modes via
--output(see Output Modes):- term: rich shell formatting (more about this on the next step)
- term-debug: print formatting info for testing/debugging
- text: plain text, no styling
- auto: the default, rich term that degrades gracefully
- json, csv, yaml: automatic serialization of your data
- Pipe output to external commands (jq, clipboard, tee) via
pipe_through,pipe_to, orpipe_to_clipboard
What's next: Adding rich styling to make the output beautiful.
Your files now:
src/
├── main.rs # App::builder() setup
├── commands.rs # Commands enum with #[derive(Dispatch)]
├── handlers.rs # list(), add() with #[handler] - pure functions returning Result<T, E>
└── templates/
├── list.jinja
└── add.jinja
8. Make the Output Awesome
Let's transform that mono-typed, monochrome string into a richer and more useful UI. Borrowing from web apps setup, we keep the content in a template file, and we define styles in a stylesheet file.
See Styling System for full styling documentation.
8.1 Create the stylesheet
Create src/styles/default.css:
/* Styles for completed todos */
.done {
text-decoration: line-through;
color: gray;
}
/* Style for todo index numbers */
.index {
color: yellow;
}
/* Style for pending todos */
.pending {
font-weight: bold;
color: white;
}
/* Adaptive style for messages */
.message {
color: cyan;
}
@media (prefers-color-scheme: light) {
.pending { color: black; }
}
@media (prefers-color-scheme: dark) {
.pending { color: white; }
}
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:
Introduction to Rendering
Terminal outputs have significant limitations: single font, single size, no graphics. But modern terminals provide many facilities like true colors, light/dark mode support, adaptive sizing, and more. Rich, helpful, and clear outputs are within reach.
The development reality explains why such output remains rare. From a primitive syntax born in the 1970s to the scattered ecosystem support, it's been a major effort to craft great outputs—and logically, it rarely makes sense to invest that time.
standout-render is designed to make crafting polished outputs a breeze by leveraging ideas, tools, and workflows from web applications—a domain in which rich interface authoring has evolved into the best model we've got. (But none of the JavaScript ecosystem chaos, rest assured.)
In this guide, we'll explore what makes great outputs and how standout-render helps you get there.
See Also:
- Styling System - themes, adaptive attributes, CSS syntax
- Templating - MiniJinja, style tags, processing modes
- Introduction to Tabular - column layouts and tables
What Polished Output Entails
If you're building your CLI in Rust, chances are it's not a throwaway grep-formatting script—if that were the case, nothing beats shells. More likely, your program deals with complex data, logic, and computation, and the full power of Rust matters. In the same way, clear, well-presented, and designed outputs improve your users' experience when parsing that information.
Creating good results depends on discipline, consistency, and above all, experimentation—from exploring options to fine-tuning small details. Unlike code, good layout is experimental and takes many iterations: change, view result, change again, judge the new change, and so on.
The classical setup for shell UIs is anything but conducive to this. All presentation is mixed with code, often with complicated logic, if not coupled to it. Additionally, from escape codes to whitespace handling to spreading visual information across many lines of code, it becomes hard to visualize and change things.
The edit-code-compile-run cycle makes small tweaks take minutes. Sometimes a full hour for a minor change. In that scenario, it's no surprise that people don't bother.
Our Example: A Report Generator
We'll use a simple report generator to demonstrate the rendering layer. Here's our data:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] pub struct Task { pub title: String, pub status: Status, } #[derive(Serialize)] pub struct Report { pub message: Option<String>, pub tasks: Vec<Task>, } }
Our goal: transform this raw data into polished, readable output that adapts to the terminal, respects user preferences, and takes minutes to iterate on—not hours.
The Separation Principle
standout-render is designed around a strict separation of data and presentation. This isn't just architectural nicety—it unlocks a fundamentally better workflow.
Without Separation
Here's the typical approach, tangling logic and output:
#![allow(unused)] fn main() { fn print_report(tasks: &[Task]) { println!("\x1b[1;36mYour Tasks\x1b[0m"); println!("──────────"); for (i, task) in tasks.iter().enumerate() { let marker = if matches!(task.status, Status::Done) { "[x]" } else { "[ ]" }; println!("{}. {} {}", i + 1, marker, task.title); } println!("\n{} tasks total", tasks.len()); } }
Problems:
- Escape codes are cryptic and error-prone
- Changes require recompilation
- Logic and presentation are intertwined
- Testing is brittle
- No easy way to support multiple output formats
With Separation
The same output, properly separated:
#![allow(unused)] fn main() { use standout_render::{render, Theme}; use console::Style; // Data preparation (your logic layer) let report = Report { message: Some(format!("{} tasks total", tasks.len())), tasks, }; // Theme definition (can be in a separate CSS/YAML file) let theme = Theme::new() .add("title", Style::new().cyan().bold()) .add("done", Style::new().green()) .add("pending", Style::new().yellow()) .add("muted", Style::new().dim()); // Template (can be in a separate .jinja file) let template = r#" [title]Your Tasks[/title] ────────── {% for task in tasks %} [{{ task.status }}]{{ task.status }}[/{{ task.status }}] {{ task.title }} {% endfor %} {% if message %}[muted]{{ message }}[/muted]{% endif %} "#; let output = render(template, &report, &theme)?; print!("{}", output); }
Now:
- Logic is testable without output concerns
- Presentation is declarative and readable
- Styles are centralized and named semantically
- Changes to appearance don't require recompilation (with file-based templates)
Quick Iteration and Workflow
The separation principle enables a radically better workflow. Here's what standout-render provides:
1. File-Based Flow
Dedicated files for templates and styles:
- Lower risk of breaking code—especially relevant for non-developer types like technical designers
- Simpler diffs and easier navigation
- Trivial to experiment with variations (duplicate files, swap names)
Directory structure:
src/
├── main.rs
└── templates/
└── report.jinja
styles/
└── default.css
2. Hot Live Reload
During development, you edit the template or styles and re-run. No compilation. No long turnaround.
This changes the entire experience. You can make and verify small adjustments in seconds. You can extensively fine-tune output quickly, then polish the full app in a focused session. Time efficiency aside, the quick iterative cycles encourage caring about smaller details, consistency—the things you forgo when iteration is painful.
(When released, files can be compiled into the binary using embedded macros, costing no performance or path-handling headaches in distribution.)
See File System Resources for details on how hot reload works.
Best-of-Breed Specialized Formats
Templates: MiniJinja (Default)
standout-render uses MiniJinja templates by default—a Rust implementation of Jinja2, a de facto standard for rich and powerful templating. The simple syntax and powerful features let you map template text to actual output much easier than println! spreads.
Alternative engines available: For simpler templates or smaller binaries, see Template Engines for lightweight alternatives like
SimpleEngine.
{% if message %}[accent]{{ message }}[/accent]{% endif %}
{% for task in tasks %}
[{{ task.status }}]{{ task.status | upper }}[/{{ task.status }}] {{ task.title }}
{% endfor %}
Benefits:
- Simple, readable syntax
- Powerful control flow (loops, conditionals, filters)
- Partials support: templates can include other templates, enabling reuse
- Custom filters: for complex presentation needs, write small bits of code and keep templates clean
See Templating for template filters and advanced usage.
Styles: CSS Themes
The styling layer uses CSS files with the familiar syntax you already know, but with simpler semantics tailored for terminals:
.title {
color: cyan;
font-weight: bold;
}
.done { color: green; }
.blocked { color: red; }
.pending { color: yellow; }
/* Adaptive for light/dark mode */
@media (prefers-color-scheme: light) {
.panel { color: black; }
}
@media (prefers-color-scheme: dark) {
.panel { color: white; }
}
Features:
- Adaptive attributes: a style can render different values for light and dark modes
- Theming support: swap the entire visual appearance at once
- True color: RGB values for precise colors (
#ff6b35or[255, 107, 53]) - Aliases: semantic names resolve to visual styles (
commit-message: title)
YAML syntax is also supported as an alternative. See Styling System for complete style options.
Template Integration with Styling
Styles are applied with BBCode-like syntax: [style]content[/style]. A familiar, simple, and accessible form.
[title]Your Tasks[/title]
{% for task in tasks %}
[{{ task.status }}]{{ task.title }}[/{{ task.status }}]
{% endfor %}
Style tags:
- Nest properly:
[outer][inner]text[/inner][/outer] - Can span multiple lines
- Can contain template logic:
[title]{% if x %}{{ x }}{% endif %}[/title]
Output Modes: Rich, Plain, and Debug
standout-render processes style tags differently based on the output mode:
#![allow(unused)] fn main() { use standout_render::{render_with_output, OutputMode}; // Rich terminal output (ANSI codes) let rich = render_with_output(template, &data, &theme, OutputMode::Term)?; // Plain text (strips style tags) let plain = render_with_output(template, &data, &theme, OutputMode::Text)?; // Debug mode (keeps tags visible) let debug = render_with_output(template, &data, &theme, OutputMode::TermDebug)?; }
Single template for rich and plain text. The same template serves both—no duplication needed.
#![allow(unused)] fn main() { // Auto-detect based on terminal capabilities let output = render_with_output(template, &data, &theme, OutputMode::Auto)?; }
In auto mode:
- TTY with color support → rich output
- Pipe or redirect → plain text
For standout framework users: The framework's
--outputflag automatically sets the output mode. See the standout documentation for CLI integration.
Debug Mode
Use OutputMode::TermDebug for debugging:
[title]Your Tasks[/title]
[pending]pending[/pending] Implement auth
[done]done[/done] Fix tests
Style tags remain visible, making it easy to verify correct placement. Useful for testing and automation tools.
Tabular Layout
Many outputs are lists of things—log entries, servers, tasks. These benefit from vertically aligned layouts. Aligning fields seems simple at first, but when you factor in ANSI awareness, flexible size ranges, wrapping behavior, truncation, justification, and expanding cells, it becomes really hard.
Tabular gives you a declarative API, both in Rust and in templates, that handles all of this:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
{% for task in tasks %}
{{ t.row([loop.index, task.status | style_as(task.status), task.title]) }}
{% endfor %}
Output adapts to terminal width:
1. pending Implement user authentication
2. done Review pull request #142
3. pending Update dependencies
Features:
- Fixed, range, fill, and fractional widths
- Truncation (start, middle, end) with custom ellipsis
- Word wrapping for long content
- Per-column styling
- Automatic field extraction from structs
See Introduction to Tabular for a comprehensive walkthrough.
Structured Output
Beyond textual output, standout-render supports structured formats:
#![allow(unused)] fn main() { use standout_render::{render_auto, OutputMode}; // For Term/Text: renders template // For Json/Yaml/etc: serializes data directly let json_output = render_auto(template, &data, &theme, OutputMode::Json)?; let yaml_output = render_auto(template, &data, &theme, OutputMode::Yaml)?; }
Structured output for free. Because your data is Serialize-able, JSON/YAML outputs work automatically. Automation (tests, scripts, other programs) no longer needs to reverse-engineer data from formatted output.
Same data types—different output format. This enables API-like behavior from CLI apps without writing separate code paths.
Putting It All Together
Here's a complete example:
use standout_render::{render, Theme}; use console::Style; use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] pub struct Task { pub title: String, pub status: Status, } #[derive(Serialize)] pub struct Report { pub message: Option<String>, pub tasks: Vec<Task>, } fn main() -> Result<(), Box<dyn std::error::Error>> { let theme = Theme::from_yaml(r#" title: { fg: cyan, bold: true } done: green pending: yellow muted: { dim: true } "#)?; let tasks = vec![ Task { title: "Implement user authentication".into(), status: Status::Pending }, Task { title: "Review pull request #142".into(), status: Status::Done }, Task { title: "Update dependencies".into(), status: Status::Pending }, ]; let pending_count = tasks.iter() .filter(|t| matches!(t.status, Status::Pending)) .count(); let report = Report { message: Some(format!("{} pending", pending_count)), tasks, }; let template = r#" [title]My Tasks[/title] {% for task in tasks %} {{ loop.index }}. [{{ task.status }}]{{ task.status }}[/{{ task.status }}] {{ task.title }} {% endfor %} {% if message %}[muted]{{ message }}[/muted]{% endif %} "#; let output = render(template, &report, &theme)?; print!("{}", output); Ok(()) }
Output (terminal):
My Tasks
1. pending Implement user authentication
2. done Review pull request #142
3. pending Update dependencies
2 pending
With colors, "pending" appears yellow, "done" appears green.
Summary
standout-render transforms CLI output from a chore into a pleasure:
-
Separation of concerns: Data stays separate from templates. Templates define structure. Styles control appearance.
-
Fast iteration: Hot reload means edit-and-see in seconds, not minutes. This changes what's practical.
-
Familiar tools: MiniJinja for templates (Jinja2 syntax), CSS or YAML for styles. No new languages to learn.
-
Graceful degradation: One template serves rich terminals, plain pipes, and everything in between.
-
Structured output for free: JSON, YAML outputs work automatically from your serializable types.
-
Tabular layouts: Declarative column definitions handle alignment, wrapping, truncation, and ANSI-awareness.
The rendering system makes it practical to care about details. When iteration is fast and changes are safe, polish becomes achievable—not aspirational.
For complete API details, see the API documentation.
Introduction to Tabular
Polished terminal output requires two things: good formatting (see Rendering Introduction) and good layouts. For text-only, non-interactive output, layout mostly means aligning things vertically and controlling how multiple pieces of information are presented together.
Tabular provides a declarative column system with powerful primitives for sizing (fixed, range, fill, fractions), positioning (anchor to right), overflow handling (clip, wrap, truncate), cell alignment, and automated per-column styling.
Tabular is not only about tables. Any listing where items have multiple fields that benefit from vertical alignment is a good candidate—log entries with authors, timestamps, and messages; file listings with names, sizes, and dates; task lists with IDs, titles, and statuses. Add headers, separators, and borders to a tabular layout, and you have a table.
Key capabilities:
- Flexible sizing: Fixed widths, min/max ranges, fill remaining space, fractional proportions
- Smart truncation: Truncate at start, middle, or end with custom ellipsis
- Word wrapping: Wrap long content across multiple lines with proper alignment
- Unicode-aware: CJK characters, combining marks, and ANSI codes handled correctly
- Dynamic styling: Style columns or individual values based on content
In this guide, we will walk from a simple listing to a polished table, exploring the available features.
See Also:
- Introduction to Rendering - templates and styles overview
- Styling System - themes and adaptive styles
Our Example: Task List
We'll build the output for a task list. This is a perfect Tabular use case: each task has an index, title, and status. We want them aligned, readable, and visually clear at a glance.
Here's our data:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Done } #[derive(Clone, Serialize)] struct Task { title: String, status: Status, } let tasks = vec![ Task { title: "Implement user authentication".into(), status: Status::Pending }, Task { title: "Fix payment gateway timeout".into(), status: Status::Pending }, Task { title: "Update documentation for API v2".into(), status: Status::Done }, Task { title: "Review pull request #142".into(), status: Status::Pending }, ]; }
Let's progressively build this from raw output to a polished, professional listing.
Step 1: The Problem with Plain Output
Without any formatting, a naive approach might look like this:
{% for task in tasks %}
{{ loop.index }}. {{ task.title }} {{ task.status }}
{% endfor %}
Output:
1. Implement user authentication pending
2. Fix payment gateway timeout pending
3. Update documentation for API v2 done
4. Review pull request #142 pending
This is barely readable. Fields run together, nothing aligns, and scanning the list requires mental parsing of each line. Let's fix that.
Step 2: Basic Column Alignment with col
The simplest improvement is the col filter. It pads (or truncates) each value to a fixed width:
{% for task in tasks %}
{{ loop.index | col(4) }} {{ task.status | col(10) }} {{ task.title | col(40) }}
{% endfor %}
Output:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
Already much better. Each column aligns vertically, making it easy to scan. But we've hardcoded widths, and if a title is too long, it gets truncated with ....
Key insight: The
colfilter handles Unicode correctly. CJK characters count as 2 columns, combining marks don't add width, and ANSI escape codes are preserved but not counted.
Step 3: Structured Layout with tabular()
For more control, use the tabular() function. This creates a formatter that you configure once and use for all rows:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": 40}
], separator=" ") %}
{% for task in tasks %}
{{ t.row([loop.index, task.status, task.title]) }}
{% endfor %}
The output looks the same, but now the column definitions are centralized. This becomes powerful when we start adding features.
Step 4: Flexible Widths
Hardcoded widths are fragile. What if the terminal is wider or narrower? Tabular offers flexible width strategies:
| Width | Meaning |
|---|---|
8 | Exactly 8 columns (fixed) |
{"min": 10} | At least 10, grows to fit content |
{"min": 10, "max": 30} | Between 10 and 30 |
"fill" | Takes all remaining space |
"2fr" | 2 parts of remaining (proportional) |
Let's make the title column expand to fill available space:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Now on an 80-column terminal:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
On a 120-column terminal, the title column automatically expands to use the extra space.
The layout adapts to the available space.
Step 5: Right-Align Numbers
Numbers and indices look better right-aligned. Use the align option:
{% set t = tabular([
{"name": "index", "width": 4, "align": "right"},
{"name": "status", "width": 10},
{"name": "title", "width": "fill"}
], separator=" ") %}
Output:
1. pending Implement user authentication
2. pending Fix payment gateway timeout
3. done Update documentation for API v2
4. pending Review pull request #142
The indices now align on the right edge of their column.
Step 6: Anchoring Columns
Sometimes you want a column pinned to the terminal's right edge, regardless of how other columns resize. Use anchor:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "title", "width": "fill"},
{"name": "status", "width": 10, "anchor": "right"}
], separator=" ") %}
Now the status column is always at the right edge. If the terminal is 100 columns or 200, the status stays anchored. The fill column absorbs the extra space between fixed columns and anchored columns.
Step 7: Handling Long Content
What happens when a title is longer than its column? By default, Tabular truncates at the end with .... But you have options:
Truncate at Different Positions
{"name": "title", "width": 30, "overflow": "truncate"} {# "Very long title th..." #}
{"name": "title", "width": 30, "overflow": {"truncate": {"at": "start"}}} {# "...itle that is long" #}
{"name": "title", "width": 30, "overflow": {"truncate": {"at": "middle"}}} {# "Very long...is long" #}
Middle truncation is perfect for file paths where both the start and end matter: /home/user/.../important.txt
Wrap to Multiple Lines
For descriptions or messages, wrapping is often better than truncating:
{% set t = tabular([
{"name": "index", "width": 4},
{"name": "title", "width": 40, "overflow": "wrap"},
{"name": "status", "width": 10}
], separator=" ") %}
If a title exceeds 40 columns, it wraps:
1. Implement comprehensive error handling pending
for all API endpoints with proper
logging and user feedback
2. Quick fix done
The wrapped lines are indented to align with the column.
Step 8: 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 9: 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 10: 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 11: 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 │
└──────┴────────────────────────────────────┘
Step 12: The Complete Example
Putting it all together, here's a polished task list:
{% set t = table([
{"name": "#", "width": 4, "style": "muted"},
{"name": "Status", "width": 10},
{"name": "Title", "width": "fill", "overflow": {"truncate": {"at": "middle"}}}
], border="rounded", header_style="bold", separator=" | ") %}
{{ t.header_row() }}
{{ t.separator_row() }}
{% for task in tasks %}
{{ t.row([loop.index, task.status | style_as(task.status), task.title]) }}
{% endfor %}
{{ t.bottom_border() }}
Output (80 columns, with styling):
╭──────┬────────────┬───────────────────────────────────────────────────────╮
│ # │ Status │ Title │
├──────┼────────────┼───────────────────────────────────────────────────────┤
│ 1 │ pending │ Implement user authentication │
│ 2 │ pending │ Fix payment gateway timeout │
│ 3 │ done │ Update documentation for API v2 │
│ 4 │ pending │ Review pull request #142 │
╰──────┴────────────┴───────────────────────────────────────────────────────╯
Features in use:
- Rounded borders for a modern look
- Muted styling on index column for visual hierarchy
- Fill width on title to use available space
- Middle truncation for titles that exceed the column
- Dynamic status colors via
style_as
Using Tabular from Rust
Everything shown in templates is also available in Rust:
#![allow(unused)] fn main() { use standout_render::tabular::{TabularFormatter, ColumnSpec, Overflow, Alignment}; let columns = vec![ ColumnSpec::fixed(4).header("#").style("muted"), ColumnSpec::fixed(10).header("Status"), ColumnSpec::fill().header("Title").overflow(Overflow::truncate_middle()), ]; let formatter = TabularFormatter::new(columns) .separator(" | ") .terminal_width(80); // Format individual rows for (i, task) in tasks.iter().enumerate() { let row = formatter.format_row(&[ &(i + 1).to_string(), &task.status.to_string(), &task.title, ]); println!("{}", row); } }
Summary
Tabular transforms raw data into polished, scannable output with minimal effort:
- Start simple - use
colfilter for quick alignment - Structure with
tabular()- centralize column definitions - Flex with widths - use
fill, bounded ranges, and fractions - Align content - right-align numbers and dates
- Anchor columns - pin important data to edges
- Handle overflow - truncate intelligently or wrap
- Add visual hierarchy - style columns and values dynamically
- Extract automatically - let
row_from()pull fields from structs - Decorate as tables - add borders, headers, and separators
The declarative approach means your layout adapts to terminal width, handles Unicode correctly, and remains maintainable as your data evolves.
For complete API details, see the API documentation.
The Styling System
standout-render uses a theme-based styling system where named styles are applied to content through bracket notation tags. Instead of embedding ANSI codes in your templates, you define semantic style names (error, title, muted) and let the theme decide the visual representation.
This separation provides several benefits:
- Readability: Templates use meaningful names, not escape codes
- Maintainability: Change colors in one place, update everywhere
- Adaptability: Themes can respond to light/dark mode automatically
- Consistency: Enforce visual hierarchy across your application
Themes
A Theme is a named collection of styles. Each style maps a name (like title or error) to visual attributes (bold cyan, dim red, etc.).
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()); }
YAML Themes
For file-based configuration, YAML provides a concise syntax:
# theme.yaml
title:
fg: cyan
bold: true
error:
fg: red
bold: true
muted:
dim: true
success:
fg: green
# Shorthand: single attribute or space-separated
warning: yellow
emphasis: "bold italic"
Load YAML themes:
#![allow(unused)] fn main() { use standout_render::Theme; // From string let theme = Theme::from_yaml(yaml_content)?; // From file (with hot reload in debug builds) let theme = Theme::from_yaml_file("styles/theme.yaml")?; }
CSS Themes
For developers who prefer standard CSS syntax, standout-render supports a subset of CSS Level 3 tailored for terminals:
/* theme.css */
.title {
color: cyan;
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
.muted {
opacity: 0.5; /* maps to dim */
}
.success {
color: green;
}
/* Shorthand works too */
.warning { color: yellow; }
Load CSS themes:
#![allow(unused)] fn main() { use standout_render::Theme; let theme = Theme::from_css(css_content)?; let theme = Theme::from_css_file("styles/theme.css")?; }
CSS is the recommended format for new projects. It enables syntax highlighting in editors, linting tools, and familiarity for web developers.
Supported Attributes
Colors
| Attribute | CSS Property | Description |
|---|---|---|
fg | color | Foreground (text) color |
bg | background | Background color |
Color Formats
# Named colors (16 ANSI colors)
fg: red
fg: green
fg: blue
fg: cyan
fg: magenta
fg: yellow
fg: white
fg: black
# Bright variants
fg: bright_red
fg: bright_green
# 256-color palette (0-255)
fg: 208
# RGB hex
fg: "#ff6b35"
fg: "#f63" # shorthand
# RGB array
fg: [255, 107, 53]
Text Attributes
| YAML | CSS | Effect |
|---|---|---|
bold: true | font-weight: bold | Bold text |
dim: true | opacity: 0.5 | Dimmed/faint text |
italic: true | font-style: italic | Italic text |
underline: true | text-decoration: underline | Underlined text |
blink: true | text-decoration: blink | Blinking text |
reverse: true | — | Swap fg/bg colors |
hidden: true | visibility: hidden | Hidden text |
strikethrough: true | text-decoration: line-through | Strikethrough |
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:
bold: true # Shared across all modes
fg: gray # Default/fallback
light:
fg: black # Override for light mode
dark:
fg: white # Override for dark mode
When resolving panel in dark mode:
- Start with base attributes (
bold: true,fg: gray) - Merge dark overrides (
fg: whitereplacesfg: gray) - 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.
CSS Syntax
.panel {
font-weight: bold;
color: gray;
}
@media (prefers-color-scheme: light) {
.panel { color: black; }
}
@media (prefers-color-scheme: dark) {
.panel { color: white; }
}
Programmatic API
#![allow(unused)] fn main() { use standout_render::Theme; use console::{Style, Color}; let theme = Theme::new() .add_adaptive( "panel", Style::new().bold(), // Base (shared) Some(Style::new().fg(Color::Black)), // Light mode Some(Style::new().fg(Color::White)), // Dark mode ); }
Color Mode Detection
standout-render auto-detects the OS color scheme:
#![allow(unused)] fn main() { use standout_render::{detect_color_mode, ColorMode}; let mode = detect_color_mode(); match mode { ColorMode::Light => println!("Light mode"), ColorMode::Dark => println!("Dark mode"), } }
Override for testing:
#![allow(unused)] fn main() { use standout_render::set_theme_detector; set_theme_detector(|| ColorMode::Dark); // Force dark mode }
Style Aliasing
Aliases let semantic names resolve to visual styles. This is useful when multiple concepts share the same appearance:
# Define the visual style once
title:
fg: cyan
bold: true
# Aliases
commit-message: title
section-header: title
heading: title
Now [commit-message], [section-header], and [heading] all render identically to [title].
Benefits:
- Templates use meaningful, context-specific names
- Visual changes propagate automatically
- Refactoring visual design doesn't touch templates
Aliases can chain: a → b → c → concrete style. Cycles are detected and rejected at load time.
Unknown Style Tags
When a template references a style not defined in the theme, standout-render handles it gracefully:
| Output Mode | Behavior |
|---|---|
Term | Unknown tags get a ? marker: [unknown?]text[/unknown?] |
Text | Tags stripped (plain text) |
TermDebug | Tags preserved as-is |
The ? marker helps catch typos during development without crashing production apps.
Validation
For strict checking at startup:
#![allow(unused)] fn main() { use standout_render::validate_template; let errors = validate_template(template, &sample_data, &theme); if !errors.is_empty() { for error in errors { eprintln!("Unknown style: {}", error.tag_name); } std::process::exit(1); } }
Best Practices
Semantic, Presentation, and Visual Layers
Organize your styles in three conceptual layers:
1. Visual primitives (low-level appearance):
_cyan-bold:
fg: cyan
bold: true
_dim:
dim: true
_red-bold:
fg: red
bold: true
2. Presentation roles (UI concepts):
heading: _cyan-bold
secondary: _dim
danger: _red-bold
3. Semantic names (domain concepts):
# In templates, use these
task-title: heading
task-status-done: success
task-status-pending: warning
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:
# theme-default.yaml - your app's default look
# theme-colorblind.yaml - accessibility variant
# theme-monochrome.yaml - for piped output
API Reference
Theme Creation
#![allow(unused)] fn main() { // Empty theme let theme = Theme::new(); // From YAML string let theme = Theme::from_yaml(yaml_str)?; // From CSS string let theme = Theme::from_css(css_str)?; // From files (hot reload in debug) let theme = Theme::from_yaml_file(path)?; let theme = Theme::from_css_file(path)?; }
Adding Styles
#![allow(unused)] fn main() { // Static style theme.add("name", Style::new().bold()); // Adaptive style theme.add_adaptive("name", base_style, light_override, dark_override); // Alias theme.add("alias", "target_style"); }
Resolving Styles
#![allow(unused)] fn main() { // Get resolved style for current color mode let style: Option<Style> = theme.get("title"); // Get style for specific mode let style = theme.get_for_mode("panel", ColorMode::Dark); }
Color Mode
#![allow(unused)] fn main() { use standout_render::{detect_color_mode, set_theme_detector, ColorMode}; // Auto-detect let mode = detect_color_mode(); // Override (for testing) set_theme_detector(|| ColorMode::Light); }
Templating
standout-render uses a two-pass templating system that combines a template engine for logic and data binding with a custom BBCode-like syntax for styling. This separation keeps templates readable while providing full control over both content and presentation.
The default engine is MiniJinja (Jinja2-compatible), but alternative engines are available. See Template Engines for options including a lightweight SimpleEngine for reduced binary size.
Two-Pass Rendering Pipeline
Templates are processed in two distinct passes:
Template + Data → [Pass 1: MiniJinja] → Text with style tags → [Pass 2: BBParser] → Final output
Pass 1 - MiniJinja: Standard template processing. Variables are substituted, control flow executes, filters apply.
Pass 2 - BBParser: Style tag processing. Bracket-notation tags are converted to ANSI escape codes (or stripped, depending on output mode).
Example
Template: [title]{{ name }}[/title] has {{ count }} items
Data: { name: "Report", count: 42 }
After Pass 1: [title]Report[/title] has 42 items
After Pass 2: \x1b[1;36mReport\x1b[0m has 42 items (or plain: "Report has 42 items")
This separation means:
- Template logic (loops, conditionals) is handled by MiniJinja—a mature, well-documented engine
- Style application is a simple, predictable transformation
- You can debug each pass independently
MiniJinja Basics
MiniJinja implements Jinja2 syntax, a widely-used templating language. Here's a quick overview:
Variables
{{ variable }}
{{ object.field }}
{{ list[0] }}
Control Flow
{% if condition %}
Show this
{% elif other_condition %}
Show that
{% else %}
Default
{% endif %}
{% for item in items %}
{{ loop.index }}. {{ item.name }}
{% endfor %}
Filters
{{ name | upper }}
{{ list | length }}
{{ value | default("N/A") }}
{{ text | truncate(20) }}
Comments
{# This is a comment and won't appear in output #}
For comprehensive MiniJinja documentation, see the MiniJinja documentation.
Style Tags
Style tags use BBCode-like bracket notation to apply named styles from your theme:
[style-name]content to style[/style-name]
Basic Usage
[title]Report Summary[/title]
[error]Something went wrong![/error]
[muted]Last updated: {{ timestamp }}[/muted]
Nesting
Tags can nest properly:
[outer][inner]nested content[/inner][/outer]
Spanning Lines
Tags can span multiple lines:
[panel]
This is a multi-line
block of styled content
[/panel]
With Template Logic
Style tags and MiniJinja work together seamlessly:
[title]{% if custom_title %}{{ custom_title }}{% else %}Default Title{% endif %}[/title]
{% for task in tasks %}
[{{ task.status }}]{{ task.title }}[/{{ task.status }}]
{% endfor %}
The second example shows dynamic style names—the style applied depends on the value of task.status.
Processing Modes
Pass 2 (BBParser) processes style tags differently based on the output mode:
| Mode | Behavior | Use Case |
|---|---|---|
Term | Replace tags with ANSI escape codes | Rich terminal output |
Text | Strip tags completely | Plain text, pipes, files |
TermDebug | Keep tags as literal text | Debugging, testing |
Example
Template: [title]Hello[/title]
- Term:
\x1b[1;36mHello\x1b[0m(rendered as cyan bold) - Text:
Hello - TermDebug:
[title]Hello[/title]
Setting the Mode
#![allow(unused)] fn main() { use standout_render::{render_with_output, OutputMode}; // Rich terminal let output = render_with_output(template, &data, &theme, OutputMode::Term)?; // Plain text let output = render_with_output(template, &data, &theme, OutputMode::Text)?; // Debug (tags visible) let output = render_with_output(template, &data, &theme, OutputMode::TermDebug)?; // Auto-detect based on TTY let output = render_with_output(template, &data, &theme, OutputMode::Auto)?; }
Auto Mode
OutputMode::Auto detects the appropriate mode:
- If stdout is a TTY with color support →
Term - If stdout is a pipe or redirect →
Text
For standout framework users: The framework's
--outputCLI flag automatically sets the output mode. See standout documentation for details.
Built-in Filters
Beyond MiniJinja's standard filters, standout-render provides formatting filters:
Column Formatting
{{ value | col(10) }} {# pad/truncate to 10 chars #}
{{ value | col(20, align="right") }} {# right-align in 20 chars #}
{{ value | col(15, truncate="middle") }} {# truncate in middle #}
{{ value | col(15, truncate="start", ellipsis="...") }}
Padding
{{ "42" | pad_left(8) }} {# " 42" #}
{{ "hi" | pad_right(8) }} {# "hi " #}
{{ "hi" | pad_center(8) }} {# " hi " #}
Truncation
{{ long_text | truncate_at(20) }} {# "Very long text th..." #}
{{ path | truncate_at(30, "middle", "...") }} {# "/home/.../file.txt" #}
{{ text | truncate_at(20, "start") }} {# "...end of the text" #}
Display Width
{% if value | display_width > 20 %}
{{ value | truncate_at(20) }}
{% else %}
{{ value }}
{% endif %}
Returns visual width (handles Unicode—CJK characters count as 2).
Style Application
{{ value | style_as("error") }} {# wraps in [error]...[/error] #}
{{ task.status | style_as(task.status) }} {# dynamic: [pending]pending[/pending] #}
Template Registry
When using the Renderer struct, templates are resolved by name through a registry:
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; // Add inline template renderer.add_template("greeting", "Hello, [name]{{ name }}[/name]!")?; // Add directory of templates renderer.add_template_dir("./templates")?; // Render by name let output = renderer.render("greeting", &data)?; }
Resolution Priority
- Inline templates (added via
add_template()) - Directory templates (from
add_template_dir())
File Extensions
Supported extensions (in priority order): .jinja, .jinja2, .j2, .stpl, .txt
When you request "report", the registry checks:
- Inline template named
"report" report.jinjain registered directoriesreport.jinja2,report.j2,report.stpl,report.txt(lower priority)
The .stpl extension is for SimpleEngine templates. See Template Engines for details.
Template Names
Template names are derived from relative paths:
templates/
├── greeting.jinja → "greeting"
├── reports/
│ └── summary.jinja → "reports/summary"
└── errors/
└── 404.jinja → "errors/404"
Including Templates
Templates can include other templates using MiniJinja's include syntax:
{# main.jinja #}
[title]{{ title }}[/title]
{% include "partials/header.jinja" %}
{% for item in items %}
{% include "partials/item.jinja" %}
{% endfor %}
{% include "partials/footer.jinja" %}
This enables reusable components across your application.
Context Variables
Beyond your data, you can inject additional context into templates:
#![allow(unused)] fn main() { use standout_render::{render_with_vars, OutputMode}; use std::collections::HashMap; let mut vars = HashMap::new(); vars.insert("version", "1.0.0"); vars.insert("app_name", "MyApp"); let output = render_with_vars( "{{ app_name }} v{{ version }}: {{ message }}", &data, &theme, OutputMode::Term, vars, )?; }
When handler data and context variables have the same key, handler data wins. Context is supplementary.
Structured Output
For machine-readable output (JSON, YAML, CSV), templates are bypassed entirely:
#![allow(unused)] fn main() { use standout_render::{render_auto, OutputMode}; // Template is used for Term/Text modes // Data is serialized directly for Json/Yaml/Csv let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
| Mode | Behavior |
|---|---|
Term | Render template, apply styles |
Text | Render template, strip styles |
TermDebug | Render template, keep style tags |
Json | serde_json::to_string_pretty(data) |
Yaml | serde_yaml::to_string(data) |
Csv | Flatten and format as CSV |
This means your serializable data types automatically support structured output without additional code.
Validation
Check templates for unknown style tags before deploying:
#![allow(unused)] fn main() { use standout_render::validate_template; let errors = validate_template(template, &sample_data, &theme); if !errors.is_empty() { for error in &errors { eprintln!("Unknown style tag: [{}]", error.tag_name); } } }
Validation catches:
- Misspelled style names
- References to undefined styles
- Mismatched opening/closing tags
API Reference
Render Functions
#![allow(unused)] fn main() { use standout_render::{ render, // Basic: template + data + theme render_with_output, // With explicit output mode render_with_mode, // With output mode + color mode render_with_vars, // With extra context variables render_auto, // Auto-dispatch template vs serialize render_auto_with_context, }; // Basic let output = render(template, &data, &theme)?; // With output mode let output = render_with_output(template, &data, &theme, OutputMode::Term)?; // With color mode override (for testing) let output = render_with_mode(template, &data, &theme, OutputMode::Term, ColorMode::Dark)?; // Auto (template for text modes, serialize for structured) let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
Renderer Struct
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; renderer.add_template("name", "content")?; renderer.add_template_dir("./templates")?; let output = renderer.render("name", &data)?; let output = renderer.render_with_mode("name", &data, OutputMode::Text)?; }
File System Resources
standout-render supports file-based templates and stylesheets that can be hot-reloaded during development and embedded into release binaries. This workflow combines the rapid iteration of interpreted languages with the distribution simplicity of compiled binaries.
The Development Workflow
During development, you want to:
- Edit a template or stylesheet
- Re-run your program
- See changes immediately
During release, you want:
- A single binary with no external dependencies
- No file paths to manage
- No risk of missing assets
standout-render supports both modes with the same code.
Hot Reload
In debug builds (debug_assertions enabled), file-based resources are re-read from disk on each render. This means:
- Edit
templates/report.jinja→ re-run → see changes - Edit
styles/theme.css→ re-run → see new styles - No recompilation needed
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; renderer.add_template_dir("./templates")?; // In debug: reads from disk each time // In release: uses cached content let output = renderer.render("report", &data)?; }
How It Works
The Renderer tracks the source of each template:
- Inline: Content provided as a string (always cached)
- File-based: Path recorded, content read on demand
In debug builds, file-based templates are re-read before each render. In release builds, content is cached after first load.
File Registries
Both templates and stylesheets use a registry pattern: a map from names to content.
Template Registry
#![allow(unused)] fn main() { use standout_render::TemplateRegistry; let mut registry = TemplateRegistry::new(); // Add from directory registry.add_dir("./templates")?; // Add inline registry.add("greeting", "Hello, {{ name }}!")?; // Resolve by name let content = registry.get("report")?; }
Name resolution from paths:
./templates/
├── greeting.jinja → "greeting"
├── reports/
│ ├── summary.jinja → "reports/summary"
│ └── detail.jinja → "reports/detail"
└── partials/
└── header.jinja → "partials/header"
Names are relative paths without extensions.
Stylesheet Registry
#![allow(unused)] fn main() { use standout_render::StylesheetRegistry; let mut registry = StylesheetRegistry::new(); registry.add_dir("./styles")?; let theme = registry.get("default")?; // loads default.css or default.yaml }
Supported Extensions
Templates
| Extension | Priority |
|---|---|
.jinja | 1 (highest) |
.jinja2 | 2 |
.j2 | 3 |
.txt | 4 (lowest) |
If both report.jinja and report.txt exist, report.jinja is used.
Stylesheets
| Extension | Format |
|---|---|
.css | CSS syntax |
.yaml | YAML syntax |
.yml | YAML syntax |
Embedding Resources
For release builds, embed resources directly into the binary using the provided macros:
Embedding Templates
#![allow(unused)] fn main() { use standout_render::{embed_templates, EmbeddedTemplates}; // Embed all .jinja files from a directory let templates: EmbeddedTemplates = embed_templates!("src/templates"); // Use with Renderer let mut renderer = Renderer::new(theme)?; renderer.add_embedded_templates(templates)?; }
Embedding Stylesheets
#![allow(unused)] fn main() { use standout_render::{embed_styles, EmbeddedStyles}; // Embed all .css/.yaml files from a directory let styles: EmbeddedStyles = embed_styles!("src/styles"); // Load a specific theme let theme = styles.get("default")?; }
Hybrid Approach
Combine embedded defaults with optional file overrides:
#![allow(unused)] fn main() { use standout_render::{Renderer, embed_templates}; let embedded = embed_templates!("src/templates"); let mut renderer = Renderer::new(theme)?; // Add embedded first (lower priority) renderer.add_embedded_templates(embedded)?; // Add file directory (higher priority, overrides embedded) if Path::new("./templates").exists() { renderer.add_template_dir("./templates")?; } }
This pattern lets users customize templates without modifying the binary.
Resolution Priority
When resolving a template or stylesheet name, sources are checked in priority order:
- Inline (added via
add()oradd_template()) - File-based directories (in order added, later = higher priority)
- Embedded (lowest priority)
Example:
#![allow(unused)] fn main() { renderer.add_embedded_templates(embedded)?; // Priority 1 (lowest) renderer.add_template_dir("./vendor")?; // Priority 2 renderer.add_template_dir("./templates")?; // Priority 3 (highest) renderer.add_template("report", "inline")?; // Priority 4 (always wins) }
If "report" exists in all sources, the inline version is used.
Directory Structure
Recommended project layout:
my-cli/
├── src/
│ ├── main.rs
│ ├── templates/ # Templates for embedding
│ │ ├── list.jinja
│ │ ├── detail.jinja
│ │ └── partials/
│ │ └── header.jinja
│ └── styles/ # Stylesheets for embedding
│ ├── default.css
│ └── colorblind.css
├── templates/ # Development overrides (gitignored)
└── styles/ # Development overrides (gitignored)
In main.rs:
#![allow(unused)] fn main() { let embedded_templates = embed_templates!("src/templates"); let embedded_styles = embed_styles!("src/styles"); let mut renderer = Renderer::new(theme)?; renderer.add_embedded_templates(embedded_templates)?; // In debug, also check local directories for overrides #[cfg(debug_assertions)] { if Path::new("./templates").exists() { renderer.add_template_dir("./templates")?; } } }
Error Handling
Missing Templates
#![allow(unused)] fn main() { match renderer.render("nonexistent", &data) { Ok(output) => println!("{}", output), Err(e) => { // Template not found in any source eprintln!("Template error: {}", e); } } }
Name Collisions
Same-directory collisions use extension priority (.jinja > .txt).
Cross-directory collisions are resolved by priority order (later directories win).
Invalid Content
Template syntax errors are reported with line numbers:
Template 'report' error at line 15:
unexpected end of template, expected 'endif'
API Reference
TemplateRegistry
#![allow(unused)] fn main() { use standout_render::TemplateRegistry; let mut registry = TemplateRegistry::new(); // Add sources registry.add("name", "content")?; registry.add_dir("./templates")?; registry.add_embedded(embedded_templates)?; // Query let content: Option<&str> = registry.get("name"); let names: Vec<&str> = registry.names(); let exists: bool = registry.contains("name"); }
StylesheetRegistry
#![allow(unused)] fn main() { use standout_render::StylesheetRegistry; let mut registry = StylesheetRegistry::new(); // Add sources registry.add_dir("./styles")?; registry.add_embedded(embedded_styles)?; // Get parsed theme let theme: Theme = registry.get("default")?; let names: Vec<&str> = registry.names(); }
Embed Macros
#![allow(unused)] fn main() { use standout_render::{embed_templates, embed_styles}; // At compile time, reads all matching files and embeds content let templates = embed_templates!("path/to/templates"); let styles = embed_styles!("path/to/styles"); }
Renderer Integration
#![allow(unused)] fn main() { use standout_render::Renderer; let mut renderer = Renderer::new(theme)?; // Templates renderer.add_template("name", "content")?; renderer.add_template_dir("./templates")?; renderer.add_embedded_templates(embedded)?; // Render let output = renderer.render("name", &data)?; }
Introduction to Dispatch
CLI applications typically mix business logic with output formatting: database queries interleaved with println!, validation tangled with ANSI codes, error handling scattered across presentation. The result is code that's hard to test, hard to change, and impossible to reuse.
standout-dispatch enforces a clean separation:
CLI args → Handler (logic) → Data → Renderer (presentation) → Output
- Handlers receive parsed arguments, return serializable data
- Renderers are pluggable callbacks you provide
- Hooks intercept execution at defined points
This isn't just architectural nicety—it unlocks:
- Testable handlers — Pure functions with explicit inputs and outputs
- Swappable renderers — JSON, templates, plain text from the same handler
- Cross-cutting concerns — Auth, logging, transformation via hooks
- Incremental adoption — Migrate one command at a time
The Problem
Here's a typical CLI command implementation:
#![allow(unused)] fn main() { fn list_command(matches: &ArgMatches) { let verbose = matches.get_flag("verbose"); let items = storage::list().expect("failed to list"); println!("\x1b[1;36mItems\x1b[0m"); println!("──────"); for item in &items { if verbose { println!("{}: {} (created: {})", item.id, item.name, item.created); } else { println!("{}: {}", item.id, item.name); } } println!("\n{} items total", items.len()); } }
Problems with this approach:
- Testing is painful — You have to capture stdout and parse it
- No format flexibility — Want JSON output? Write a whole new function
- Error handling is crude —
expector scattered error messages - Logic and presentation intertwined — Can't reuse the logic elsewhere
- Cross-cutting concerns require duplication — Auth checks in every command
The Solution: Handlers Return Data
With standout-dispatch, handlers focus purely on logic:
#![allow(unused)] fn main() { use standout_dispatch::{Handler, Output, CommandContext, HandlerResult}; use serde::Serialize; #[derive(Serialize)] struct ListResult { items: Vec<Item>, total: usize, } fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListResult> { let items = storage::list()?; // Errors propagate naturally Ok(Output::Render(ListResult { total: items.len(), items, })) } }
The handler:
- Receives parsed arguments (
&ArgMatches) and execution context - Returns a
Resultwith serializable data - Contains zero presentation logic
Rendering is handled separately:
#![allow(unused)] fn main() { use standout_dispatch::from_fn; // Simple JSON renderer let render = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); }
Or use a full template engine:
#![allow(unused)] fn main() { let render = from_fn(move |data, view| { my_renderer::render_template(view, data, &theme) }); }
Quick Start
[dependencies]
standout-dispatch = "2.1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
use standout_dispatch::{ FnHandler, Output, CommandContext, HandlerResult, from_fn, extract_command_path, path_to_string, }; use clap::{Command, Arg}; use serde::Serialize; #[derive(Serialize)] struct Greeting { message: String } fn main() -> anyhow::Result<()> { // 1. Define clap command let cmd = Command::new("myapp") .subcommand( Command::new("greet") .arg(Arg::new("name").required(true)) ); // 2. Create handler let greet_handler = FnHandler::new(|matches, _ctx| { let name: &String = matches.get_one("name").unwrap(); Ok(Output::Render(Greeting { message: format!("Hello, {}!", name), })) }); // 3. Create render function let render = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); // 4. Parse and dispatch let matches = cmd.get_matches(); let path = extract_command_path(&matches); if path_to_string(&path) == "greet" { let ctx = CommandContext { command_path: path }; let result = greet_handler.handle(&matches, &ctx)?; if let Output::Render(data) = result { let json = serde_json::to_value(&data)?; let output = render(&json, "greet")?; println!("{}", output); } } Ok(()) }
The Output Enum
Handlers return one of three output types:
#![allow(unused)] fn main() { pub enum Output<T: Serialize> { Render(T), // Data for rendering Silent, // No output (side-effect commands) Binary { // Raw bytes (file exports) data: Vec<u8>, filename: String, }, } }
Output::Render(T)
The common case. Data is passed to your render function:
#![allow(unused)] fn main() { fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let items = storage::list()?; Ok(Output::Render(items)) } }
Output::Silent
For commands with side effects only:
#![allow(unused)] fn main() { fn delete_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let id: &String = matches.get_one("id").unwrap(); storage::delete(id)?; Ok(Output::Silent) } }
Output::Binary
For generating files:
#![allow(unused)] fn main() { fn export_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let data = generate_report()?; let csv_bytes = format_as_csv(&data)?; Ok(Output::Binary { data: csv_bytes.into_bytes(), filename: "report.csv".into(), }) } }
State Management
Handlers access state through CommandContext, which provides two injection mechanisms:
App State (Shared)
Configure long-lived resources at build time:
#![allow(unused)] fn main() { use standout::cli::App; struct Database { /* connection pool */ } struct Config { api_url: String } App::builder() .app_state(Database::connect()?) // Shared across all dispatches .app_state(Config::load()?) .command("list", list_handler, "{{ items }}") .build()? }
Access in handlers via ctx.app_state:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let db = ctx.app_state.get_required::<Database>()?; let config = ctx.app_state.get_required::<Config>()?; Ok(Output::Render(db.list(&config.api_url)?)) } }
Extensions (Per-Request)
Pre-dispatch hooks inject request-scoped state via ctx.extensions:
#![allow(unused)] fn main() { Hooks::new().pre_dispatch(|matches, ctx| { let user_id = matches.get_one::<String>("user").unwrap(); ctx.extensions.insert(UserScope { user_id: user_id.clone() }); Ok(()) }) }
For full details, see App State and Extensions.
Hooks: Cross-Cutting Concerns
Hooks let you intercept execution without modifying handler logic:
#![allow(unused)] fn main() { use standout_dispatch::{Hooks, HookError, RenderedOutput}; let hooks = Hooks::new() // Before handler: validation, auth, inject per-request state .pre_dispatch(|matches, ctx| { if !is_authenticated() { return Err(HookError::pre_dispatch("auth required")); } Ok(()) }) // After handler, before render: transform data .post_dispatch(|_m, _ctx, mut data| { if let Some(obj) = data.as_object_mut() { obj.insert("timestamp".into(), json!(Utc::now().to_rfc3339())); } Ok(data) }) // After render: transform output .post_output(|_m, _ctx, output| { if let RenderedOutput::Text(s) = output { Ok(RenderedOutput::Text(format!("{}\n-- footer", s))) } else { Ok(output) } }); }
Hook Phases
| Phase | Timing | Receives | Can |
|---|---|---|---|
pre_dispatch | Before handler | ArgMatches, &mut Context | Abort execution, inject state |
post_dispatch | After handler, before render | ArgMatches, Context, Data | Transform data |
post_output | After render | ArgMatches, Context, Output | Transform output |
State Injection: Pre-dispatch hooks can inject dependencies via
ctx.extensionsthat handlers retrieve. This enables dependency injection without changing handler signatures. See Handler Contract: Extensions for details.
Hook Chaining
Multiple hooks per phase run sequentially:
#![allow(unused)] fn main() { Hooks::new() .post_dispatch(add_metadata) // Runs first .post_dispatch(filter_sensitive) // Receives add_metadata's output }
Handler Types
Closure Handlers
Most handlers are simple closures:
#![allow(unused)] fn main() { let handler = FnHandler::new(|matches, ctx| { let name: &String = matches.get_one("name").unwrap(); Ok(Output::Render(Data { name: name.clone() })) }); }
Trait Implementations
For handlers with internal state:
#![allow(unused)] fn main() { struct DbHandler { pool: DatabasePool, } impl Handler for DbHandler { type Output = Vec<Row>; fn handle(&self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Row>> { let query: &String = matches.get_one("query").unwrap(); let rows = self.pool.query(query)?; Ok(Output::Render(rows)) } } }
Struct Handlers (With State)
When handlers need internal state with &mut self:
#![allow(unused)] fn main() { impl Handler for Cache { type Output = Data; fn handle(&mut self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Data> { self.invalidate(); // &mut self works Ok(Output::Render(self.get()?)) } } }
See Handler Contract for full details.
Command Routing Utilities
Extract and navigate clap's ArgMatches:
#![allow(unused)] fn main() { use standout_dispatch::{ extract_command_path, get_deepest_matches, has_subcommand, path_to_string, }; // myapp db migrate --steps 5 let path = extract_command_path(&matches); // ["db", "migrate"] let path_str = path_to_string(&path); // "db.migrate" let deep = get_deepest_matches(&matches); // ArgMatches for "migrate" }
Testing Handlers
Because handlers are pure functions, testing is straightforward:
#![allow(unused)] fn main() { #[test] fn test_list_handler() { let cmd = Command::new("test") .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue)); let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap(); let ctx = CommandContext { command_path: vec!["list".into()], }; let result = list_handler(&matches, &ctx); assert!(result.is_ok()); if let Ok(Output::Render(data)) = result { assert!(data.verbose); } } }
No mocking needed—construct ArgMatches with clap, call your handler, assert on the result.
Summary
standout-dispatch provides:
- Clean separation — Handlers return data, renderers produce output
- Pluggable rendering — Use any output format without changing handlers
- Hook system — Cross-cutting concerns without code duplication
- Testable design — Handlers are pure functions with explicit contracts
- Incremental adoption — Migrate one command at a time
For complete API details, see the API documentation.
For standout framework users: The framework provides full integration with templates and themes. See the standout documentation for the
AppandAppBuilderAPIs that wire dispatch and render together automatically.
The Handler Contract
Handlers are where your application logic lives. The handler contract is designed to be explicit rather than permissive. By enforcing serializable return types and clear ownership semantics, the library guarantees that your code remains testable and decoupled from output formatting.
Quick Start: The #[handler] Macro
For most handlers, use the #[handler] macro to write pure functions:
#![allow(unused)] fn main() { use standout_macros::handler; #[handler] pub fn list(#[flag] all: bool, #[arg] limit: Option<usize>) -> Result<Vec<Item>, anyhow::Error> { storage::list(all, limit) } // Generates: list__handler(&ArgMatches, &CommandContext) -> HandlerResult<Vec<Item>> }
The macro:
- Extracts CLI arguments from
ArgMatchesbased on annotations - Auto-wraps
Result<T, E>inOutput::RenderviaIntoHandlerResult - Preserves the original function for direct testing
Parameter Annotations:
| Annotation | Type | Extraction |
|---|---|---|
#[flag] | bool | matches.get_flag("name") |
#[flag(name = "x")] | bool | matches.get_flag("x") |
#[arg] | T | Required argument |
#[arg] | Option<T> | Optional argument |
#[arg] | Vec<T> | Multiple values |
#[arg(name = "x")] | T | Argument with custom CLI name |
#[ctx] | &CommandContext | Access to context |
#[matches] | &ArgMatches | Raw matches (escape hatch) |
Return Type Handling:
| Return Type | Generated Wrapper |
|---|---|
Result<T, E> | Auto-wrapped in Output::Render |
Result<(), E> | Wrapped in Output::Silent |
Testing: The original function is preserved, so you can test directly:
list(true, Some(10)).
The Handler Trait
#![allow(unused)] fn main() { pub trait Handler { type Output: Serialize; fn handle(&mut self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Self::Output>; } }
Key characteristics:
- Mutable self:
&mut selfallows direct state modification - Output must be Serialize: Needed for JSON/YAML modes and template context
Implementing the trait directly is useful when your handler needs internal state—database connections, configuration, caches, etc.
Example: Struct Handler with State
#![allow(unused)] fn main() { use standout_dispatch::{Handler, Output, CommandContext, HandlerResult}; use clap::ArgMatches; use serde::Serialize; struct CachingDatabase { connection: Connection, cache: HashMap<String, Vec<Row>>, } impl CachingDatabase { fn query_with_cache(&mut self, sql: &str) -> Result<Vec<Row>, Error> { if let Some(cached) = self.cache.get(sql) { return Ok(cached.clone()); } let result = self.connection.execute(sql)?; self.cache.insert(sql.to_string(), result.clone()); Ok(result) } } impl Handler for CachingDatabase { type Output = Vec<Row>; fn handle(&mut self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Row>> { let query: &String = matches.get_one("query").unwrap(); let rows = self.query_with_cache(query)?; // &mut self works! Ok(Output::Render(rows)) } } }
Closure Handlers
Most handlers are simple closures using FnHandler:
#![allow(unused)] fn main() { use standout_dispatch::{FnHandler, Output, HandlerResult}; let mut counter = 0; let handler = FnHandler::new(move |_matches, _ctx| { counter += 1; // Mutation works! Ok(Output::Render(counter)) }); }
The closure signature:
#![allow(unused)] fn main() { fn(&ArgMatches, &CommandContext) -> HandlerResult<T> where T: Serialize }
Closures are FnMut, allowing captured variables to be mutated.
SimpleFnHandler (No Context Needed)
When your handler doesn't need CommandContext, use SimpleFnHandler for a cleaner signature:
#![allow(unused)] fn main() { use standout_dispatch::SimpleFnHandler; let handler = SimpleFnHandler::new(|matches| { let verbose = matches.get_flag("verbose"); let items = storage::list()?; Ok(ListResult { items, verbose }) }); }
The closure signature:
#![allow(unused)] fn main() { fn(&ArgMatches) -> Result<T, E> where T: Serialize, E: Into<anyhow::Error> }
SimpleFnHandler automatically wraps the result in Output::Render via IntoHandlerResult.
IntoHandlerResult Trait
The IntoHandlerResult trait enables handlers to return Result<T, E> directly instead of HandlerResult<T>:
#![allow(unused)] fn main() { use standout_dispatch::IntoHandlerResult; // Before: explicit Output wrapping fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let items = storage::list()?; Ok(Output::Render(items)) } // After: automatic conversion fn list(_m: &ArgMatches, _ctx: &CommandContext) -> impl IntoHandlerResult<Vec<Item>> { storage::list() // Result<Vec<Item>, Error> auto-converts } }
The trait is implemented for:
Result<T, E>whereE: Into<anyhow::Error>→ wrapsOk(t)inOutput::Render(t)HandlerResult<T>→ passes through unchanged
This is used internally by SimpleFnHandler and the #[handler] macro.
HandlerResult
HandlerResult<T> is a standard Result type:
#![allow(unused)] fn main() { pub type HandlerResult<T> = Result<Output<T>, anyhow::Error>; }
The ? operator works naturally for error propagation:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Items> { let items = storage::load()?; // Propagates errors let filtered = filter_items(&items)?; // Propagates errors Ok(Output::Render(Items { filtered })) } }
The Output Enum
Output<T> represents what a handler produces:
#![allow(unused)] fn main() { pub enum Output<T: Serialize> { Render(T), Silent, Binary { data: Vec<u8>, filename: String }, } }
Output::Render(T)
The common case. Data is passed to the render function:
#![allow(unused)] fn main() { #[derive(Serialize)] struct ListResult { items: Vec<Item>, total: usize, } fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListResult> { let items = storage::list()?; Ok(Output::Render(ListResult { total: items.len(), items, })) } }
Output::Silent
No output produced. Useful for commands with side effects only:
#![allow(unused)] fn main() { fn delete_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let id: &String = matches.get_one("id").unwrap(); storage::delete(id)?; Ok(Output::Silent) } }
Silent behavior:
- Post-output hooks still receive
RenderedOutput::Silent - Render function is not called
- Nothing prints to stdout
Output::Binary
Raw bytes for file output:
#![allow(unused)] fn main() { fn export_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> { let data = generate_report()?; let pdf_bytes = render_to_pdf(&data)?; Ok(Output::Binary { data: pdf_bytes, filename: "report.pdf".into(), }) } }
Binary output bypasses the render function entirely.
CommandContext
CommandContext provides execution environment information and state access:
#![allow(unused)] fn main() { pub struct CommandContext { pub command_path: Vec<String>, pub app_state: Rc<Extensions>, pub extensions: Extensions, } }
command_path: The subcommand chain as a vector, e.g., ["db", "migrate"]. Useful for logging or conditional logic.
app_state: Shared, immutable state configured at app build time via AppBuilder::app_state(). Wrapped in Arc for cheap cloning. Use for database connections, configuration, API clients.
extensions: Per-request, mutable state injected by pre-dispatch hooks. Use for user sessions, request IDs, computed values.
For comprehensive coverage of state management, see App State and Extensions.
State Access: App State vs Extensions
Handlers access state through two distinct mechanisms with different semantics:
| Aspect | ctx.app_state | ctx.extensions |
|---|---|---|
| Mutability | Immutable (&) | Mutable (&mut) |
| Lifetime | App lifetime | Per-request |
| Set by | AppBuilder::app_state() | Pre-dispatch hooks |
| Use for | Database, Config, API clients | User sessions, request IDs |
App State (Shared Resources)
Configure long-lived resources at build time:
#![allow(unused)] fn main() { App::builder() .app_state(Database::connect()?) .app_state(Config::load()?) .command("list", list_handler, template)? .build()? }
Access in handlers via ctx.app_state:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let db = ctx.app_state.get_required::<Database>()?; let config = ctx.app_state.get_required::<Config>()?; let items = db.query_items(config.max_results)?; Ok(Output::Render(items)) } }
Extensions (Per-Request State)
Pre-dispatch hooks inject request-scoped state:
#![allow(unused)] fn main() { use standout_dispatch::{Hooks, HookError}; struct UserScope { user_id: String, permissions: Vec<String> } let hooks = Hooks::new() .pre_dispatch(|matches, ctx| { // Can read app_state to set up per-request state let db = ctx.app_state.get_required::<Database>()?; let user_id = matches.get_one::<String>("user").unwrap().clone(); let permissions = db.get_permissions(&user_id)?; ctx.extensions.insert(UserScope { user_id, permissions }); Ok(()) }); }
Handlers retrieve from extensions:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let db = ctx.app_state.get_required::<Database>()?; // shared let scope = ctx.extensions.get_required::<UserScope>()?; // per-request let items = db.list_for_user(&scope.user_id)?; Ok(Output::Render(items)) } }
Extensions API
Both app_state and extensions use the same Extensions type with these methods:
| Method | Description |
|---|---|
insert<T>(value) | Insert a value, returns previous if any |
get<T>() | Get immutable reference, returns Option<&T> |
get_required<T>() | Get reference or return error if missing |
get_mut<T>() | Get mutable reference, returns Option<&mut T> |
remove<T>() | Remove and return value |
contains<T>() | Check if type exists |
len() | Number of stored values |
is_empty() | True if no values stored |
clear() | Remove all values |
Use get_required for mandatory dependencies (fails fast with clear error), get for optional ones.
When to Use Which
Use App State for:
- Database connections — expensive to create, should be pooled
- Configuration — loaded once at startup
- API clients — shared HTTP clients with connection pooling
Use Extensions for:
- User context — current user, session, permissions
- Request metadata — request ID, timing, correlation ID
- Transient state — data computed by one hook, used by handler
The Two-State Pattern
The separation exists because:
- Closure capture doesn't work with
#[derive(Dispatch)]— macro-generated dispatch calls handlers with a fixed signature - App-level resources shouldn't be created per-request — database pools and config are expensive
- Per-request state needs mutable injection — hooks compute values at runtime
#![allow(unused)] fn main() { // App state: configured once at build time App::builder() .app_state(Database::connect()?) // Shared via Arc .hooks("users.list", Hooks::new() .pre_dispatch(|matches, ctx| { // Extensions: computed per-request, can use app_state let db = ctx.app_state.get_required::<Database>()?; let user = authenticate(matches, db)?; ctx.extensions.insert(user); Ok(()) }))? }
For comprehensive coverage of state management patterns, see App State and Extensions.
Accessing CLI Arguments
The ArgMatches parameter provides access to parsed arguments through clap's standard API:
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Data> { // Flags let verbose = matches.get_flag("verbose"); // Required options let name: &String = matches.get_one("name").unwrap(); // Optional values let limit: Option<&u32> = matches.get_one("limit"); // Multiple values let tags: Vec<&String> = matches.get_many("tags") .map(|v| v.collect()) .unwrap_or_default(); Ok(Output::Render(Data { ... })) } }
For subcommands, you work with the ArgMatches for your specific command level.
Testing Handlers
Because handlers are pure functions with explicit inputs and outputs, they're straightforward to test:
#![allow(unused)] fn main() { #[test] fn test_list_handler() { let cmd = Command::new("test") .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue)); let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap(); let ctx = CommandContext { command_path: vec!["list".into()], ..Default::default() }; let result = list_handler(&matches, &ctx); assert!(result.is_ok()); if let Ok(Output::Render(data)) = result { assert!(data.verbose); } } }
No mocking frameworks needed—construct ArgMatches with clap, create a CommandContext, call your handler, assert on the result.
Testing with App State
When handlers depend on app_state, inject test fixtures:
#![allow(unused)] fn main() { #[test] fn test_handler_with_app_state() { use std::sync::Arc; // Create test fixtures let mock_db = MockDatabase::with_items(vec![ Item { id: "1", name: "Test" } ]); // Build app_state with test data let mut app_state = Extensions::new(); app_state.insert(mock_db); let ctx = CommandContext { command_path: vec!["list".into()], app_state: Arc::new(app_state), extensions: Extensions::new(), }; let cmd = Command::new("test"); let matches = cmd.try_get_matches_from(["test"]).unwrap(); let result = list_handler(&matches, &ctx); assert!(result.is_ok()); } }
Testing Handlers with Mutable State
Handler tests can verify state mutation across calls:
#![allow(unused)] fn main() { #[test] fn test_handler_state_mutation() { struct Counter { count: u32 } impl Handler for Counter { type Output = u32; fn handle(&mut self, _m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<u32> { self.count += 1; Ok(Output::Render(self.count)) } } let mut handler = Counter { count: 0 }; let cmd = Command::new("test"); let matches = cmd.try_get_matches_from(["test"]).unwrap(); let ctx = CommandContext { command_path: vec!["count".into()], ..Default::default() }; // State accumulates across calls let _ = handler.handle(&matches, &ctx); let _ = handler.handle(&matches, &ctx); let result = handler.handle(&matches, &ctx); assert!(matches!(result, Ok(Output::Render(3)))); } }
Execution Model
standout-dispatch manages a strict linear pipeline from CLI input to rendered output. This explicitly separated flow ensures that logic (handlers) remains decoupled from presentation (renderers) and side-effects (hooks).
The Pipeline
Clap Parsing → Pre-dispatch → Handler → Post-dispatch → Renderer → Post-output → Piping → Output
Each stage has a clear responsibility:
Clap Parsing: Your clap::Command definition is parsed normally. standout-dispatch doesn't replace clap—it works with the resulting ArgMatches.
Pre-dispatch Hook: Runs before the handler. Can abort execution (e.g., auth checks).
Handler: Your logic function executes. It receives ArgMatches and CommandContext, returning a HandlerResult<T>—either data to render, a silent marker, or binary content. For simpler handlers, use the #[handler] macro to write pure functions that return Result<T, E> directly (see Handler Contract).
Post-dispatch Hook: Runs after the handler, before rendering. Can transform data.
Renderer: Your render function receives the data and produces output (string or binary).
Post-output Hook: Runs after rendering. Can transform the final output string.
Piping: Optionally sends output to external commands (jq, tee, clipboard). Implemented as specialized post-output hooks. See Output Piping.
Output: The result is returned or written to stdout.
Command Paths
A command path is a vector of strings representing the subcommand chain:
myapp db migrate --steps 5
The command path is ["db", "migrate"].
Extracting Command Paths
#![allow(unused)] fn main() { use standout_dispatch::{extract_command_path, path_to_string, get_deepest_matches}; let matches = cmd.get_matches(); // Get the full path let path = extract_command_path(&matches); // ["db", "migrate"] // Convert to dot notation let path_str = path_to_string(&path); // "db.migrate" // Get ArgMatches for the deepest command let deep = get_deepest_matches(&matches); // ArgMatches for "migrate" }
Command Path Utilities
| Function | Purpose |
|---|---|
extract_command_path | Get subcommand chain as Vec<String> |
path_to_string | Convert path to dot notation ("db.migrate") |
string_to_path | Convert dot notation to path |
get_deepest_matches | Get ArgMatches for deepest subcommand |
has_subcommand | Check if any subcommand was invoked |
State Injection
Handlers access state through CommandContext, which provides two mechanisms:
app_state: Shared, immutable state configured at build time (database, config)extensions: Per-request, mutable state injected by hooks
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<T> { // App state: shared resources let db = ctx.app_state.get_required::<Database>()?; // Extensions: per-request state let scope = ctx.extensions.get_required::<UserScope>()?; // ... } }
For full details on state management, see App State and Extensions.
The Hooks System
Hooks are functions that run at specific points in the pipeline. They let you intercept, validate, or transform without touching handler logic—keeping concerns separated.
Three Phases
Pre-dispatch: Runs before the handler. Can abort execution or inject per-request state.
Use for: authentication checks, input validation, logging start time, injecting per-request state via extensions.
Pre-dispatch hooks receive &mut CommandContext, allowing them to inject state via ctx.extensions that handlers can retrieve. They also have read access to ctx.app_state for shared resources:
#![allow(unused)] fn main() { use standout_dispatch::{Hooks, HookError}; // Per-request state types (injected by hooks) struct UserSession { user_id: u64 } Hooks::new() .pre_dispatch(|matches, ctx| { // Read from app_state (shared) let db = ctx.app_state.get_required::<Database>()?; // Validate and set up per-request state let token = std::env::var("API_TOKEN") .map_err(|_| HookError::pre_dispatch("API_TOKEN required"))?; let user_id = db.validate_token(&token)?; // Inject into extensions (per-request) ctx.extensions.insert(UserSession { user_id }); Ok(()) }) }
Handlers then use both app_state and extensions:
#![allow(unused)] fn main() { fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> { // App state: shared across all requests let db = ctx.app_state.get_required::<Database>()?; // Extensions: per-request state from hooks let session = ctx.extensions.get_required::<UserSession>()?; let items = db.fetch_items(session.user_id)?; Ok(Output::Render(items)) } }
See the Handler Contract for full Extensions API documentation, and App State for details on the two-state model.
Post-dispatch: Runs after the handler, before rendering. Can transform data.
Use for: adding timestamps, filtering sensitive fields, data enrichment. The hook receives handler output as serde_json::Value, allowing generic transformations regardless of the handler's output type.
#![allow(unused)] fn main() { Hooks::new().post_dispatch(|_matches, _ctx, mut data| { if let Some(obj) = data.as_object_mut() { obj.insert("generated_at".into(), json!(Utc::now().to_rfc3339())); } Ok(data) }) }
Post-output: Runs after rendering. Can transform the final string.
Use for: adding headers/footers, logging, metrics. The hook receives RenderedOutput—an enum of Text(String), Binary(Vec<u8>, String), or Silent.
#![allow(unused)] fn main() { use standout_dispatch::RenderedOutput; Hooks::new().post_output(|_matches, _ctx, output| { match output { RenderedOutput::Text(s) => { Ok(RenderedOutput::Text(format!("{}\n-- Generated by MyApp", s))) } other => Ok(other), } }) }
Hook Chaining
Multiple hooks per phase are supported. Pre-dispatch hooks run sequentially—first error aborts. Post-dispatch and post-output hooks chain: each receives the output of the previous, enabling composable transformations.
#![allow(unused)] fn main() { Hooks::new() .post_dispatch(add_metadata) // Runs first .post_dispatch(filter_sensitive) // Receives add_metadata's output }
Order matters: filter_sensitive sees the metadata that add_metadata inserted.
Output Piping
Piping sends rendered output to external shell commands. It's implemented as specialized post-output hooks with three modes:
#![allow(unused)] fn main() { use standout::cli::App; let app = App::builder() .commands(|g| { g.command_with("export", handlers::export, |cfg| { cfg.template("export.jinja") // Filter through jq (capture mode) .pipe_through("jq '.items'") }) .command_with("copy", handlers::copy, |cfg| { cfg.template("copy.jinja") // Send to clipboard (consume mode) .pipe_to_clipboard() }) .command_with("debug", handlers::debug, |cfg| { cfg.template("debug.jinja") // Log to file while displaying (passthrough mode) .pipe_to("tee /tmp/debug.log") }) }) .build()?; }
| Mode | Method | Behavior |
|---|---|---|
| Passthrough | pipe_to() | Run command, return original output |
| Capture | pipe_through() | Return command's stdout as new output |
| Consume | pipe_to_clipboard() | Send to clipboard, return empty |
Pipes can be chained and combined with other post-output hooks. See Output Piping for full documentation.
Error Handling
When a hook returns Err(HookError):
- Execution stops immediately
- Remaining hooks in that phase don't run
- For pre-dispatch: the handler never executes
- For post phases: the rendered output is discarded
- The error message is returned
#![allow(unused)] fn main() { use standout_dispatch::HookError; // Create error with phase context HookError::pre_dispatch("database connection failed") // With source error for debugging HookError::post_dispatch("transformation failed") .with_source(underlying_error) }
Render Handlers
The render handler is a pluggable callback that converts data to output:
#![allow(unused)] fn main() { use standout_dispatch::{from_fn, RenderFn}; // Simple JSON renderer let render: RenderFn = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); }
Render Function Signature
#![allow(unused)] fn main() { fn(&serde_json::Value, &str) -> Result<String, RenderError> }
Parameters:
data: The serialized handler outputview: A view/template name hint (can be ignored)
Using View Names
The view parameter enables template-based rendering:
#![allow(unused)] fn main() { let render = from_fn(move |data, view| { match view { "list" => format_as_list(data), "detail" => format_as_detail(data), _ => Ok(serde_json::to_string_pretty(data)?), } }); }
For standout framework users: The framework automatically maps view names to template files. See standout documentation for details.
Local Render Functions
For render functions that need mutable state:
#![allow(unused)] fn main() { use standout_dispatch::{from_fn_mut, LocalRenderFn}; let render: LocalRenderFn = from_fn_mut(|data, view| { // Can capture and mutate state Ok(format_data(data)) }); }
Default Command Support
Handle the case when no subcommand is specified:
#![allow(unused)] fn main() { use standout_dispatch::{has_subcommand, insert_default_command}; let matches = cmd.get_matches_from(args); if !has_subcommand(&matches) { // Re-parse with default command inserted let args_with_default = insert_default_command(std::env::args(), "list"); let matches = cmd.get_matches_from(args_with_default); // Now dispatch to "list" } }
insert_default_command inserts the command name after the binary name but before any flags.
Putting It Together
A complete dispatch flow:
use standout_dispatch::{ SimpleFnHandler, FnHandler, Output, CommandContext, Hooks, HookError, from_fn, extract_command_path, get_deepest_matches, path_to_string, }; fn main() -> anyhow::Result<()> { // 1. Define clap command let cmd = Command::new("myapp") .subcommand(Command::new("list")) .subcommand(Command::new("delete").arg(Arg::new("id").required(true))); // 2. Create handlers // SimpleFnHandler: for handlers that don't need CommandContext let list_handler = SimpleFnHandler::new(|_m| { storage::list() // Result<T, E> auto-wraps in Output::Render }); // FnHandler: when you need CommandContext let delete_handler = FnHandler::new(|matches, _ctx| { let id: &String = matches.get_one("id").unwrap(); storage::delete(id)?; Ok(Output::Silent) }); // 3. Create render function let render = from_fn(|data, _view| { Ok(serde_json::to_string_pretty(data)?) }); // 4. Create hooks let hooks = Hooks::new() .pre_dispatch(|_m, _ctx| { println!("Starting command..."); Ok(()) }); // 5. Parse and dispatch let matches = cmd.get_matches(); let path = extract_command_path(&matches); let mut ctx = CommandContext { command_path: path.clone(), ..Default::default() }; // Run pre-dispatch hooks (may inject state via ctx.extensions) hooks.run_pre_dispatch(&matches, &mut ctx)?; // Dispatch based on command let result = match path_to_string(&path).as_str() { "list" => { let output = list_handler.handle(&matches, &ctx)?; if let Output::Render(data) = output { let json = serde_json::to_value(&data)?; let rendered = render(&json, "list")?; println!("{}", rendered); } } "delete" => { let deep = get_deepest_matches(&matches); delete_handler.handle(deep, &ctx)?; println!("Deleted."); } _ => eprintln!("Unknown command"), }; Ok(()) }
Summary
The execution model provides:
- Clear pipeline — Each stage has defined inputs and outputs
- Hook points — Intercept before, after handler, and after render
- Command routing — Utilities for navigating subcommand hierarchies
- Pluggable rendering — Render functions are separate from handlers
- Testable stages — Each component can be tested in isolation
Partial Adoption
One of the key benefits of standout-dispatch is that you don't need to adopt it all at once. You can migrate one command at a time, keeping existing code alongside dispatch-managed commands.
The Problem with All-or-Nothing Frameworks
Many CLI frameworks require a complete rewrite:
- All commands must use the framework's patterns
- Existing code can't coexist with framework code
- Migration is a massive undertaking
- Risk is concentrated in a single change
standout-dispatch is designed differently. It's a library, not a framework—you call it, it doesn't call you.
Strategy: Migrate One Command at a Time
Step 1: Identify a Good Starting Command
Pick a command that:
- Is self-contained (few dependencies on other commands)
- Has clear inputs and outputs
- Would benefit from structured output (JSON, etc.)
- Has existing tests you can update
Step 2: Create the Handler
Convert the command's logic to a handler:
#![allow(unused)] fn main() { // Before: mixed logic and output fn list_command(matches: &ArgMatches) { let items = storage::list().unwrap(); for item in items { println!("{}: {}", item.id, item.name); } } // After: handler returns data fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> { let items = storage::list()?; Ok(Output::Render(items)) } }
Step 3: Set Up Dispatch for That Command
use standout_dispatch::{FnHandler, from_fn, extract_command_path, path_to_string}; fn main() { let cmd = build_clap_command(); // Your existing clap definition let matches = cmd.get_matches(); let path = extract_command_path(&matches); // Dispatch-managed command if path_to_string(&path) == "list" { let handler = FnHandler::new(list_handler); let render = from_fn(|data, _| Ok(serde_json::to_string_pretty(data)?)); let ctx = CommandContext { command_path: path }; if let Ok(Output::Render(data)) = handler.handle(&matches, &ctx) { let json = serde_json::to_value(&data).unwrap(); println!("{}", render(&json, "list").unwrap()); } return; } // Fall back to existing code for other commands match matches.subcommand() { Some(("add", sub)) => add_command(sub), Some(("delete", sub)) => delete_command(sub), _ => {} } }
Step 4: Repeat
Migrate one command at a time. Each migration:
- Is a small, reviewable change
- Can be tested independently
- Doesn't affect other commands
- Is easy to roll back if needed
Coexistence Patterns
Pattern 1: Check Path First
#![allow(unused)] fn main() { let path = extract_command_path(&matches); // Dispatch-managed commands let dispatch_commands = ["list", "show", "export"]; if dispatch_commands.contains(&path_to_string(&path).as_str()) { dispatch_command(&matches, &path); return; } // Legacy commands legacy_dispatch(&matches); }
Pattern 2: Try Dispatch, Fall Back
#![allow(unused)] fn main() { if let Some(result) = try_dispatch(&matches) { handle_dispatch_result(result); } else { // Not a dispatch-managed command legacy_dispatch(&matches); } }
Pattern 3: Wrapper Function
#![allow(unused)] fn main() { fn run_command(matches: &ArgMatches) { let path = extract_command_path(matches); match path_to_string(&path).as_str() { // New dispatch-based handlers "list" => run_with_dispatch(list_handler, matches, &path), "show" => run_with_dispatch(show_handler, matches, &path), // Legacy handlers (unchanged) "add" => add_command(get_deepest_matches(matches)), "delete" => delete_command(get_deepest_matches(matches)), _ => eprintln!("Unknown command"), } } fn run_with_dispatch<T: Serialize>( handler: impl Fn(&ArgMatches, &CommandContext) -> HandlerResult<T>, matches: &ArgMatches, path: &[String], ) { let ctx = CommandContext { command_path: path.to_vec() }; match handler(matches, &ctx) { Ok(Output::Render(data)) => { let json = serde_json::to_value(&data).unwrap(); println!("{}", serde_json::to_string_pretty(&json).unwrap()); } Ok(Output::Silent) => {} Ok(Output::Binary { data, filename }) => { std::fs::write(&filename, &data).unwrap(); } Err(e) => eprintln!("Error: {}", e), } } }
Benefits During Migration
Immediate Benefits per Command
Each migrated command gains:
- Structured output — JSON/YAML support
- Testable logic — Handler is a pure function
- Error handling —
?operator, proper error types - Hook points — Add logging, auth without touching handler
Progressive Enhancement
As you migrate more commands:
- Shared hooks — Apply auth check to all migrated commands
- Consistent output — Same renderer for all commands
- Unified error handling — Errors formatted consistently
Migration Checklist
For each command:
-
Create data types (
#[derive(Serialize)]) - Write handler function
- Add to dispatch routing
- Update tests to test handler directly
- Verify existing behavior unchanged
- Document the migration
Example: Full Migration
Before (monolithic):
fn main() { let matches = build_cli().get_matches(); match matches.subcommand() { Some(("list", sub)) => list_command(sub), Some(("add", sub)) => add_command(sub), Some(("delete", sub)) => delete_command(sub), Some(("export", sub)) => export_command(sub), _ => {} } }
After (gradual migration):
fn main() { let matches = build_cli().get_matches(); let path = extract_command_path(&matches); // Dispatch-managed (migrated) if let Some(result) = dispatch_if_managed(&matches, &path) { return; } // Legacy (not yet migrated) match matches.subcommand() { Some(("add", sub)) => add_command(sub), Some(("delete", sub)) => delete_command(sub), _ => {} } } fn dispatch_if_managed(matches: &ArgMatches, path: &[String]) -> Option<()> { let ctx = CommandContext { command_path: path.to_vec() }; let render = from_fn(|data, _| Ok(serde_json::to_string_pretty(data)?)); let result = match path_to_string(path).as_str() { "list" => list_handler(matches, &ctx), "export" => export_handler(matches, &ctx), _ => return None, // Not managed by dispatch }; match result { Ok(Output::Render(data)) => { let json = serde_json::to_value(&data).ok()?; println!("{}", render(&json, "").ok()?); } Ok(Output::Silent) => {} Ok(Output::Binary { data, filename }) => { std::fs::write(&filename, &data).ok()?; } Err(e) => eprintln!("Error: {}", e), } Some(()) }
Summary
Partial adoption lets you:
- Start small — Migrate one command at a time
- Reduce risk — Each migration is independent
- Maintain velocity — Keep shipping while migrating
- Validate benefits — See the value before full commitment
The goal is pragmatic improvement, not architectural purity. Migrate what benefits most, leave what works alone.
Output Modes
Standout supports multiple output formats through a single handler because modern CLI tools serve two masters: human operators and machine automation.
The same handler logic produces styled terminal output for eyes, plain text for logs, or structured JSON for jq pipelines—controlled entirely by the user's --output flag. This frees you from writing separate "API" and "CLI" logic.
The OutputMode Enum
#![allow(unused)] fn main() { pub enum OutputMode { Auto, // Auto-detect terminal capabilities Term, // Always use ANSI escape codes Text, // Never use ANSI codes (plain text) TermDebug, // Keep style tags as [name]...[/name] Json, // Serialize as JSON (skip template) Yaml, // Serialize as YAML (skip template) Xml, // Serialize as XML (skip template) Csv, // Serialize as CSV (skip template) } }
Three categories:
Templated modes (Auto, Term, Text): Render the template, vary ANSI handling.
Debug mode (TermDebug): Render the template, keep tags as literals for inspection.
Structured modes (Json, Yaml, Xml, Csv): Skip the template entirely, serialize handler data directly.
Auto Mode
Auto is the default. It queries the terminal for color support:
#![allow(unused)] fn main() { Term::stdout().features().colors_supported() }
If colors are supported, Auto behaves like Term (ANSI codes applied). If not, Auto behaves like Text (tags stripped).
This detection happens at render time, not startup. Piping output to a file or another process typically disables color support, so:
myapp list # Colors (if terminal supports)
myapp list > file.txt # No colors (not a TTY)
myapp list | less # No colors (pipe)
The --output Flag
Standout adds a global --output flag accepting these values:
myapp list --output=auto # Default
myapp list --output=term # Force ANSI codes
myapp list --output=text # Force plain text
myapp list --output=term-debug # Show style tags
myapp list --output=json # JSON serialization
myapp list --output=yaml # YAML serialization
myapp list --output=xml # XML serialization
myapp list --output=csv # CSV serialization
The flag is global—it applies to all subcommands.
Term vs Text
Term: Always applies ANSI escape codes, even when piping:
myapp list --output=term > colored.txt
Useful when you want to preserve colors for later display (e.g., less -R).
Text: Never applies ANSI codes:
myapp list --output=text
Useful for clean output regardless of terminal capabilities, or when processing output with other tools.
TermDebug Mode
TermDebug preserves style tags instead of converting them:
Template: [title]Hello[/title]
Output: [title]Hello[/title]
Use cases:
- Debugging template issues
- Verifying style tag placement
- Automated testing of template output
Unlike Term mode, unknown tags don't get the ? marker in TermDebug.
Structured Modes
Structured modes bypass the template entirely. Handler data is serialized directly:
#![allow(unused)] fn main() { #[derive(Serialize)] struct ListOutput { items: Vec<Item>, total: usize, } fn list_handler(...) -> HandlerResult<ListOutput> { Ok(Output::Render(ListOutput { items, total: items.len() })) } }
myapp list --output=json
{
"items": [...],
"total": 42
}
Same handler, same types—different output format. This enables:
- Machine-readable output for scripts
- Integration with other tools (
jq, etc.) - API-like behavior from CLI apps
CSV Output
CSV mode flattens nested JSON automatically. For more control, use FlatDataSpec.
See Tabular Layout for detailed CSV configuration.
#![allow(unused)] fn main() { let spec = FlatDataSpec::builder() .column(Column::new(Width::Fixed(10)).key("name").header("Name")) .column(Column::new(Width::Fixed(10)).key("meta.role").header("Role")) .build(); render_auto_with_spec(template, &data, &theme, OutputMode::Csv, Some(&spec))? }
The key field uses dot notation for nested paths ("meta.role" extracts data["meta"]["role"]).
File Output
The --output-file-path flag redirects output to a file:
myapp list --output-file-path=results.txt
myapp list --output=json --output-file-path=data.json
Behavior:
- Text output: written to file, nothing printed to stdout
- Binary output: written to file (same as without flag)
- Silent output: no-op
After writing to file, stdout output is suppressed to prevent double-printing.
Customizing Flags
Rename or disable the flags via AppBuilder:
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // --format instead of --output .output_file_flag(Some("out")) // --out instead of --output-file-path .build()? }
#![allow(unused)] fn main() { App::builder() .no_output_flag() // Disable --output entirely .no_output_file_flag() // Disable file output .build()? }
Accessing OutputMode in Handlers
CommandContext carries the resolved output mode:
#![allow(unused)] fn main() { fn handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Data> { if ctx.output_mode.is_structured() { // Skip interactive prompts in JSON mode } if ctx.output_mode == OutputMode::Csv { // Maybe adjust data structure for flat output } Ok(Output::Render(data)) } }
Helper methods:
#![allow(unused)] fn main() { ctx.output_mode.should_use_color() // True for Term, depends on terminal for Auto ctx.output_mode.is_structured() // True for Json, Yaml, Xml, Csv ctx.output_mode.is_debug() // True for TermDebug }
Rendering Without CLI
For standalone rendering with explicit mode:
#![allow(unused)] fn main() { use standout::{render_auto, OutputMode}; // Renders template for Term/Text, serializes for Json/Yaml let output = render_auto(template, &data, &theme, OutputMode::Json)?; }
The "auto" in render_auto refers to template-vs-serialize dispatch, not color detection.
For full control over both output mode and color mode:
#![allow(unused)] fn main() { use standout::{render_with_mode, ColorMode}; let output = render_with_mode( template, &data, &theme, OutputMode::Term, ColorMode::Dark, )?; }
App Configuration
AppBuilder is the unified entry point for configuring your application. Instead of scattering configuration across multiple structs (Standout, RenderSetup, Theme), everything from command registration to theme selection happens in one fluent interface.
This design ensures that your application defines its entire environment—commands, styles, templates, and hooks—before the runtime starts, preventing configuration race conditions and simplifying testing.
This guide covers the full setup: embedding resources, registering commands, configuring themes, and customizing behavior.
See also:
- Rendering System for details on templates and styles.
- Topics System for help topics.
Basic Setup
#![allow(unused)] fn main() { use standout::cli::App; use standout_macros::{embed_templates, embed_styles}; let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .command("list", list_handler, "list.j2") .build()?; app.run(Cli::command(), std::env::args()); }
Embedding Resources
Templates
embed_templates! embeds template files at compile time:
#![allow(unused)] fn main() { .templates(embed_templates!("src/templates")) }
Collects files matching: .jinja, .jinja2, .j2, .stpl, .txt (in priority order).
Custom template engines: For advanced use cases,
standout-rendersupports pluggable template engines. See the Template Engines topic for details on usingSimpleEngineor implementing custom engines.
Directory structure:
src/templates/
list.j2
add.j2
db/
migrate.j2
status.j2
Templates are referenced by path without extension: "list", "db/migrate".
Styles
embed_styles! embeds stylesheet files:
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) }
Collects files matching: .yaml, .yml.
src/styles/
default.yaml
dark.yaml
light.yaml
Themes are referenced by filename without extension: "default", "dark".
Hot Reloading
In debug builds, embedded resources are re-read from disk on each render—edit without recompiling. In release builds, embedded content is used directly.
This is automatic when the source path exists on disk.
Runtime Overrides
Users can override embedded resources with local files:
#![allow(unused)] fn main() { App::builder() .templates(embed_templates!("src/templates")) .templates_dir("~/.myapp/templates") // Overrides embedded .styles(embed_styles!("src/styles")) .styles_dir("~/.myapp/themes") // Overrides embedded }
Local directories take precedence. This enables user customization without recompiling.
Theme Selection
From Stylesheet Registry
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) // Optional: set explicit default name // If omitted, tries "default", "theme", then "base" .default_theme("dark") }
If .default_theme() is not called, AppBuilder attempts to load a theme from the registry in this order:
defaultthemebase
This allows you to provide a standard base.yaml or theme.yaml without requiring explicit configuration code. If the explicit theme isn't found, build() returns SetupError::ThemeNotFound.
Explicit Theme
#![allow(unused)] fn main() { let theme = Theme::new() .add("title", Style::new().bold().cyan()) .add("muted", Style::new().dim()); App::builder() .theme(theme) // Overrides stylesheet registry }
Explicit .theme() takes precedence over .default_theme().
Command Registration
Simple Commands
#![allow(unused)] fn main() { App::builder() .command("list", list_handler, "list.j2") .command("add", add_handler, "add.j2") }
Arguments: command name, handler function, template path.
With Configuration
#![allow(unused)] fn main() { App::builder() .command_with("delete", delete_handler, |cfg| cfg .template("delete.j2") .pre_dispatch(require_confirmation) .post_dispatch(log_deletion)) }
Inline hook attachment without separate .hooks() call.
Nested Groups
#![allow(unused)] fn main() { App::builder() .group("db", |g| g .command("migrate", migrate_handler, "db/migrate.j2") .command("status", status_handler, "db/status.j2") .group("backup", |b| b .command("create", backup_create, "db/backup/create.j2") .command("restore", backup_restore, "db/backup/restore.j2"))) }
Creates command paths: db.migrate, db.status, db.backup.create, db.backup.restore.
From Dispatch Macro
#![allow(unused)] fn main() { #[derive(Dispatch)] enum Commands { List, Add, #[dispatch(nested)] Db(DbCommands), } App::builder() .commands(Commands::dispatch_config()) }
The macro generates registration for all variants.
Default Command
When a CLI is invoked without a subcommand (a "naked" invocation like myapp or myapp --verbose), you can specify a default command to run:
#![allow(unused)] fn main() { App::builder() .default_command("list") .command("list", list_handler, "list.j2") .command("add", add_handler, "add.j2") }
With this configuration:
myappbecomesmyapp listmyapp --output=jsonbecomesmyapp list --output=jsonmyapp add foostays asmyapp add foo(explicit command takes precedence)
With Dispatch Macro
Use the #[dispatch(default)] attribute to mark a variant as the default:
#![allow(unused)] fn main() { #[derive(Dispatch)] #[dispatch(handlers = handlers)] enum Commands { #[dispatch(default)] List, Add, } App::builder() .commands(Commands::dispatch_config()) }
Only one command can be marked as default. Multiple #[dispatch(default)] attributes will cause a compile error.
Hooks
Attach hooks to specific command paths:
#![allow(unused)] fn main() { App::builder() .command("migrate", migrate_handler, "migrate.j2") .hooks("db.migrate", Hooks::new() .pre_dispatch(require_admin) .post_dispatch(add_timestamp) .post_output(log_result)) }
The path uses dot notation matching the command hierarchy.
Context Injection
Add values available in all templates:
Static Context
#![allow(unused)] fn main() { App::builder() .context("version", "1.0.0") .context("app_name", "MyApp") }
Dynamic Context
#![allow(unused)] fn main() { App::builder() .context_fn("terminal_width", |ctx| { Value::from(ctx.terminal_width.unwrap_or(80)) }) .context_fn("timestamp", |_ctx| { Value::from(chrono::Utc::now().to_rfc3339()) }) }
Dynamic providers receive RenderContext with output mode, terminal width, and handler data.
Topics
Add help topics:
#![allow(unused)] fn main() { App::builder() .topics_dir("docs/topics") .add_topic(Topic::new("auth", "Authentication...", TopicType::Text, None)) }
See Topics System for details.
Flag Customization
Output Flag
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // --format instead of --output }
#![allow(unused)] fn main() { App::builder() .no_output_flag() // Disable entirely }
File Output Flag
#![allow(unused)] fn main() { App::builder() .output_file_flag(Some("out")) // --out instead of --output-file-path }
#![allow(unused)] fn main() { App::builder() .no_output_file_flag() // Disable entirely }
The App Struct
build() produces an App:
#![allow(unused)] fn main() { pub struct App { registry: TopicRegistry, output_flag: Option<String>, output_file_flag: Option<String>, output_mode: OutputMode, theme: Option<Theme>, command_hooks: HashMap<String, Hooks>, template_registry: Option<TemplateRegistry>, stylesheet_registry: Option<StylesheetRegistry>, } }
Running the App
Standard Execution
#![allow(unused)] fn main() { if let Some(matches) = app.run(Cli::command(), std::env::args()) { // Standout didn't handle this command, fall back to legacy legacy_dispatch(matches); } }
Parses args, dispatches to handler, prints output. Returns Option<ArgMatches>—None if handled, Some(matches) for fallback.
Capture Output
For testing, post-processing, or when you need the output string:
#![allow(unused)] fn main() { match app.run_to_string(cmd, args) { RunResult::Handled(output) => { /* use output string */ } RunResult::Binary(bytes, filename) => { /* handle binary */ } RunResult::NoMatch(matches) => { /* fallback dispatch */ } } }
Returns RunResult instead of printing.
Parse Only
#![allow(unused)] fn main() { let matches = app.parse_with(cmd); // Use matches for manual dispatch }
Parses with Standout's augmented command but doesn't dispatch.
Build Validation
build() validates:
- Theme exists if
.default_theme()was called - Returns
SetupError::ThemeNotFoundif not found
What's NOT validated at build time:
- Templates (resolved lazily at render time)
- Command handlers
- Hook signatures (verified at registration)
Complete Example
use standout::cli::{App, HandlerResult, Output}; use standout_macros::{embed_templates, embed_styles}; use clap::{Command, Arg}; use serde::Serialize; #[derive(Serialize)] struct ListOutput { items: Vec<String>, } fn list_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListOutput> { let items = vec!["one".into(), "two".into()]; Ok(Output::Render(ListOutput { items })) } fn main() -> Result<(), Box<dyn std::error::Error>> { let cli = Command::new("myapp") .subcommand(Command::new("list").about("List items")); let app = App::builder() .templates(embed_templates!("src/templates")) .styles(embed_styles!("src/styles")) .default_theme("default") .context("version", env!("CARGO_PKG_VERSION")) .command("list", list_handler, "list.j2") .topics_dir("docs/topics") .build()?; app.run(cli, std::env::args()); Ok(()) }
Template src/templates/list.j2:
[header]Items[/header] ({{ items | length }} total)
{% for item in items %}
- {{ item }}
{% endfor %}
[muted]v{{ version }}[/muted]
Style src/styles/default.yaml:
header:
fg: cyan
bold: true
muted:
dim: true