Framework Integration

This page describes how standout-input plugs into the standout CLI framework so that input chains become a declarative part of your command configuration. If you only want to use standout-input standalone, see Introduction to Input — the framework integration is purely additive.


The Picture

Without framework integration, a handler resolves chains imperatively:

#![allow(unused)]
fn main() {
fn create(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Pad> {
    let body = InputChain::<String>::new()
        .try_source(ArgSource::new("body"))
        .try_source(StdinSource::new())
        .try_source(EditorSource::new())
        .resolve(matches)?;          // <-- handler does this itself

    /* business logic ... */
}
}

That works, but the chain becomes invisible to anyone reading the command's registration: input rules are mixed in with logic, and you can't see at a glance "this command takes a body that may come from arg / stdin / editor".

With the integration, the chain is part of CommandConfig, just like template, hooks, and pipe_through:

#![allow(unused)]
fn main() {
use standout::cli::{App, CommandContextInput, Output};
use standout::input::{ArgSource, EditorSource, InputChain, StdinSource};

App::builder()
    .command_with("create", create, |cfg| {
        cfg.template("create.jinja")
            .input("body", InputChain::<String>::new()
                .try_source(ArgSource::new("body"))
                .try_source(StdinSource::new())
                .try_source(EditorSource::new()))
    })?
    .build()?;

fn create(_m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Value> {
    let body: &String = ctx.input("body")?;     // <-- already resolved
    /* business logic ... */
}
}

The chain runs in the pre-dispatch phase — before the handler is called — so handlers always see fully-resolved input. Errors during resolution (validation failure, editor cancelled, …) abort the request before any business logic runs.


Where Resolution Happens

standout's execution pipeline runs hooks in three phases:

parsed CLI args → PRE-DISPATCH → handler → POST-DISPATCH → render → POST-OUTPUT

.input(name, chain) is sugar over .pre_dispatch(...) — the same hook used for auth checks, request-scoped state, etc. Each .input(...) call adds one pre-dispatch hook that:

  1. Walks to the deepest subcommand's ArgMatches (so chains see the same args the handler does).
  2. Calls chain.resolve_with_source(matches).
  3. Stashes the result in an Inputs bag on ctx.extensions under name.

If resolution returns an error, dispatch stops and the framework reports Hook error: input `body`: <error message>. The handler does not run.


Reading Inputs in the Handler

Bring the CommandContextInput extension trait into scope and call .input::<T>(name):

#![allow(unused)]
fn main() {
use standout::cli::{CommandContextInput, Output};

fn create(_m: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Value> {
    let body: &String = ctx.input("body")?;
    let force: &bool = ctx.input("force")?;

    /* ... */
}
}

The lookup is by (name, T). If the name was never registered, you get a MissingInput::NotRegistered error. If the registered type doesn't match T, you get MissingInput::TypeMismatch. The error type implements std::error::Error and converts cleanly with ?.

Inspecting the source

Sometimes you want to know where an input came from — for instance, to log "title was read from clipboard" or to alter behavior when input is piped vs. interactive:

#![allow(unused)]
fn main() {
match ctx.input_source("body") {
    Some(InputSourceKind::Editor) => log::info!("body composed in editor"),
    Some(InputSourceKind::Stdin) => log::info!("body piped from stdin"),
    Some(other) => log::debug!("body came from {other}"),
    None => unreachable!("body is registered, so it was resolved"),
}
}

Iterating all inputs

For diagnostic output (like --explain flags) you can grab the whole bag:

#![allow(unused)]
fn main() {
if let Some(bag) = ctx.inputs() {
    for (name, source) in bag.iter_sources() {
        eprintln!("  {name}: {source}");
    }
}
}

Multiple Inputs

.input(...) accumulates. A command can declare any number of named inputs of any types — including multiple inputs of the same type, which the TypeId-keyed ctx.app_state / raw ctx.extensions cannot disambiguate:

#![allow(unused)]
fn main() {
.command_with("create", create, |cfg| {
    cfg.template("create.jinja")
        .input("title", InputChain::<String>::new()
            .try_source(ArgSource::new("title"))
            .default("untitled".to_string()))
        .input("body", InputChain::<String>::new()
            .try_source(ArgSource::new("body"))
            .try_source(StdinSource::new())
            .try_source(EditorSource::new()))
        .input("force", InputChain::<bool>::new()
            .try_source(FlagSource::new("force"))
            .default(false))
})
}

Each chain runs in registration order during pre-dispatch. They share the same Inputs bag on ctx.extensions, so two String inputs (title, body) coexist without colliding.


Validation

Chain-level validation runs as part of resolve_with_source. If validation fails on a non-interactive source, the pre-dispatch hook returns an error and dispatch aborts:

#![allow(unused)]
fn main() {
.input("body", InputChain::<String>::new()
    .try_source(ArgSource::new("body"))
    .validate(|s| !s.trim().is_empty(), "body must not be empty"))
}

If the user runs mycli create --body " ", the framework reports:

Hook error: input `body`: validation failed: body must not be empty

For interactive sources (prompts, editor), validation failure re-prompts instead of aborting — the chain decides the loop. See Backends for the full validation/retry semantics.


Testing

The framework path composes naturally with standout-test:

#![allow(unused)]
fn main() {
use standout_test::TestHarness;

#[test]
fn create_uses_arg_when_provided() {
    let app = build_app();
    let cmd = my_clap_command();

    let result = TestHarness::new()
        .text_output()
        .run(&app, cmd, ["mycli", "create", "--body", "hello"]);

    result.assert_stdout_contains("hello");
}

#[test]
fn create_falls_back_to_stdin() {
    let app = build_app();
    let cmd = my_clap_command();

    let result = TestHarness::new()
        .piped_stdin("from pipe\n")
        .text_output()
        .run(&app, cmd, ["mycli", "create"]);

    result.assert_stdout_contains("from pipe");
}
}

The harness installs MockStdin / MockClipboard via standout-input's process-global default readers, so StdinSource::new() and ClipboardSource::new() inside the chain transparently see the mocks. No source code changes are needed to make the chain testable.

For lower-level tests that don't need the harness, you can manipulate the readers directly with set_default_stdin_reader and friends; serialize tests that touch them with #[serial] from serial_test.


Re-exports and Feature Flags

standout re-exports standout-input as standout::input, so a single dependency on standout is enough:

[dependencies]
standout = "7"
#![allow(unused)]
fn main() {
use standout::input::{ArgSource, InputChain, StdinSource};
}

A default standout dependency only enables standout-input's simple-prompts backend, which has no extra deps. The heavier backends are opt-in via these standout features:

FeatureEnablesAdds deps
input-editorEditorSource (opens $VISUAL / $EDITOR)tempfile, which, shell-words
input-inquireThe Inquire* rich TUI prompt sourcesinquire (~29 transitive)
[dependencies]
standout = { version = "7", features = ["input-editor"] }

You can still depend on standout-input directly if you want to bypass the standout re-export and pick features there.


When NOT to Use the Builder Integration

The standalone chain.resolve(matches)? form is still the right tool when:

  • Input shape depends on already-resolved values. If --mode decides which other inputs to ask for, you can't precompute a static chain.
  • You're adopting standout incrementally and your handler isn't yet on the framework path.
  • You're using standout-input outside the standout framework altogether.

In every other case, .input(...) keeps the command's input contract visible at registration time, alongside its template and hooks.