Standout
Test your data. Render your view.
Standout is a CLI framework for Rust that enforces separation between logic and presentation. Keep application behavior in a CLI-free library; handlers adapt that behavior into serializable CLI view data instead of strings.
The Problem
CLI code that mixes logic with println! statements is impossible to unit test:
#![allow(unused)] fn main() { // You can't unit test this—it writes directly to stdout fn list_command(show_all: bool) { let todos = storage::list().unwrap(); println!("Your Todos:"); for todo in todos.iter() { if show_all || todo.status == Status::Pending { println!(" {} {}", if todo.done { "[x]" } else { "[ ]" }, todo.title); } } } }
The only way to test this is regex on captured stdout. That's fragile, verbose, and couples your tests to presentation details.
The Solution
With Standout, the library owns behavior, handlers return CLI view data, and the framework handles rendering:
#![allow(unused)] fn main() { #[handler] fn list( #[flag] all: bool, #[ctx] ctx: &CommandContext, ) -> Result<Output<TodoListView>, anyhow::Error> { let store = ctx.app_state.get_required::<TodoStore>()?; let filter = if all { TodoFilter::All } else { TodoFilter::Pending }; let todos = store.list(filter).into_iter().map(TodoView::from).collect(); let total = todos.len(); Ok(Output::Render(TodoListView { todos, total })) } #[test] fn test_list_returns_pending_view() { let Output::Render(result) = list(false, &ctx).unwrap() else { panic!("expected rendered data"); }; assert!(result.todos.iter().all(|todo| !todo.done)); } }
Test filtering and state transitions through the library interface. Test only the mapping and returned view struct in the handler. No stdout capture, regex, or template coupling. See the production-shaped application.
Standing Out
What Standout provides:
- Enforced architecture splitting data and presentation
- Logic is testable as any Rust code — and full CLI invocations are testable in-process via the
standout-testharness, without subprocess spawning or stdout parsing - Boilerplateless: declaratively link your handlers to command names and templates, Standout handles the rest
- Autodispatch: save keystrokes with auto dispatch from the known command tree
- Free output handling: rich terminal with graceful degradation, plus structured data (JSON, YAML, CSV)
- Finely crafted output:
- File-based templates for content and CSS for styling
- Rich styling with adaptive properties (light/dark modes), inheritance, and full theming
- Powerful templating through MiniJinja, including partials (reusable, smaller templates for models displayed in multiple places)
- Hot reload: changes to templates and styles don't require compiling
- Declarative layout support for tabular data
Quick Start
1. Define Your Commands and Handlers
Use the Dispatch derive macro to connect commands to typed handler adapters.
#![allow(unused)] fn main() { use standout::cli::{CommandContext, Dispatch, Output}; use standout::handler; use clap::Subcommand; use serde::Serialize; #[derive(Subcommand, Dispatch)] #[dispatch(handlers = handlers)] // handlers are in the `handlers` module pub enum Commands { #[dispatch(pure)] List, #[dispatch(pure)] Add { title: String }, } #[derive(Serialize)] struct TodoResult { todos: Vec<Todo>, } mod handlers { use super::*; #[handler] pub fn list(#[ctx] ctx: &CommandContext) -> Result<Output<TodoResult>, anyhow::Error> { let core = ctx.app_state.get_required::<TodoStore>()?; Ok(Output::Render(TodoResult::from(core.list(TodoFilter::Pending)))) } #[handler] pub fn add( #[arg] title: String, #[ctx] ctx: &CommandContext, ) -> Result<Output<TodoResult>, anyhow::Error> { let core = ctx.app_state.get_required::<TodoStore>()?; Ok(Output::Render(TodoResult::from(vec![core.add(title)?]))) } } }
2. Define Your Presentation
Templates use MiniJinja with semantic style tags. Styles are defined separately in CSS.
{# list.jinja #}
[title]My Todos[/title]
{% for todo in todos %}
- {{ todo.title }} ([status]{{ todo.status }}[/status])
{% endfor %}
/* styles/default.css */
.title { color: cyan; font-weight: bold; }
.status { color: yellow; }
3. Wire It Up
use 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
- CLI-free library separated from shell presentation
- Handlers adapt library results; framework handles rendering
- Core behavior and CLI adapters testable without stdout capture
Output Modes
- Rich terminal output with colors and styles
- Automatic JSON, YAML, CSV serialization from the same handler
- Graceful degradation when terminal lacks capabilities
Rendering
- MiniJinja templates with semantic style tags
- CSS stylesheets with light/dark mode support
- Hot reload during development—edit templates without recompiling
- Tabular layouts with alignment, truncation, and Unicode support
Integration
- Clap integration with automatic dispatch
- Declarative command registration via derive macros
Installation
cargo add standout standout-dispatch
Migrating an Existing CLI
Already have a CLI? Standout supports incremental adoption. run reports
whether Standout handled the command:
#![allow(unused)] fn main() { if !app.run(Cli::command(), std::env::args()) { your_existing_dispatch(); } }
Use run_to_string(...) and match RunResult::NoMatch(matches) when the legacy
dispatcher needs the unmatched ArgMatches.
See the Partial Adoption Guide for the full migration path.
Next Steps
- Introduction to Standout — Adopting Standout in a working CLI. Start here.
- Introduction to Testing — Why Standout CLIs are testable by design, and how the
standout-testharness replaces slow, brittle subprocess tests with fast in-process ones. - Introduction to Rendering — Creating polished terminal output
- Introduction to Tabular — Building aligned, readable tabular layouts
- All Topics — In-depth documentation for specific systems