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:
- Walks to the deepest subcommand's
ArgMatches(so chains see the same args the handler does). - Calls
chain.resolve_with_source(matches). - Stashes the result in an
Inputsbag onctx.extensionsundername.
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:
| Feature | Enables | Adds deps |
|---|---|---|
input-editor | EditorSource (opens $VISUAL / $EDITOR) | tempfile, which, shell-words |
input-inquire | The Inquire* rich TUI prompt sources | inquire (~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
--modedecides which other inputs to ask for, you can't precompute a static chain. - You're adopting
standoutincrementally and your handler isn't yet on the framework path. - You're using
standout-inputoutside thestandoutframework altogether.
In every other case, .input(...) keeps the command's input contract visible at registration time, alongside its template and hooks.