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."

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:

  1. Clap parses the arguments
  2. Standout traverses the ArgMatches subcommand chain to find the deepest match
  3. If no subcommand was specified and a default command is configured, Standout inserts the default command and reparses
  4. It extracts the command path (e.g., ["db", "migrate"])
  5. It looks up the handler for that path
  6. It executes the handler with the appropriate ArgMatches slice

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:

AspectAppLocalApp
Handler storageArc<dyn Fn + Send + Sync>Rc<RefCell<dyn FnMut>>
Handler callhandler.handle(...) with &selfhandler.handle(...) with &mut self
Run methodapp.run(cmd, args) with &selfapp.run(cmd, args) with &mut self
Thread safetyYesNo

Choose LocalApp when your handlers need mutable access to captured state without interior mutability wrappers. See Handler Contract for detailed guidance.