Error Handling
Standout owns the shell adapter: handlers and hooks return errors as data, and
App::run performs the final stderr write and process exit. Ordinary handler,
hook, render, pipe, and final-write failures use status 1; Clap usage failures
use status 2.
The handler diagnostic framing
One framing covers every diagnostic Standout writes on an application's behalf:
a fixed Error: prefix, then the error's own Display text, then a newline.
Handler failures and hook failures both use it, so a reader sees one shape:
Error: could not read /etc/myapp.toml
Error: hook error (pre-dispatch): input `body`: Validation failed: body must not be empty
A hook's own Display names its phase, which is why a hook line carries
hook error ({phase}): inside the framing. The wording of these diagnostics is
internal, not contract (What Is Contract); an
application that must pin its stderr bytes writes them itself through
AppFailure, below.
The diagnostic document
Under --output json, yaml, csv or ndjson the framing above does not
apply: the failure is the stdout document, placed per mode as
Execution Outcomes
states. The document is Diagnostic (standout::cli::Diagnostic): type, schema_version,
severity, kind, summary, detail, and an optional range. An ordinary
error becomes summary from its Display with an empty detail; a handler
that has more to say returns a Diagnostic as its error:
#![allow(unused)] fn main() { use standout::cli::{Diagnostic, HandlerResult}; fn handler(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<View> { Err(Diagnostic::error("config line 2 does not parse") .detail("expected `resource <name> <state>`") .range("main.tfl", 2, 1) .into()) } }
In a human mode the same value is prose under the framing:
Error: main.tfl:2:1: config line 2 does not parse, the detail on the next
line. A hook failure reaches the document with the HookError's message as
summary; its phase is the kind (hook-pre-dispatch, hook-post-dispatch,
hook-post-output). The shape per mode, the kind vocabulary and the argv
scan that picks the mode for a pre-parse failure are in
Execution Outcomes.
Ordinary application errors
Return ordinary errors through HandlerResult with ?. Standout applies the
handler diagnostic framing, reports the failure under RunErrorKind::Handler,
and exits with status 1:
#![allow(unused)] fn main() { fn handler(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<View> { let view = load_view()?; Ok(Output::Render(view)) } }
Do not print or call process::exit from handlers. This keeps capture APIs,
TestHarness, output ownership, and real process behavior on the same seam.
An application-owned status and diagnostic
AppFailure is the seam for a domain error whose exit status and stderr bytes
the application's own specification pins. It carries any nonzero u8 and a
verbatim stderr payload: Standout adds no Error: prefix and no trailing
newline, and the status rides to the process exit. Construction rejects status
0, so a domain error can never report shell success.
#![allow(unused)] fn main() { use standout::cli::{AppFailure, HandlerResult}; fn handler(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<View> { let Some(repo) = find_repo()? else { return Err(AppFailure::new(1, "ghlike: repository not found: demo/gamma\n")?.into()); }; Ok(Output::Render(to_view(repo))) } }
A pre-dispatch guard reaches the same seam through
HookError::pre_dispatch_app. Capture callers see RunErrorKind::App.
Preserving an authoritative external failure
Use ExternalFailure when another operation owns the status and diagnostic
contract, such as a delegated Git invocation — the application is relaying a
verdict rather than reaching one, which is the whole difference from
AppFailure. Construction rejects status 0 and validates nothing else: an
empty diagnostic is accepted, which is what makes a nonzero exit with no output
on either stream expressible. The diagnostic is a verbatim stderr payload:
Standout adds no Error: prefix and no trailing newline.
#![allow(unused)] fn main() { use standout::cli::{ExternalFailure, HandlerResult}; fn handler(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<View> { let output = run_git()?; if !output.status.success() { let status = output.status.code().and_then(|code| u8::try_from(code).ok()).unwrap_or(1); let diagnostic = String::from_utf8_lossy(&output.stderr).into_owned(); return Err(ExternalFailure::new(status, diagnostic)?.into()); } Ok(Output::Render(to_view(output))) } }
A supported pre-dispatch check uses the same ExternalFailure interface:
#![allow(unused)] fn main() { Hooks::new().pre_dispatch(|_matches, _ctx| { let failure = ExternalFailure::new(128, "fatal: repository not found\n") .expect("128 is nonzero"); Err(HookError::pre_dispatch_external(failure)) }) }
What either failure writes to each stream under a structured mode is in Execution Outcomes.
Neither escape hatch is an error-mapping registry. Wrapping an ordinary error
does not change its status, and neither declaration is recognized from
post-dispatch or post-output hooks. Attach an underlying cause with
with_source when one exists.
Capture callers match result.outcome() / into_outcome() as
DispatchResult::Error, then inspect the kind (RunErrorKind::App or
RunErrorKind::External), error.exit_status(), and error.as_str(). See
Execution Outcomes and Testing.