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

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:

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 build and then tdoo 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] to bool flags, #[arg] to required/optional args
  • Auto-wraps Result<T, E> in Output::Render for you

Parameter Annotations:

AnnotationTypeWhat it extracts
#[flag]boolBoolean flag (--verbose)
#[arg]TRequired argument
#[arg]Option<T>Optional argument
#[arg]Vec<T>Multiple values
#[ctx]&CommandContextAccess 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 (Listhandlers::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: Run tdoo 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, or pipe_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.css or src/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: Run tdoo list - you should see colored, styled output! Verify: Run tdoo 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 col filter (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() accepts FnMut closures
  • Handlers can capture &mut references to state
  • The Handler trait uses &mut self for struct-based handlers
  • No Send + Sync requirements—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 named list.jinja, list.j2, or list.txt.
  • 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 .mystyle in CSS or mystyle: in YAML. Run with --output term-debug to 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 List maps to function handlers::list (snake_case conversion). Or use explicit mapping: #[dispatch(handler = my_custom_handler)]
  • JSON output is empty or wrong
    • Symptom: --output json produces unexpected results.
    • Cause: Serialize derive 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.
  • Styles not loading
    • Error: theme not found: default
    • Cause: Stylesheet file missing or wrong path.
    • Fix: Ensure src/styles/default.css or default.yaml exists. Check embed_styles! path matches your file structure.

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:


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 (#ff6b35 or [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 --output flag 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:

  1. Separation of concerns: Data stays separate from templates. Templates define structure. Styles control appearance.

  2. Fast iteration: Hot reload means edit-and-see in seconds, not minutes. This changes what's practical.

  3. Familiar tools: MiniJinja for templates (Jinja2 syntax), CSS or YAML for styles. No new languages to learn.

  4. Graceful degradation: One template serves rich terminals, plain pipes, and everything in between.

  5. Structured output for free: JSON, YAML outputs work automatically from your serializable types.

  6. 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:


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 col filter 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:

WidthMeaning
8Exactly 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:

StyleLook
"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:

  1. Start simple - use col filter for quick alignment
  2. Structure with tabular() - centralize column definitions
  3. Flex with widths - use fill, bounded ranges, and fractions
  4. Align content - right-align numbers and dates
  5. Anchor columns - pin important data to edges
  6. Handle overflow - truncate intelligently or wrap
  7. Add visual hierarchy - style columns and values dynamically
  8. Extract automatically - let row_from() pull fields from structs
  9. 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

AttributeCSS PropertyDescription
fgcolorForeground (text) color
bgbackgroundBackground 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

YAMLCSSEffect
bold: truefont-weight: boldBold text
dim: trueopacity: 0.5Dimmed/faint text
italic: truefont-style: italicItalic text
underline: truetext-decoration: underlineUnderlined text
blink: truetext-decoration: blinkBlinking text
reverse: trueSwap fg/bg colors
hidden: truevisibility: hiddenHidden text
strikethrough: truetext-decoration: line-throughStrikethrough

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:

  1. Start with base attributes (bold: true, fg: gray)
  2. Merge dark overrides (fg: white replaces fg: gray)
  3. 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: abc → 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 ModeBehavior
TermUnknown tags get a ? marker: [unknown?]text[/unknown?]
TextTags stripped (plain text)
TermDebugTags 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:

ModeBehaviorUse Case
TermReplace tags with ANSI escape codesRich terminal output
TextStrip tags completelyPlain text, pipes, files
TermDebugKeep tags as literal textDebugging, 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 --output CLI 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

  1. Inline templates (added via add_template())
  2. 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.jinja in registered directories
  • report.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)?;
}
ModeBehavior
TermRender template, apply styles
TextRender template, strip styles
TermDebugRender template, keep style tags
Jsonserde_json::to_string_pretty(data)
Yamlserde_yaml::to_string(data)
CsvFlatten 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:

  1. Edit a template or stylesheet
  2. Re-run your program
  3. See changes immediately

During release, you want:

  1. A single binary with no external dependencies
  2. No file paths to manage
  3. 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

ExtensionPriority
.jinja1 (highest)
.jinja22
.j23
.txt4 (lowest)

If both report.jinja and report.txt exist, report.jinja is used.

Stylesheets

ExtensionFormat
.cssCSS syntax
.yamlYAML syntax
.ymlYAML 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:

  1. Inline (added via add() or add_template())
  2. File-based directories (in order added, later = higher priority)
  3. 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:

  1. Testing is painful — You have to capture stdout and parse it
  2. No format flexibility — Want JSON output? Write a whole new function
  3. Error handling is crudeexpect or scattered error messages
  4. Logic and presentation intertwined — Can't reuse the logic elsewhere
  5. 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 Result with 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

PhaseTimingReceivesCan
pre_dispatchBefore handlerArgMatches, &mut ContextAbort execution, inject state
post_dispatchAfter handler, before renderArgMatches, Context, DataTransform data
post_outputAfter renderArgMatches, Context, OutputTransform output

State Injection: Pre-dispatch hooks can inject dependencies via ctx.extensions that 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:

  1. Clean separation — Handlers return data, renderers produce output
  2. Pluggable rendering — Use any output format without changing handlers
  3. Hook system — Cross-cutting concerns without code duplication
  4. Testable design — Handlers are pure functions with explicit contracts
  5. 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 App and AppBuilder APIs 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 ArgMatches based on annotations
  • Auto-wraps Result<T, E> in Output::Render via IntoHandlerResult
  • Preserves the original function for direct testing

Parameter Annotations:

AnnotationTypeExtraction
#[flag]boolmatches.get_flag("name")
#[flag(name = "x")]boolmatches.get_flag("x")
#[arg]TRequired argument
#[arg]Option<T>Optional argument
#[arg]Vec<T>Multiple values
#[arg(name = "x")]TArgument with custom CLI name
#[ctx]&CommandContextAccess to context
#[matches]&ArgMatchesRaw matches (escape hatch)

Return Type Handling:

Return TypeGenerated 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 self allows 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> where E: Into<anyhow::Error> → wraps Ok(t) in Output::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:

Aspectctx.app_statectx.extensions
MutabilityImmutable (&)Mutable (&mut)
LifetimeApp lifetimePer-request
Set byAppBuilder::app_state()Pre-dispatch hooks
Use forDatabase, Config, API clientsUser 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:

MethodDescription
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:

  1. Closure capture doesn't work with #[derive(Dispatch)] — macro-generated dispatch calls handlers with a fixed signature
  2. App-level resources shouldn't be created per-request — database pools and config are expensive
  3. 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

FunctionPurpose
extract_command_pathGet subcommand chain as Vec<String>
path_to_stringConvert path to dot notation ("db.migrate")
string_to_pathConvert dot notation to path
get_deepest_matchesGet ArgMatches for deepest subcommand
has_subcommandCheck 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()?;
}
ModeMethodBehavior
Passthroughpipe_to()Run command, return original output
Capturepipe_through()Return command's stdout as new output
Consumepipe_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 output
  • view: 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:

  1. Clear pipeline — Each stage has defined inputs and outputs
  2. Hook points — Intercept before, after handler, and after render
  3. Command routing — Utilities for navigating subcommand hierarchies
  4. Pluggable rendering — Render functions are separate from handlers
  5. 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:

  1. Structured output — JSON/YAML support
  2. Testable logic — Handler is a pure function
  3. Error handling? operator, proper error types
  4. Hook points — Add logging, auth without touching handler

Progressive Enhancement

As you migrate more commands:

  1. Shared hooks — Apply auth check to all migrated commands
  2. Consistent output — Same renderer for all commands
  3. 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:

  1. Start small — Migrate one command at a time
  2. Reduce risk — Each migration is independent
  3. Maintain velocity — Keep shipping while migrating
  4. 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:

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-render supports pluggable template engines. See the Template Engines topic for details on using SimpleEngine or 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:

  1. default
  2. theme
  3. base

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:

  • myapp becomes myapp list
  • myapp --output=json becomes myapp list --output=json
  • myapp add foo stays as myapp 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::ThemeNotFound if 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