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

FunctionPurpose
extract_command_pathGet subcommand chain as Vec<String>
path_to_stringConvert path to dot notation ("db.migrate")
string_to_pathConvert dot notation to path
get_deepest_matchesGet ArgMatches for deepest subcommand
has_subcommandCheck 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()?;
}
ModeMethodBehavior
Passthroughpipe_to()Run command, return original output
Capturepipe_through()Return command's stdout as new output
Consumepipe_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:

  1. Clear pipeline — Each stage has defined inputs and outputs
  2. Hook points — Intercept before, after handler, and after render
  3. Command routing — Utilities for navigating subcommand hierarchies
  4. Presentation ownership — Rendering stays outside dispatch handlers
  5. Testable stages — Each component can be tested in isolation