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