The Handler Contract

Handlers are shell adapters: they map parsed CLI input to application calls and return serializable CLI-owned view data. Keep reusable behavior in a CLI-free library. The handler contract is designed to be explicit rather than permissive, so adapters remain testable and decoupled from output formatting.


Quick Start: The #[handler] Macro

For most handlers, use the #[handler] macro to write typed adapter functions:

use standout_macros::handler;

#[handler]
pub fn list(#[flag] all: bool, #[arg] limit: Option<usize>) -> Result<Vec<Item>, anyhow::Error> {
    storage::list(all, limit)
}

The macro leaves list alone and adds three items beside it:

ItemWhat it is
list__handler(&ArgMatches, &CommandContext)reads the arguments out of ArgMatches and calls list. It returns the annotated return type verbatim — here Result<Vec<Item>, anyhow::Error>, not HandlerResult<Vec<Item>>
list__expected_args() -> Vec<ExpectedArg>what App::verify_command reads
list_Handlera unit struct implementing Handler — the registrable item

The Result<T, E> to Output::Render wrap happens inside list_Handler's Handler::handle (through IntoHandlerResult), never in list__handler itself. That is the difference the next two tables spell out.

The un-suffixed handlers::list is not registrable — it has the wrong signature by design, so that a test can call it directly. Which of the other two items you register depends on the method:

MethodWhat it takesWhat to pass
AppBuilder::command_withimpl Handlerhandlers::list_Handler
GroupBuilder::command / command_with, and therefore #[derive(Dispatch)]a closure returning HandlerResult<T>handlers::list__handler

That second row constrains the return type, and it is the one place the two registration paths genuinely differ. list__handler returns the annotated type verbatim, so it satisfies HandlerResult<T> only when the function was written -> Result<Output<T>, E> (or -> Result<(), E>, whose wrapper returns HandlerResult<()>). A handler annotated -> Result<T, E> cannot be registered through #[derive(Dispatch)]: expansion fails with expected list__handler to return Result<Output<_>, Error>, but it returns Result<Items, Error>. Write it -> Result<Output<T>, E>, or register list_Handler through AppBuilder::command_with, where Handler::handle applies the wrap for you.

The three return shapes, and the original functions still being callable:

use standout::cli::{CommandContext, Output};
use standout::handler;

#[derive(serde::Serialize)]
pub struct Items {
    pub names: Vec<String>,
}

/// `Handler::Output` is `Items`; `handle` wraps the value in `Output::Render`.
#[handler]
pub fn list(#[flag] all: bool) -> Result<Items, anyhow::Error> {
    let mut names = vec!["ssh".to_string()];
    if all {
        names.push("cron".to_string());
    }
    Ok(Items { names })
}

/// `Handler::Output` is `Items`; the `Output` passes through untouched.
#[handler]
pub fn about(#[ctx] _ctx: &CommandContext) -> Result<Output<Items>, anyhow::Error> {
    Ok(Output::Render(Items { names: vec!["unitctl".to_string()] }))
}

/// `Handler::Output` is `()`; `handle` produces `Output::Silent`.
#[handler]
pub fn reload(#[flag] _force: bool) -> Result<(), anyhow::Error> {
    Ok(())
}

fn main() {
    // No ArgMatches, no dispatcher: the annotated function is what a unit test calls.
    assert_eq!(list(true).unwrap().names, ["ssh", "cron"]);
    reload(false).unwrap();
}

Every #[dispatch(…)] and #[handler] attribute is listed in the #[dispatch(…)] and #[handler] reference.

Parameter Annotations:

AnnotationTypeExtraction
#[flag]boolmatches.get_flag("name")
#[flag(name = "x")]boolmatches.get_flag("x")
#[arg]TRequired argument
#[arg]Option<T>Optional argument
#[arg]Vec<T>Multiple values
#[arg(name = "x")]TArgument with custom CLI name
#[ctx]&CommandContextAccess to context
#[matches]&ArgMatchesRaw matches (escape hatch)

Without name = "x", the argument id is the parameter name with underscores turned into hyphens: no_legend reads the argument id no-legend. Clap's own derive ids an argument by the field name it comes from, so a clap-derive no_legend field declares #[arg(id = "no-legend")] to meet the handler, or the handler parameter takes the field's id with #[flag(name = "no_legend")]. app.verify_command(&cmd) reports the mismatch instead of leaving it to a runtime get_flag panic. A parameter named with a raw identifier drops the r# first, the way clap's derive drops it from a field name: r#type reads the argument id type.

Return Type Handling: the function must return Result<T, E>; the macro rejects anything else with handler must return Result<T, E>. What T is decides what Handler::Output becomes and whether anything is wrapped.

Annotated return typeHandler::OutputWhat handle produces
Result<T, E>TOk(value) wrapped in Output::Render(value)
Result<Output<T>, E> (that is, HandlerResult<T>)Tthe Output you returned, unchanged
Result<(), E>()Output::Silent

Testing: The original function is preserved, so you can test directly: list(true, Some(10)).


The Handler Trait

pub trait Handler {
    type Output: Serialize;
    fn handle(&mut self, matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Self::Output>;
}

Key characteristics:

  • Mutable self: &mut self allows direct state modification
  • Output must be Serialize: Needed for JSON/YAML modes and template context

Implementing the trait directly is useful when your handler needs internal state—database connections, configuration, caches, etc.

Example: Struct Handler with State

use standout_dispatch::{Handler, Output, CommandContext, HandlerResult};
use clap::ArgMatches;
use serde::Serialize;

struct CachingDatabase {
    connection: Connection,
    cache: HashMap<String, Vec<Row>>,
}

impl CachingDatabase {
    fn query_with_cache(&mut self, sql: &str) -> Result<Vec<Row>, Error> {
        if let Some(cached) = self.cache.get(sql) {
            return Ok(cached.clone());
        }
        let result = self.connection.execute(sql)?;
        self.cache.insert(sql.to_string(), result.clone());
        Ok(result)
    }
}

impl Handler for CachingDatabase {
    type Output = Vec<Row>;

    fn handle(&mut self, matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Row>> {
        let query: &String = matches.get_one("query").unwrap();
        let rows = self.query_with_cache(query)?;  // &mut self works!
        Ok(Output::Render(rows))
    }
}

Closure Handlers

Most handlers are simple closures using FnHandler:

use standout_dispatch::{FnHandler, Output, HandlerResult};

let mut counter = 0;

let handler = FnHandler::new(move |_matches, _ctx| {
    counter += 1;  // Mutation works!
    Ok(Output::Render(counter))
});

The closure signature:

fn(&ArgMatches, &CommandContext) -> HandlerResult<T>
where T: Serialize

Closures are FnMut, allowing captured variables to be mutated.


SimpleFnHandler (No Context Needed)

When your handler doesn't need CommandContext, use SimpleFnHandler for a cleaner signature:

use standout_dispatch::SimpleFnHandler;

let handler = SimpleFnHandler::new(|matches| {
    let verbose = matches.get_flag("verbose");
    let items = storage::list()?;
    Ok(ListResult { items, verbose })
});

The closure signature:

fn(&ArgMatches) -> Result<T, E>
where T: Serialize, E: Into<anyhow::Error>

SimpleFnHandler automatically wraps the result in Output::Render via IntoHandlerResult.


IntoHandlerResult Trait

The IntoHandlerResult trait enables handlers to return Result<T, E> directly instead of HandlerResult<T>:

use standout_dispatch::IntoHandlerResult;

// Before: explicit Output wrapping
fn list(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Vec<Item>> {
    let items = storage::list()?;
    Ok(Output::Render(items))
}

// After: automatic conversion
fn list(_m: &ArgMatches, _ctx: &CommandContext) -> impl IntoHandlerResult<Vec<Item>> {
    storage::list()  // Result<Vec<Item>, Error> auto-converts
}

The trait is implemented for:

  • Result<T, E> where E: Into<anyhow::Error> → wraps Ok(t) in Output::Render(t)
  • HandlerResult<T> → passes through unchanged

This is used internally by SimpleFnHandler and the #[handler] macro.


HandlerResult

HandlerResult<T> is a standard Result type:

pub type HandlerResult<T> = Result<Output<T>, anyhow::Error>;

The ? operator works naturally for error propagation:

fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Items> {
    let items = storage::load()?;           // Propagates errors
    let filtered = filter_items(&items)?;   // Propagates errors
    Ok(Output::Render(Items { filtered }))
}

Owner-declared failures

AppFailure and ExternalFailure carry a nonzero status and a stderr payload the framework writes verbatim, through the same HandlerResult seam (Error Handling):

// The application's own specification pins the status and the line.
Err(AppFailure::new(1, "ghlike: repository not found: demo/gamma\n")?.into())

// A delegated executable decided both, and the handler is relaying them.
Err(ExternalFailure::new(128, git_stderr)?.into())

A successful run declares a status through the output, not through an error: Output::Render(data).with_exit_status(ExitStatus::from(2)) (Execution Outcomes).


The Output Enum

Output<T> represents what a handler produces:

#[non_exhaustive]
pub enum Output<T: Serialize> {
    Render(T),
    Silent,
    Binary { data: Vec<u8>, filename: String },
    Artifact(Artifact<T>),
    WithStatus { output: Box<Output<T>>, status: ExitStatus },
}

Output is #[non_exhaustive]: matches on it need a _ arm so later shapes can be added without breaking downstream code. WithStatus is built by with_exit_status and wraps a Render or Silent output together with the exit status the handler chose; split_exit_status() takes it apart, exit_status() reads it (SUCCESS when none was declared), map_render(f) reaches the rendered value through it, and the is_* predicates answer for the wrapped output. Declaring a status on Binary or Artifact is a render error.

Output::Render(T)

The common case. Data is passed to the render function:

#[derive(Serialize)]
struct ListResult {
    items: Vec<Item>,
    total: usize,
}

fn list_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ListResult> {
    let items = storage::list()?;
    Ok(Output::Render(ListResult {
        total: items.len(),
        items,
    }))
}

Output::Silent

No output produced. Useful for commands with side effects only:

fn delete_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> {
    let id: &String = matches.get_one("id").unwrap();
    storage::delete(id)?;
    Ok(Output::Silent)
}

Silent behavior:

  • Post-output hooks still receive RenderedOutput::Silent
  • Render function is not called
  • Nothing prints to stdout

Output::Binary

Raw bytes for file output:

fn export_handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> {
    let data = generate_report()?;
    let pdf_bytes = render_to_pdf(&data)?;

    Ok(Output::Binary {
        data: pdf_bytes,
        filename: "report.pdf".into(),
    })
}

Binary output bypasses the render function entirely.

The filename is a hint for the caller, not permission to write. Without --output-file-path, run() sends the bytes to stdout and touches no file. If you want the framework to write the suggested destination, use Output::Artifact — that opt-in is the whole difference between the two shapes.

Output::Artifact

Owned bytes plus an application-owned report, for commands that produce a file and have something to say about it. Output::Binary cannot carry a report, and nothing renders after its write, so a command that wants to say "exported 12 rows to /tmp/report.csv (2 warnings)" would otherwise have to write the file itself — pulling destination policy back into the application core.

use standout::cli::{Artifact, HandlerResult, Output};

#[derive(Serialize)]
struct ExportReport {
    exported: usize,
    warnings: Vec<Warning>,
}

fn export_handler(_m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<ExportReport> {
    let export = core::export_csv()?;   // bytes + facts, no filesystem

    Ok(Output::Artifact(
        Artifact::new(export.csv)
            .suggest_destination(export.suggested_filename)
            .with_report(ExportReport {
                exported: export.rows,
                warnings: export.warnings,
            }),
    ))
}

Artifact builder

Artifact<T> is T, the report type, plus a byte payload and two opt-in destination hints:

MethodPurpose
Artifact::new(bytes: impl Into<Vec<u8>>)Construct with the payload bytes. An empty vector is legal — a zero-byte artifact writes an empty file (or nothing to stdout).
.suggest_destination(path: impl Into<PathBuf>)Offer a default write path (destination policy step 2).
.allow_stdout()Permit the framework to fall back to stdout (step 3).
.with_report(report: T)Attach the report rendered after the write.

When the report is non-empty, the framework appends a newline after it via writeln!; it does not strip or collapse a newline the report already ends with, so a report that ends in \n produces a blank line. An absent or empty report emits nothing — no newline at all. A destination must still be selected first: an Artifact that suggests none, does not .allow_stdout(), and gets no --output-file-path fails with a stderr diagnostic rather than writing silently. Once a destination is selected and the write succeeds, a zero-byte artifact with no report adds nothing to either stream.

Who owns what:

ConcernOwner
Artifact bytesApplication
Suggested destinationApplication (a suggestion)
Semantic report and warning taxonomyApplication
Destination selectionFramework
The write and its failureFramework
Receipt (completed destination)Framework

Destination policy

Standout selects the destination deterministically:

  1. the explicit --output-file-path override;
  2. the artifact's suggest_destination(...), if the application opted in;
  3. stdout, if the application opted in with allow_stdout().

If none applies, the run fails with FinalWrite(Artifact) rather than inventing a file or dropping the bytes. All three steps share that one failure path.

Write first, report second

Standout writes, then renders the report from a fixed envelope:

{
  "report": { "exported": 12, "warnings": [] },
  "receipt": { "destination": "/tmp/report.csv", "stdout": false, "byte_count": 480 }
}

So a template can say what only the framework knows:

Exported {{ report.exported }} rows to {{ receipt.destination }}

The envelope shape is fixed (report + receipt) whatever the report's type, so no application key can collide with the receipt. Structured modes serialize the same envelope. A failed write renders nothing: success cannot outrun the write that justifies it.

The report channel

Mixing a report into the bytes would corrupt them, so the channel follows the destination:

Artifact destinationReport goes to
Filestdout
Stdout (allow_stdout())stderr

Hooks and artifacts

Post-dispatch hooks see the report as ordinary handler data. Post-output hooks see RenderedOutput::Artifact and can still transform the bytes or the report via as_artifact_mut(). Hooks never perform the write — that stays framework- owned, which is what keeps the failure path single and the report honest.

Bytes are owned; streaming is deliberately not part of this contract.


Incremental commands

Handler carries three associated types. Output is the batch value or the summary; Event is the type of the values the command produces while it runs; Outcome is what handle wraps the value in, and HandlerOutcome admits Output<T> only under NoEvents, so a command that declares events has Summary<T> and no payload variant to return. A batch command sets Event = NoEvents, whose uninhabited type leaves emit with no argument that can be constructed, and ignores the third parameter.

pub trait Handler {
    type Event: Serialize + 'static;
    type Output: Serialize;
    type Outcome: HandlerOutcome<Self::Output, Self::Event>;

    fn handle(
        &mut self,
        matches: &ArgMatches,
        ctx: &CommandContext,
        results: &mut Results<Self::Event>,
    ) -> Result<Self::Outcome, anyhow::Error>;
}

emits_events::<H::Event>() is how the consuming framework asks whether a command is incremental: every event type but NoEvents says it is. The fact comes from the handler's own signature rather than a declaration beside it, so no handler can say one thing and do another, and the framework can decide before the handler runs what counting one invocation's events could only tell it afterwards. It decides at build time that the command needs its <name>.event template; and it refuses an incremental command under an encoding that carries a command's results as one document before the handler runs.

Event is 'static because an associated type carries no lifetime from handle's parameters: an event holding a borrow of something the handler was passed has no way to name it here.

Results::emit takes the event by value, returns once the framework has retained it and written it, and fails when the value does not serialize (EmitError::Serialize), the destination cannot turn it into bytes (EmitError::Render) or cannot write them (EmitError::Write). Serialize is the whole bound Results adds: an event may hold an Rc or anything else that does not cross threads.

Results is &mut and not Clone, so a handler cannot store it or keep emitting past its own run, and it exposes emit and nothing else: a handler cannot ask which representation is running or where the bytes go.

The adapter behind a two-argument closure (FnHandler) sets Event = NoEvents; EventsFnHandler is the adapter behind a three-argument closure taking &mut Results<E>, and that closure returns Summary<T> — Render or Silent, with an exit status — rather than Output<T>. #[handler] picks between the two adapters, and between the two outcomes, from whether the function declares a Results parameter.

RunRecorder is the framework's own retention of the same values, passed to the dispatch closure rather than held on the context, so a handler cannot record values the channel never saw. EventSink is what the consuming framework implements to render or frame an event for a representation.


CommandContext

CommandContext provides execution environment information and state access:

pub struct CommandContext {
    pub command_path: Vec<String>,
    pub app_state: Rc<Extensions>,
    pub extensions: Extensions,
}

command_path: The subcommand chain as a vector, e.g., ["db", "migrate"]. Useful for logging or conditional logic.

app_state: Shared, immutable state configured at app build time via AppBuilder::app_state(). Held in an Rc<Extensions> for cheap cloning; the dispatch pipeline is single-threaded, so app state is not Send/Sync. Use for database connections, configuration, API clients.

extensions: Per-request, mutable state injected by pre-dispatch hooks. Use for user sessions, request IDs, computed values.

The context carries no presentation state. A command that produces its result while it runs receives the typed results channel as the third parameter of Handler::handle, not through the context; see Incremental commands.

For comprehensive coverage of state management, see App State and Extensions.


State Access: App State vs Extensions

Handlers access state through two distinct mechanisms with different semantics:

Aspectctx.app_statectx.extensions
MutabilityImmutable (&)Mutable (&mut)
LifetimeApp lifetimePer-request
Set byAppBuilder::app_state()Pre-dispatch hooks
Use forDatabase, Config, API clientsUser sessions, request IDs

App State (Shared Resources)

Configure long-lived resources at build time:

App::builder()
    .app_state(Database::connect()?)
    .app_state(Config::load()?)
    .command("list", list_handler, template)?
    .build()?

Access in handlers via ctx.app_state:

fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> {
    let db = ctx.app_state.get_required::<Database>()?;
    let config = ctx.app_state.get_required::<Config>()?;

    let items = db.query_items(config.max_results)?;
    Ok(Output::Render(items))
}

Extensions (Per-Request State)

Pre-dispatch hooks inject request-scoped state:

use standout_dispatch::{Hooks, HookError};

struct UserScope { user_id: String, permissions: Vec<String> }

let hooks = Hooks::new()
    .pre_dispatch(|matches, ctx| {
        // Can read app_state to set up per-request state
        let db = ctx.app_state.get_required::<Database>()?;

        let user_id = matches.get_one::<String>("user").unwrap().clone();
        let permissions = db.get_permissions(&user_id)?;

        ctx.extensions.insert(UserScope { user_id, permissions });
        Ok(())
    });

Handlers retrieve from extensions:

fn list_handler(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Vec<Item>> {
    let db = ctx.app_state.get_required::<Database>()?;       // shared
    let scope = ctx.extensions.get_required::<UserScope>()?;  // per-request

    let items = db.list_for_user(&scope.user_id)?;
    Ok(Output::Render(items))
}

Extensions API

Both app_state and extensions use the same Extensions type with these methods:

MethodDescription
insert<T>(value)Insert a value, returns previous if any
get<T>()Get immutable reference, returns Option<&T>
get_required<T>()Get reference or return error if missing
get_mut<T>()Get mutable reference, returns Option<&mut T>
remove<T>()Remove and return value
contains<T>()Check if type exists
len()Number of stored values
is_empty()True if no values stored
clear()Remove all values

Use get_required for mandatory dependencies (fails fast with clear error), get for optional ones.

When to Use Which

Use App State for:

  • Database connections — expensive to create, should be pooled
  • Configuration — loaded once at startup
  • API clients — shared HTTP clients with connection pooling

Use Extensions for:

  • User context — current user, session, permissions
  • Request metadata — request ID, timing, correlation ID
  • Transient state — data computed by one hook, used by handler

The Two-State Pattern

The separation exists because:

  1. Closure capture doesn't work with #[derive(Dispatch)] — macro-generated dispatch calls handlers with a fixed signature
  2. App-level resources shouldn't be created per-request — database pools and config are expensive
  3. Per-request state needs mutable injection — hooks compute values at runtime
// App state: configured once at build time
App::builder()
    .app_state(Database::connect()?)  // Shared via Rc
    .hooks("users.list", Hooks::new()
        .pre_dispatch(|matches, ctx| {
            // Extensions: computed per-request, can use app_state
            let db = ctx.app_state.get_required::<Database>()?;
            let user = authenticate(matches, db)?;
            ctx.extensions.insert(user);
            Ok(())
        }))?

For comprehensive coverage of state management patterns, see App State and Extensions.


Accessing CLI Arguments

The ArgMatches parameter provides access to parsed arguments through clap's standard API:

fn handler(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Data> {
    // Flags
    let verbose = matches.get_flag("verbose");

    // Required options
    let name: &String = matches.get_one("name").unwrap();

    // Optional values
    let limit: Option<&u32> = matches.get_one("limit");

    // Multiple values
    let tags: Vec<&String> = matches.get_many("tags")
        .map(|v| v.collect())
        .unwrap_or_default();

    Ok(Output::Render(Data { ... }))
}

For subcommands, you work with the ArgMatches for your specific command level.


Testing Handlers

Because handlers have explicit inputs and outputs, their adapter behavior is straightforward to test directly. Test validation, filtering, and state transitions through the CLI-free library instead:

#[test]
fn test_list_handler() {
    let cmd = Command::new("test")
        .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue));
    let matches = cmd.try_get_matches_from(["test", "--verbose"]).unwrap();

    let ctx = CommandContext {
        command_path: vec!["list".into()],
        ..Default::default()
    };

    let result = list_handler(&matches, &ctx);

    assert!(result.is_ok());
    if let Ok(Output::Render(data)) = result {
        assert!(data.verbose);
    }
}

No mocking frameworks needed—construct ArgMatches with clap, create a CommandContext, call your handler, assert on the result.

Testing with App State

When handlers depend on app_state, inject test fixtures:

#[test]
fn test_handler_with_app_state() {
    use std::rc::Rc;

    // Create test fixtures
    let mock_db = MockDatabase::with_items(vec![
        Item { id: "1", name: "Test" }
    ]);

    // Build app_state with test data
    let mut app_state = Extensions::new();
    app_state.insert(mock_db);

    let ctx = CommandContext {
        command_path: vec!["list".into()],
        app_state: Rc::new(app_state),
        ..Default::default()
    };

    let cmd = Command::new("test");
    let matches = cmd.try_get_matches_from(["test"]).unwrap();

    let result = list_handler(&matches, &ctx);
    assert!(result.is_ok());
}

Testing Handlers with Mutable State

Handler tests can verify state mutation across calls:

#[test]
fn test_handler_state_mutation() {
    struct Counter { count: u32 }

    impl Handler for Counter {
        type Output = u32;
        fn handle(&mut self, _m: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<u32> {
            self.count += 1;
            Ok(Output::Render(self.count))
        }
    }

    let mut handler = Counter { count: 0 };
    let cmd = Command::new("test");
    let matches = cmd.try_get_matches_from(["test"]).unwrap();
    let ctx = CommandContext {
        command_path: vec!["count".into()],
        ..Default::default()
    };

    // State accumulates across calls
    let _ = handler.handle(&matches, &ctx);
    let _ = handler.handle(&matches, &ctx);
    let result = handler.handle(&matches, &ctx);

    assert!(matches!(result, Ok(Output::Render(3))));
}