Introduction to Input Collection
CLI applications need input from multiple sources: command-line arguments, piped stdin, environment variables, interactive prompts, and editors. Managing these sources with proper fallback logic and validation is tedious and error-prone.
standout-input provides a declarative API for input collection with automatic fallback chains. Define where input can come from, and the library handles the rest.
See Also:
- Backends - Detailed backend options and custom implementations
- Introduction to Standout - Full framework integration
The Problem
Typical CLI input handling looks like this:
#![allow(unused)] fn main() { fn get_message(matches: &ArgMatches) -> Result<String, Error> { // Try CLI argument first if let Some(msg) = matches.get_one::<String>("message") { return Ok(msg.clone()); } // Try stdin if piped if !std::io::stdin().is_terminal() { let mut buffer = String::new(); std::io::stdin().read_to_string(&mut buffer)?; if !buffer.trim().is_empty() { return Ok(buffer.trim().to_string()); } } // Try environment variable if let Ok(msg) = std::env::var("MY_MESSAGE") { return Ok(msg); } // Fall back to prompting print!("Enter message: "); std::io::stdout().flush()?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; Ok(line.trim().to_string()) } }
Problems:
- Imperative logic obscures the intended priority
- Hard to test (stdin, environment, terminal detection)
- Duplicated across commands
- Easy to miss edge cases (empty input, whitespace)
The Solution: Input Chains
standout-input replaces imperative logic with declarative chains:
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EnvSource, TextPromptSource}; let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) // 1. CLI argument .try_source(StdinSource::new()) // 2. Piped stdin .try_source(EnvSource::new("MY_MESSAGE")) // 3. Environment variable .try_source(TextPromptSource::new("Enter message: ")) // 4. Interactive prompt .resolve(&matches)?; }
The chain tries each source in order. The first source that provides input wins. If all sources return None, the chain returns InputError::NoInput.
Benefits:
- Declarative — Priority is explicit and readable
- Testable — All sources accept mocks for deterministic testing
- Composable — Build chains for different commands with shared sources
- Validated — Add validation rules that apply to any source
Quick Start
Add standout-input to your Cargo.toml:
[dependencies]
standout-input = "0.1"
Basic Chain
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, DefaultSource}; use clap::{Command, Arg}; // Set up clap let cmd = Command::new("myapp") .arg(Arg::new("message").short('m').long("message")); let matches = cmd.get_matches(); // Build an input chain let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) .try_source(StdinSource::new()) .default("Hello, World!".to_string()) .resolve(&matches)?; }
This chain:
- Checks if
--messagewas provided - If not, reads from stdin (only if piped, not interactive)
- Falls back to the default value
With Validation
Add validation rules that apply regardless of the source:
#![allow(unused)] fn main() { let email = InputChain::<String>::new() .try_source(ArgSource::new("email")) .try_source(TextPromptSource::new("Email: ")) .validate(|s| s.contains('@'), "Must be a valid email address") .validate(|s| s.len() >= 5, "Email too short") .resolve(&matches)?; }
For interactive sources (prompts, editor), validation failures trigger re-prompting. For non-interactive sources (args, stdin), validation failures return an error.
Knowing the Source
Sometimes you need to know where input came from:
#![allow(unused)] fn main() { use standout_input::InputSourceKind; let result = InputChain::<String>::new() .try_source(ArgSource::new("file")) .try_source(StdinSource::new()) .default("default.txt".to_string()) .resolve_with_source(&matches)?; match result.source { InputSourceKind::Arg => println!("From --file argument"), InputSourceKind::Stdin => println!("From piped input"), InputSourceKind::Default => println!("Using default"), _ => {} } let filename = result.value; }
Available Sources
Non-Interactive Sources
These sources don't require user interaction and work in CI/scripted environments:
| Source | Type | Description |
|---|---|---|
ArgSource | String | CLI argument value |
FlagSource | bool | CLI flag (true/false) |
StdinSource | String | Piped stdin (skipped if stdin is a terminal) |
EnvSource | String | Environment variable |
ClipboardSource | String | System clipboard contents |
DefaultSource<T> | T | Fallback value |
Interactive Sources (Feature-Gated)
These require a terminal and are feature-gated to control dependencies:
simple-prompts feature (default, no dependencies):
| Source | Type | Description |
|---|---|---|
TextPromptSource | String | Basic text input prompt |
ConfirmPromptSource | bool | Yes/no confirmation prompt |
editor feature (default, adds tempfile + which):
| Source | Type | Description |
|---|---|---|
EditorSource | String | Opens $VISUAL/$EDITOR for multi-line input |
inquire feature (optional, adds inquire crate):
| Source | Type | Description |
|---|---|---|
InquireText | String | Rich text input with autocomplete |
InquireConfirm | bool | Polished yes/no prompt |
InquireSelect<T> | T | Single selection with arrow keys |
InquireMultiSelect<T> | Vec<T> | Multiple selection with checkboxes |
InquirePassword | String | Masked password input |
InquireEditor | String | Editor with preview |
See Backends for full documentation on each source.
Standalone Prompts (No Chain)
Chains shine for CLI commands that need fallback between sources. For interactive flows that drive standout themselves — wizards, REPLs, setup helpers — every interactive source has a .prompt() shortcut that skips the chain machinery and the &ArgMatches plumbing entirely:
#![allow(unused)] fn main() { use standout_input::{InquireConfirm, InquireSelect, InquireText}; let pack: String = InquireText::new("Pack name:") .help("a-z0-9-") .prompt()?; let env: String = InquireSelect::new("Environment:", vec!["dev", "staging", "prod"]) .prompt()? .to_string(); let proceed: bool = InquireConfirm::new("Continue?") .default(true) .prompt()?; }
prompt() returns Result<T, InputError> directly — no Option to unwrap. Stdin not being a TTY or an empty submission both map to [InputError::NoInput], so a re-ask loop is just match on the error. User cancellation is reported as a backend-specific variant (PromptCancelled for prompts, EditorCancelled for editors); see the Interactive Flows topic for the full table.
Available on every interactive source:
| Source | Returns |
|---|---|
TextPromptSource, ConfirmPromptSource | Result<String, _>, Result<bool, _> |
EditorSource | Result<String, _> |
InquireText, InquireConfirm, InquirePassword, InquireEditor | as above |
InquireSelect<T>, InquireMultiSelect<T> | Result<T, _>, Result<Vec<T>, _> |
The InputCollector impls are unchanged — these sources still work in chains exactly as before. See Interactive Flows for a full wizard walkthrough that pairs .prompt() with standout's renderer.
Common Patterns
The gh pr create Pattern
Many CLI tools follow this pattern for body text:
#![allow(unused)] fn main() { // arg → stdin → editor → default let body = InputChain::<String>::new() .try_source(ArgSource::new("body")) .try_source(StdinSource::new()) .try_source(EditorSource::new().extension(".md")) .default(String::new()) .resolve(&matches)?; }
Confirmation with --yes Flag
Skip prompts in scripts with a flag override:
#![allow(unused)] fn main() { let confirmed = InputChain::<bool>::new() .try_source(FlagSource::new("yes")) .try_source(ConfirmPromptSource::new("Proceed?").default(false)) .resolve(&matches)?; }
Running with --yes returns true immediately. Without the flag, the user is prompted.
API Token with Environment Fallback
#![allow(unused)] fn main() { let token = InputChain::<String>::new() .try_source(ArgSource::new("token")) .try_source(EnvSource::new("GITHUB_TOKEN")) .try_source(InquirePassword::new("GitHub token:")) .resolve(&matches)?; }
Clipboard Prefill
For tools like paste managers:
#![allow(unused)] fn main() { let content = InputChain::<String>::new() .try_source(ArgSource::new("content")) .try_source(StdinSource::new()) .try_source(ClipboardSource::new()) .try_source(EditorSource::new()) .resolve(&matches)?; }
Testing
All sources accept mock implementations, enabling deterministic tests without actual terminal I/O, environment variables, or clipboard access.
Mocking Stdin
#![allow(unused)] fn main() { use standout_input::{StdinSource, MockStdin}; // Simulate piped input let source = StdinSource::with_reader(MockStdin::piped("test content")); // Simulate interactive terminal (no piped input) let source = StdinSource::with_reader(MockStdin::terminal()); }
Mocking Environment Variables
#![allow(unused)] fn main() { use standout_input::{EnvSource, MockEnv}; let env = MockEnv::new() .with_var("API_KEY", "secret123") .with_var("DEBUG", "true"); let source = EnvSource::with_reader("API_KEY", env); }
Mocking Clipboard
#![allow(unused)] fn main() { use standout_input::{ClipboardSource, MockClipboard}; let source = ClipboardSource::with_reader(MockClipboard::with_content("clipboard text")); let source = ClipboardSource::with_reader(MockClipboard::empty()); }
Mocking Prompts
#![allow(unused)] fn main() { use standout_input::{TextPromptSource, MockTerminal}; // Simulate user typing "Alice" and pressing Enter let source = TextPromptSource::with_terminal("Name: ", MockTerminal::with_response("Alice")); // Simulate multiple responses for retry scenarios let terminal = MockTerminal::with_responses(["invalid", "valid@email.com"]); }
Mocking Editor
#![allow(unused)] fn main() { use standout_input::{EditorSource, MockEditorRunner}; // Simulate editor returning content let source = EditorSource::with_runner(MockEditorRunner::with_result("user input")); // Simulate no editor available let source = EditorSource::with_runner(MockEditorRunner::no_editor()); }
Full Integration Test
#![allow(unused)] fn main() { use standout_input::{InputChain, ArgSource, StdinSource, EnvSource, MockStdin, MockEnv}; use clap::{Command, Arg}; #[test] fn test_input_priority() { let cmd = Command::new("test") .arg(Arg::new("token").long("token")); // Test: env var is used when arg is not provided let matches = cmd.clone().get_matches_from(["test"]); let chain = InputChain::<String>::new() .try_source(ArgSource::new("token")) .try_source(StdinSource::with_reader(MockStdin::terminal())) .try_source(EnvSource::with_reader("TOKEN", MockEnv::new().with_var("TOKEN", "from-env"))); let result = chain.resolve(&matches).unwrap(); assert_eq!(result, "from-env"); // Test: arg overrides env var let matches = cmd.get_matches_from(["test", "--token", "from-arg"]); let chain = InputChain::<String>::new() .try_source(ArgSource::new("token")) .try_source(EnvSource::with_reader("TOKEN", MockEnv::new().with_var("TOKEN", "from-env"))); let result = chain.resolve(&matches).unwrap(); assert_eq!(result, "from-arg"); } }
Feature Flags
standout-input uses feature flags to control dependencies:
| Feature | Default | Dependencies | Provides |
|---|---|---|---|
editor | Yes | tempfile, which | EditorSource |
simple-prompts | Yes | none | TextPromptSource, ConfirmPromptSource |
inquire | No | inquire (~29 deps) | Rich TUI prompts |
Minimal Dependencies
For the smallest footprint:
[dependencies]
standout-input = { version = "0.1", default-features = false }
This gives you only non-interactive sources (~2 dependencies).
Full Feature Set
[dependencies]
standout-input = { version = "0.1", features = ["inquire"] }
Standalone vs. Standout Framework
standout-input works as a standalone library with any clap-based CLI:
#![allow(unused)] fn main() { // Standalone usage use standout_input::{InputChain, ArgSource, StdinSource}; let message = InputChain::<String>::new() .try_source(ArgSource::new("message")) .try_source(StdinSource::new()) .resolve(&matches)?; }
When using the full Standout framework, input chains integrate with the dispatch system:
#![allow(unused)] fn main() { // With Standout framework (future integration) use standout::cli::App; use standout_input::{InputChain, ArgSource, EditorSource}; App::builder() .command_with("create", handlers::create, |cfg| { cfg.input("body", |chain| { chain .try_source(ArgSource::new("body")) .try_source(EditorSource::new()) }) }) .build()?; }
Summary
standout-input transforms CLI input handling from imperative spaghetti into declarative chains:
- Declarative priority — Source order is explicit in the chain definition
- Testable — All sources accept mocks for deterministic testing
- Feature-gated — Control dependencies with feature flags
- Validated — Chain-level validation with retry support for interactive sources
- Composable — Build reusable source configurations
For detailed information on specific backends, including how to implement custom sources, see Backends.