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:
- Templating and Styling System for templates and styles.
- Topics System for help topics.
Basic Setup
#![allow(unused)] fn main() { use standout::cli::{App, FnHandler}; 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_with("list", FnHandler::new(list_handler), |config| config.template_name("list"))? .build()?; app.run(Cli::command(), std::env::args()); }
Embedding Resources
Templates
embed_templates! embeds template files at compile time:
#![allow(unused)] fn main() { .templates(embed_templates!("src/templates")) }
Collects files matching: .jinja, .jinja2, .j2, .stpl, .txt (in priority order).
Custom template engines: For advanced use cases,
standout-rendersupports pluggable template engines. See the Template Engines topic for details on usingSimpleEngineor implementing custom engines.
Directory structure:
src/templates/
list.j2
add.j2
db/
migrate.j2
status.j2
Templates are referenced by path without extension: "list", "db/migrate".
Styles
embed_styles! embeds stylesheet files:
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) }
Collects files matching: .css (and legacy .yaml, .yml).
src/styles/
default.css
dark.css
light.css
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.
Resources Read at Run Time
templates_dir and styles_dir add a directory read at run time, for
resources that live outside the crate source tree and so cannot be embedded:
#![allow(unused)] fn main() { App::builder() .templates(embed_templates!("src/templates")) .templates_dir("~/.myapp/templates") // Adds names the binary does not embed .styles(embed_styles!("src/styles")) .styles_dir("~/.myapp/themes") // Likewise for themes }
These directories add names; they do not replace them. A registry resolves
an embedded name before it looks at any directory registered this way, so a
~/.myapp/templates/list.jinja sitting beside an embedded list never
renders — see Resolution Priority.
To let a user directory win, register only the directory, without the
embed_templates! call, when that directory exists.
Theme Selection
From Stylesheet Registry
#![allow(unused)] fn main() { .styles(embed_styles!("src/styles")) .default_theme("dark") }
.default_theme(name) names the theme build() loads from the stylesheet
registry; if that name isn't found, build() returns
SetupError::ThemeNotFound. With no .default_theme(...) call, build()
does not fall back to any conventional name — the application resolves to no
application theme, leaving the framework's own base styling.
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) }
.theme(...) sets the theme directly, bypassing the stylesheet registry.
Calling both .styles(...) and .theme(...) on the same builder is a
SetupError that names both calls — configure one path or the other, not
both.
Command Registration
Simple Commands
#![allow(unused)] fn main() { App::builder() .command_with("list", FnHandler::new(list_handler), |cfg| cfg)? .command_with("add", FnHandler::new(add_handler), |cfg| cfg)? }
AppBuilder::command_with takes an impl Handler, not a bare function, so a
plain fn(&ArgMatches, &CommandContext) -> HandlerResult<T> is wrapped in
FnHandler::new(...) first; a #[handler]-annotated function registers as the
name_Handler struct the macro generates instead. (GroupBuilder::command_with
— the entry .commands(...) and #[derive(Dispatch)] reach — takes the bare
closure and wraps it for you, which is why the nested-group example below does
not name FnHandler.)
With no .template_name(...) set on the CommandConfig, the template
resolves by convention: the command path with . replaced by / (list,
add), matched against the registered templates using the extension list
.jinja, .jinja2, .j2, .stpl, .txt.
With Configuration
#![allow(unused)] fn main() { App::builder() .command_with("delete", FnHandler::new(delete_handler), |cfg| cfg .template_name("delete") .pre_dispatch(require_confirmation) .post_dispatch(log_deletion))? }
Inline command configuration can also attach a
StructuredOutputProjection for CSV shaping. The projection stays at the
presentation boundary: it sees post-dispatch data, and handlers remain
independent of the selected output mode. See Output Modes.
Nested Groups
#![allow(unused)] fn main() { App::builder() .commands(|g| g .group("db", |g| g .command("migrate", migrate_handler) .command("status", status_handler) .group("backup", |b| b .command("create", backup_create) .command("restore", backup_restore))))? }
Creates command paths: db.migrate, db.status, db.backup.create,
db.backup.restore. Each resolves, by convention, to a template named after
its path (db/migrate, db/status, db/backup/create,
db/backup/restore); attach a CommandConfig to a group entry with
command_with instead of command when one needs .template_name(...) or
another CommandConfig setting.
From Dispatch Macro
#![allow(unused)] fn main() { #[derive(Dispatch)] #[dispatch(handlers = handlers)] enum Commands { List, Add, #[dispatch(nested)] Db(DbCommands), } App::builder() .commands(Commands::dispatch_config())? }
#[dispatch(handlers = <module path>)] on the enum is required: it names the
module Dispatch looks up each variant's handler function in (handlers::list
for List, handlers::add for Add). A nested variant's own type
(DbCommands here) needs its own #[derive(Dispatch)] with its own
#[dispatch(handlers = ...)] — the macro generates registration for all
variants, but a container attribute is scoped to the enum it's on.
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, "{{ items | length }} items") .command("add", add_handler, "Added {{ name }}") }
With this configuration:
myappbecomesmyapp listmyapp --output=jsonbecomesmyapp list --output=jsonmyapp add foostays asmyapp add foo(explicit command takes precedence)
Default resolution applies to both the integrated dispatch path (run, run_with) and configured parsing (get_matches_from). If you parse first and build dispatch state afterwards, the matches you get back already name the resolved command.
Invocation-Aware Defaults
A fixed name can't express "it depends". default_command_with chooses the default per invocation:
#![allow(unused)] fn main() { App::builder() .default_command_with(|ctx| { Some(if ctx.stdin_is_piped() { "add" } else { "list" }.to_string()) }) .command("list", list_handler, "{{ items | length }} items") .command("add", add_handler, "Added {{ name }}") }
myappat a terminal becomesmyapp listcat notes.txt | myappbecomesmyapp add, which reads the pipemyapp done 3stays asmyapp done 3
The resolver receives a DefaultCommandContext exposing only the facts needed to pick a command:
| Method | Fact |
|---|---|
matches() | The parsed root ArgMatches — globals and root flags |
app_state::<T>() | Read-only app state registered via .app_state(...) |
stdin_is_terminal() / stdin_is_piped() | Whether stdin is redirected |
Plus std::env for env-derived facts. The matches are the root's, so global flags and root arguments are all there; there is no subcommand, because that is what makes the invocation naked.
Stdin is never read during resolution. The terminal check is the same non-consuming StdinReader::is_terminal seam the input system uses, so a handler's InputChain still consumes the pipe normally afterwards. This also means piped-but-empty stdin is a pipe, not a terminal — emptiness is only knowable by reading, which resolution never does. If empty input should be an error, that's the receiving command's InputChain policy, not the resolver's.
Ordering guarantees
Clap decides which command a line named, and resolution reads that decision. A parse that selected a subcommand is not naked; a parse that selected none is.
- Explicit and nested commands short-circuit resolution — the resolver never runs.
--help/--versionare Clap's own displays: no default is inserted, somyapp --helprenders the root's help rather than a default command's.- Invalid syntax stays a Clap usage error (exit 2). If a default command is configured, a refused line is offered to it —
myapp --allis a naked line at a root that has no--all, and becomesmyapp list --allwhen--allbelongs tolist— and whatever the amended line parses to, success or failure, is what you get. --, option values, aliases, and short clusters mean exactly what they mean everywhere else, because the same parser reads them.
A root that requires a subcommand — what #[command(subcommand)] command: Commands produces — still accepts a naked invocation: the line is refused, the default is substituted, and the amended line parses. The field does not have to be Option<Commands>. See ADR-0018.
Combining both
Both may be configured together. The resolver is consulted first; returning None declines to the static default:
#![allow(unused)] fn main() { App::builder() // Pipes mean `add`; everything else falls back to `list`. .default_command("list") .default_command_with(|ctx| ctx.stdin_is_piped().then(|| "add".to_string())) }
Returning a name that isn't a command of your clap::Command fails the run with RunErrorKind::DefaultCommand (exit 1), carrying a diagnostic that names the offending resolver output and lists the valid commands. A resolver naming a command the CLI doesn't have is an application bug, so it's reported as one rather than reaching Clap as a usage error blaming the user. Return None to decline.
Validation is against your clap::Command's names, not Standout's registered handlers — so partial adoption stays coherent: resolving to a Clap command Standout doesn't handle yields NoMatch, exactly as typing it explicitly would.
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_with("db.migrate", FnHandler::new(migrate_handler), |cfg| cfg)? .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.
Hook order, and where a questionnaire sits in it
Pre-dispatch hooks run in the order they were registered — Hooks keeps them
in a list and run_pre_dispatch walks it front to back. The same holds for
post-dispatch and post-output.
CommandConfig::questionnaire::<T>() is a pre-dispatch hook, so it takes its
place in that same order: a .pre_dispatch(f) written before it runs before
the answers are resolved and cannot read them, and one written after it runs
with ctx.questionnaire::<T>() already populated.
// `check_permissions` runs first, then the questionnaire resolves,
// then `audit` runs and can read the answers.
CommandConfig::new(handler)
.pre_dispatch(check_permissions)
.questionnaire::<ProvisionAnswers>()
.pre_dispatch(audit)
One trap goes with that: CommandConfig::hooks(hooks) replaces the config's
hook set rather than appending to it, so calling .hooks(…) after
.questionnaire::<T>() discards the questionnaire's own hook and the answers
never resolve. Register per-phase with .pre_dispatch(…) when a questionnaire
is involved.
Registering the same phase for one path through both CommandConfig and
AppBuilder::hooks is a configuration error naming the path and the phase,
rather than one hook set silently replacing the other.
Stating a single ordering rule for pre-dispatch hooks — including which matches they receive — is issue #352 in the adopter-seams epic. What is written above is today's behavior.
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.
They also receive ctx.ambiguous_width(), the application's explicit
East Asian Ambiguous character-width policy. Configure it at the rendering
seam; narrow is the compatibility default and Standout does not infer a locale:
#![allow(unused)] fn main() { use standout::{AmbiguousWidth, cli::App}; let app = App::builder() .ambiguous_width(AmbiguousWidth::Wide) .build()?; }
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.
Version
Application version metadata belongs on the builder, next to the rest of the app's configuration:
#![allow(unused)] fn main() { App::builder() .version(env!("CARGO_PKG_VERSION")) }
Standout applies the value to the root command wherever it augments and parses
it, so every entry point — run, run_with, get_matches_from, and
TestHarness — answers myapp --version the same way:
Clap's own display, on stdout, exit status 0, typed as
SuccessKind::ClapVersion (see Execution
Outcomes).
Clap keeps owning the spelling and formatting of that output and the display
short-circuit; the builder only says what the version is. Leave .version()
unset and the supplied clap::Command is untouched, including a version
configured on Clap directly.
This is separate from .context("version", …), which puts a value in templates
({{ version }}); an app that wants both says both.
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 }
Output Mode Fallback
The mode used when the flag is absent from the command line. It defaults to
OutputMode::Auto; an application that decides its own default — from its own
environment variable, a config file, or anything else it reads at build time —
sets it here:
#![allow(unused)] fn main() { App::builder() .output_mode_fallback(OutputMode::Term) }
Precedence is --output first, then the fallback. An explicit --output always
wins, so this sets the default rather than overriding the user. Forcing color
regardless of mode is a separate axis and is not what this call does.
Every path that renders without an --output on the command line uses the
fallback: command output, both help spellings (app help and app --help), the
--output help entry's advertised default, and the diagnostics of errors raised
before parsing. app --help renders in the fallback even when the command line
does carry an --output — the help flags never read the flag (Help).
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. The theme build() merged is always present
(theme: Theme); get_default_theme() returns &Theme.
#![allow(unused)] fn main() { pub struct App { registry: TopicRegistry, output_flag: Option<String>, output_mode_fallback: OutputMode, output_file_flag: Option<String>, theme: Theme, command_hooks: HashMap<String, Hooks>, template_registry: Option<TemplateRegistry>, stylesheet_registry: Option<StylesheetRegistry>, } }
Running the App
Standard Execution
#![allow(unused)] fn main() { if !app.run(Cli::command(), std::env::args()) { // Standout did not handle this command; fall back to legacy dispatch. legacy_dispatch(); } }
Parses args, dispatches to a handler, and performs the final write. It returns
true when Standout handled the command and false for an unmatched fallback.
Help/version and successes use stdout/status 0, usage errors use stderr/status
2, and runtime/write failures use stderr/status 1. The two owner-declared
failures are the exceptions: AppFailure carries the application's own nonzero
status and verbatim stderr payload, and ExternalFailure preserves an
authoritative external operation's. See Error Handling.
Capture Output
For tests, reach for standout_test::TestHarness (see Testing).
For post-processing, or any other embedding caller that needs the output
string, pass destination properties and input sources in explicitly:
#![allow(unused)] fn main() { let target = TargetProperties::detect(); let sources = InputSources::from_process(); let result = app.run_with(cmd, args, target, sources); let _ = result.warnings(); match result.into_outcome() { DispatchResult::Handled(output) => { /* use output string */ } DispatchResult::Binary(bytes, filename) => { /* handle binary */ } DispatchResult::Error(error) => { /* inspect error.kind() */ } DispatchResult::NoMatch(matches) => { /* fallback dispatch */ } _ => {} } }
Returns CompletedRun instead of printing: a wrapper around DispatchResult
plus framework warnings. Use exit_status(), success_kind(), and
error_kind() for typed assertions; see Execution
Outcomes.
Parse Only
#![allow(unused)] fn main() { match app.get_matches_from(cmd, std::env::args(), &InputSources::from_process()) { HelpResult::Matches(matches) => { /* use matches for manual dispatch */ } HelpResult::Help(text) | HelpResult::PagedHelp(text) => { /* the invocation asked for help */ } HelpResult::Error(e) => { /* a clap::Error: usage failure, or --version display */ } } }
Parses with Standout's augmented command, intercepting help display; returns matches only when the invocation didn't trigger a help/usage/version display.
Build Validation
build() validates:
- a theme registry exists and contains the theme named by
.default_theme(...) - named templates resolve through
.templates(...)or.templates_dir(...) - convention templates resolve when application templates are configured; without application templates, human-mode rendering reports the missing convention template at runtime
- registered templates compile
- framework templates only use tags defined by the resolved theme
command_groups, topics, andhelp_word(true)are not combined with.help_handling(false)- commands do not collide with the
helpword standout installs when help handling is on - the same command path and hook phase are not configured through both
CommandConfigandAppBuilder::hooks
What's NOT validated at build time:
- Command handlers
- Hook signatures (verified at registration)
Complete Example
use standout::cli::{App, CommandContext, FnHandler, HandlerResult, Output}; use standout_macros::{embed_templates, embed_styles}; use clap::{Command, ArgMatches}; 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") .version(env!("CARGO_PKG_VERSION")) .context("version", env!("CARGO_PKG_VERSION").into()) .command_with("list", FnHandler::new(list_handler), |config| { config.template_name("list") })? .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.css:
.header { color: cyan; font-weight: bold; }
.muted { opacity: 0.5; }