Execution Model
Standout manages a strict linear pipeline from CLI input to rendered output. This explicitly separated flow ensures that logic (Handlers) remains decoupled from presentation (Templates) and side-effects (Hooks).
Understanding this model allows you to extend the framework predictably—knowing exactly where to intercept execution, what data is available, and how to test each stage in isolation.
The Pipeline
Clap Parsing → Dispatch → Handler → Hooks → Rendering → Output
Each stage has a clear responsibility:
Clap Parsing: Your clap::Command definition is augmented with Standout's flags (--output, custom help) and parsed normally. Standout doesn't replace clap—it builds on top of it.
Dispatch: Standout extracts the command path from the parsed ArgMatches, navigating through subcommands to find the deepest match. It then looks up the registered handler for that path.
Handler: Your logic function executes. It receives the ArgMatches and a CommandContext, returning a HandlerResult<T>—either data to render, a silent marker, or binary content. With App, handlers use &self; with LocalApp, handlers use &mut self (see Handler Contract).
Hooks: If registered, hooks run at three points around the handler. They can validate, transform, or intercept without modifying handler logic.
Rendering: Handler output is serialized and passed through the template engine, producing styled terminal output (or structured data like JSON, depending on output mode).
Output: The result is written to stdout or a file.
This pipeline is what Standout manages for you—the glue code between "I have a clap definition" and "I want rich, testable output."
- See Handler Contract for handler details.
- See Rendering System for the render phase.
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"].
When you register commands with AppBuilder:
#![allow(unused)] fn main() { App::builder() .command("list", list_handler, "list.j2") .group("db", |g| g .command("migrate", migrate_handler, "db/migrate.j2") .command("status", status_handler, "db/status.j2")) .build()? }
Standout builds an internal registry mapping paths to handlers:
["list"]→list_handler["db", "migrate"]→migrate_handler["db", "status"]→status_handler
The command path is available in CommandContext.command_path for your handler to inspect, and uses dot notation ("db.migrate") when registering hooks.
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.
Use for: authentication checks, input validation, logging start time. The hook receives ArgMatches and CommandContext but returns no data—only success or failure.
#![allow(unused)] fn main() { Hooks::new().pre_dispatch(|matches, ctx| { if !is_authenticated() { return Err(HookError::pre_dispatch("authentication required")); } Ok(()) }) }
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, compression, logging. The hook receives RenderedOutput—an enum of Text(String), Binary(Vec<u8>, String), or Silent.
#![allow(unused)] fn main() { 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.
Attaching Hooks
Two approaches:
#![allow(unused)] fn main() { // Via AppBuilder.hooks() with dot-notation path App::builder() .command("migrate", migrate_handler, "migrate.j2") .hooks("db.migrate", Hooks::new() .pre_dispatch(require_admin)) .build()? // Via command_with() inline App::builder() .command_with("migrate", migrate_handler, |cfg| cfg .template("migrate.j2") .pre_dispatch(require_admin)) .build()? }
See App Configuration for more on registration.
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 becomes the command output
HookError includes the phase and an optional source error for context:
#![allow(unused)] fn main() { HookError::pre_dispatch("database connection failed") .with_source(db_error) }
Dispatch Details
When app.run() executes:
- Clap parses the arguments
- Standout traverses the
ArgMatchessubcommand chain to find the deepest match - If no subcommand was specified and a default command is configured, Standout inserts the default command and reparses
- It extracts the command path (e.g.,
["db", "migrate"]) - It looks up the handler for that path
- It executes the handler with the appropriate
ArgMatchesslice
Default Command Behavior
When you configure a default command:
#![allow(unused)] fn main() { App::builder() .default_command("list") }
A "naked" invocation like myapp or myapp --verbose will automatically dispatch to the list command. The arguments are modified internally to insert the command name, then reparsed. This ensures all clap validation and parsing rules apply correctly to the default command.
If no handler matches, run() returns Some(matches), letting you fall back to manual dispatch:
#![allow(unused)] fn main() { if let Some(matches) = app.run(cmd, args) { // Standout didn't handle this command, fall back to legacy match matches.subcommand() { Some(("legacy", sub)) => legacy_handler(sub), _ => {} } } }
This enables gradual adoption—Standout handles some commands while others use your existing code.
What Standout Adds to Your Command
When you call app.run(), Standout augments your clap::Command with:
Custom help subcommand:
myapp help # Main help
myapp help topic-name # Specific topic
myapp help --page # Use pager for long content
Global --output flag:
myapp list --output=json
myapp db status --output=yaml
Values: auto, term, text, term-debug, json, yaml, xml, csv
Global --output-file-path flag:
myapp list --output-file-path=results.txt
These flags are global—they apply to all subcommands. You can rename or disable them via AppBuilder:
#![allow(unused)] fn main() { App::builder() .output_flag(Some("format")) // Rename to --format .no_output_file_flag() // Disable file output }
App vs LocalApp Dispatch
Both App and LocalApp follow the same pipeline, with one key difference:
| Aspect | App | LocalApp |
|---|---|---|
| Handler storage | Arc<dyn Fn + Send + Sync> | Rc<RefCell<dyn FnMut>> |
| Handler call | handler.handle(...) with &self | handler.handle(...) with &mut self |
| Run method | app.run(cmd, args) with &self | app.run(cmd, args) with &mut self |
| Thread safety | Yes | No |
Choose LocalApp when your handlers need mutable access to captured state without interior mutability wrappers. See Handler Contract for detailed guidance.