Execution Model
standout-dispatch manages a strict linear pipeline from CLI input to rendered
output. This explicitly separated flow keeps handler adapters decoupled from
presentation (renderers) and shell pipeline 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 CLI adapter executes. It receives ArgMatches and
CommandContext, calls the CLI-free application library, and returns a
HandlerResult<T>—either data to render, a silent marker, or binary content.
For simpler adapters, use the #[handler] macro to write typed 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
| Function | Purpose |
|---|---|
extract_command_path | Get subcommand chain as Vec<String> |
path_to_string | Convert path to dot notation ("db.migrate") |
string_to_path | Convert dot notation to path |
get_deepest_matches | Get ArgMatches for deepest subcommand |
has_subcommand | Check 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 App State and Extensions for the Extensions API and 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(TextOutput), Binary(Vec<u8>, String), Artifact(ArtifactOutput), or Silent. For an artifact the bytes and report are still pre-write, so a hook may transform them via as_artifact_mut(); the framework, not the hook, performs the final write.
#![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_name("export") // Filter through jq (capture mode) .pipe_through("jq '.items'") }) .command_with("copy", handlers::copy, |cfg| { cfg.template_name("copy") // Send to clipboard (consume mode) .pipe_to_clipboard() }) .command_with("debug", handlers::debug, |cfg| { cfg.template_name("debug") // Log to file while displaying (passthrough mode) .pipe_to("tee /tmp/debug.log") }) }) .build()?; }
| Mode | Method | Behavior |
|---|---|---|
| Passthrough | pipe_to() | Run command, return original output |
| Capture | pipe_through() | Return command's stdout as new output |
| Consume | pipe_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) }
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, 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 hooks let hooks = Hooks::new() .pre_dispatch(|_m, _ctx| { println!("Starting command..."); Ok(()) }); // 4. 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 { println!("{}", serde_json::to_string_pretty(&data)?); } } "delete" => { let deep = get_deepest_matches(&matches); delete_handler.handle(deep, &ctx)?; println!("Deleted."); } _ => eprintln!("Unknown command"), }; Ok(()) }
Summary
The execution model provides:
- Clear pipeline — Each stage has defined inputs and outputs
- Hook points — Intercept before, after handler, and after render
- Command routing — Utilities for navigating subcommand hierarchies
- Presentation ownership — Rendering stays outside dispatch handlers
- Testable stages — Each component can be tested in isolation