Execution Outcomes

Standout carries shell semantics as typed data from argument parsing through dispatch, rendering, hooks, output files, and final writes. Applications can let App::run own output without losing the distinction between a usage error and a runtime failure.

Status and streams

OutcomeStream used by run() and run_emitted()Status
Help or versionstdout0
Successful rendered text or binarystdout0
Output::Silentnone0
Success with a status the handler declaredstdout, as any successexact declared status
--output-file-path successfile only0
Artifact written to a filebytes to the file, report to stdout0
Artifact written to stdoutbytes to stdout, report to stderr0
Artifact with no selectable destinationstderr1
Clap usage errorstderr2
Handler, hook, render, pipe, or write failurestderr1
Application-declared external failurestderrexact declared nonzero status

The framework names no exit code beyond 0, 1 and 2. A successful run that wants to say more — "planned, with changes", "checked, with findings", "listed nothing" — declares its own status, and that status is success-with-signal, never an error: the result still goes to stdout, nothing becomes a diagnostic, warnings flush as usual, and only the process status differs. The Rust ecosystem has no convention for such codes (ExitCode knows 0 and 1, clap uses 2, cargo's 101 marks a panic), so the meaning of a declared status is the application's to document.

// A plan with changes exits 2, the way `terraform plan -detailed-exitcode` does.
let output = Output::Render(plan);
if detailed_exitcode && has_changes {
    return Ok(output.with_exit_status(ExitStatus::from(2)));
}
Ok(output)

// A list that found nothing exits 3; a non-empty list exits 0.
Ok(list_view(items).empty_exit_status(3).output())

Output::with_exit_status(ExitStatus) is the general form and applies to Output::Render and Output::Silent; calling it again replaces the status. A status declared on Output::Binary or Output::Artifact is a render error (1), since those outcomes carry no status of their own. ListViewBuilder::empty_exit_status(n) is the list case (List View). The capture API (App::run_command) returns rendered output with no status (it still reports the binary and artifact case as an error), so a declared status is visible through run, run_emitted, dispatch, run_with and the test harness, which report it through exit_status().

The failure rows describe the human representation and its term-debug diagnostic view; under a structured encoding a failure is the stdout document instead, below.

Framework warnings flush after the primary output and do not change its status. A final write failure does replace a successful status with 1 (below).

Failures under a structured mode

When the resolved representation is json, yaml, csv or ndjson, stdout carries the result or the diagnostic, never both, and stderr carries nothing the framework wrote for the failure. In the single-document modes that is one document per run; under ndjson the diagnostic is one compact line at the point in the stream where the run failed, after the entries the handler already wrote (Output Modes). Statuses do not change. An App or External failure still writes its verbatim bytes to stderr and adds the stdout document, kind app or external, with the bytes as detail and their first line as summary. Warnings stay prose on stderr in the single-document modes, because the document has one root; under ndjson each is a severity: warning diagnostic entry of kind framework on stdout, after the result or the failure.

The document is Diagnostic, serialized flat; range is present only when set:

{
  "type": "diagnostic",
  "schema_version": 1,
  "severity": "error",
  "kind": "handler",
  "summary": "config line 2 does not parse",
  "detail": "expected `resource <name> <state>`",
  "range": { "filename": "main.tfl", "start": { "line": 2, "column": 1 } }
}

kind is a DiagnosticKind, the RunErrorKind projected onto the fixed wire vocabulary: clap-usage, default-command, handler, hook-pre-dispatch, hook-post-dispatch, hook-post-output, render, final-write (for every FinalWrite payload), external, app — plus framework, the kind of a warning entry, which no run failure produces. In csv the document is one row whose header ends in range_filename, range_line and range_column, empty when there is no range. RunError::diagnostic() is the value, emit_run_result writes it, and standout::cli::parse_diagnostic reads it back (under ndjson, out of the whole stream), as TestResult::diagnostic() does. How an error fills summary and detail is in Error Handling.

A failure clap reports before a parse completes — an unknown flag, a default-command miss, --help — takes its mode from a scan of the raw argv for the output flag: the last occurrence wins, --output json and --output=json alike, and nothing after -- counts. A value that is not a mode (--output jsn) scans as no mode at all, so the run stays in the fallback mode: when the parse reaches the value, clap reports it as a usage error, prose on stderr, exit 2.

Emitting without exiting

run_emitted is run up to the exit: it detects the destination, dispatches, writes both streams through whichever delivery the run chose, flushes warnings, and then returns a ProcessOutcome instead of ending the process. run calls it and exits with the status it reports, so the two never differ in what reaches stdout, stderr, a file, or the pager.

let outcome = app.run_emitted(Cli::command(), std::env::args());
telemetry.shutdown();
std::process::exit(outcome.status.code().into());

run_emitted writes through standout::cli::emit_run_result(&outcome, output_mode, &mut stdout, &mut stderr), which is public: standout-test's harness writes its two streams with it, and an adopter that keeps its own process edge can too. It returns Ok(handled) or the final-write failure whose status replaces the run's own. Under ndjson the warnings follow through standout::cli::emit_warning_entries; every other mode flushes them as stderr prose, and so does a NoMatch handoff in every mode, since Standout then owns no stdout. Events a handler emits through its Results channel reach stdout while the handler runs, before either: run_emitted dispatches over a StreamSink around the process's stdout and writes the result and the warning entries through the same sink, so a --output-file-path that retargeted the sink receives the whole run (events, result or diagnostic, warnings) and stdout nothing. Nothing reads that run's values back, so under the human template and ndjson it writes each event without keeping it and a long run costs no memory for its length; json, yaml and csv hold the records until the command ends, because there is no document to write before then. run_with and run_with_sink capture the events instead and return them as CompletedRun::entries(), alongside the values in CompletedRun::results().

ProcessOutcome has two public fields. handled is the bool that run returns: false only for a NoMatch handoff. status is the final ExitStatus after output errors, the one run would have exited with (final writes); a NoMatch handoff reports ExitStatus::SUCCESS because Standout emitted nothing. Everything has been written when the call returns, so a process-lifetime resource — a span exporter, an audit log, a buffered writer — can close between the last byte of output and the exit.

Capturing typed metadata

run_with keeps output in-process and returns CompletedRun: the dispatch outcome, any framework warnings collected during the run, and the lines a handler's events put on the sink as it emitted them (entries(), each with its newline), which a process would have written before the result — the rendered lines under the human template, the JSON records under ndjson, and nothing under an encoding that writes one document at the end. App::dispatch captures the same way; run_command takes the StreamSink as a parameter. Deref keeps string-oriented accessors and typed methods (exit_status(), success_kind(), error_kind()) working on the wrapper. Pattern matching needs outcome() or into_outcome(), because CompletedRun is not the variant enum.

#![allow(unused)]
fn main() {
use standout::cli::{
    CompletedRun, DispatchResult, ExitStatus, OutputKind, RunError, RunErrorKind, SuccessKind,
};
use standout::{InputSources, TargetProperties};

let result = app.run_with(
    command,
    args,
    TargetProperties::detect(),
    InputSources::from_process(),
);
let _ = result.warnings();

match result.outcome() {
    DispatchResult::Handled(output) => println!("{}", output),
    DispatchResult::Binary(bytes, filename) => consume(bytes, filename),
    DispatchResult::Artifact(run) => {
        use std::io::{self, Write};
        if run.destination().is_stdout() {
            let mut stdout = io::stdout();
            stdout.write_all(run.bytes()).and_then(|()| stdout.flush()).map_err(|error| {
                RunError::new(
                    format!("Error writing artifact stdout: {}", error),
                    RunErrorKind::FinalWrite(OutputKind::Artifact),
                )
            })?;
            if let Some(report) = run.report().filter(|r| !r.is_empty()) {
                let mut stderr = io::stderr();
                writeln!(stderr, "{}", report).and_then(|()| stderr.flush()).map_err(|error| {
                    RunError::new(
                        format!("Error writing artifact report: {}", error),
                        RunErrorKind::FinalWrite(OutputKind::Artifact),
                    )
                })?;
            }
        } else if let Some(report) = run.report().filter(|r| !r.is_empty()) {
            let mut stdout = io::stdout();
            writeln!(stdout, "{}", report).and_then(|()| stdout.flush()).map_err(|error| {
                RunError::new(
                    format!("Error writing artifact report: {}", error),
                    RunErrorKind::FinalWrite(OutputKind::Artifact),
                )
            })?;
        }
    }
    DispatchResult::Error(error) => eprintln!("{}", error),
    DispatchResult::NoMatch(_matches) => {}
    DispatchResult::Silent => {}
    _ => {}
}

assert_eq!(result.exit_status(), Some(ExitStatus::SUCCESS));
assert_eq!(result.success_kind(), Some(SuccessKind::Command));
assert_eq!(result.error_kind(), None);
}

Use into_outcome() when the fallback needs owned ArgMatches (DispatchResult::NoMatch(matches)).

RunOutput and RunError dereference to str, implement Display, and expose as_str() / into_string() for callers that used the tuple payloads as text. RunOutput::kind() names a success the same way: Command for a handler's output, and ClapHelp / ClapVersion for a help or version display. Whether those bytes went to stdout, a file or a pager is a separate question, answered by CompletedRun::delivery(). RunError::kind() identifies ClapUsage, Handler, Hook(phase), Render, or FinalWrite(Text|Binary|Artifact). External identifies the narrow application-declared external path; its exit_status() is the exact declared nonzero status and its text is the verbatim diagnostic payload.

No-match is a handoff, not an error

DispatchResult::NoMatch retains the parsed ArgMatches for partial adoption. It has no framework exit status: exit_status() returns None, run() returns false, run_emitted() reports handled: false, and Standout emits nothing. The fallback dispatcher still owns that command and its eventual status.

The reverse direction never hands off. Before parsing, run and run_with check every registered path against the clap Command: a handler registered under a path the CLI declares no subcommand for is unreachable — no invocation can name it and no fallback owns it, since the app did register a handler. That returns DispatchResult::Error naming the registered path, and the clap spelling too when the two differ only by - versus _ (list_units registered against a CLI declaring list-units). App::verify_command reports the same mismatch at setup time.

That check reads canonical command names only. Clap resolves an alias to the command it names before ArgMatches reports it, so dispatch never sees the alias: a handler registered as ls against Command::new("list").alias("ls") is reached by neither spelling and is reported as unreachable. Registering list is what makes both list and ls run the handler.

Framework-owned final writes

run() writes successful text and binary bytes to stdout, diagnostics to stderr in a human mode and to stdout as a document in a structured one, and exits with the typed non-zero status when execution fails. Complete human output on a terminal may go to a pager instead of stdout, on the rule Paging states; a pager that cannot start leaves the bytes for stdout, so nothing is lost. A closed downstream pipe is not an error only for final rendered command text: BrokenPipe there means the consumer stopped reading early. Binary stdout writes and artifact report writes keep their typed final-write failures. The suggested filename on binary output remains available to capture callers; use --output-file-path when the framework should write either text or binary to a file instead of stdout.

Capture APIs do not perform the final stdout/stderr write, but file redirection is part of dispatch and therefore reports typed FinalWrite failures directly. External failures are never redirected to an output file: they remain stderr diagnostics when run() performs the final write.

Capture APIs return the run's warnings instead of rendering them (CompletedRun::warnings(), TestResult::warnings()).

Compound artifacts

Output::Artifact extends the framework-owned write to commands that also have something to say about it: Standout selects the destination, writes the bytes, and emits the report; a failed write produces FinalWrite(Artifact) and no report at all. The destination policy, the report envelope and the report channel are in Handler Contract.