Output Modes

A handler returns one serializable value and Standout decides what a run does with it: renders the command's template for a person, or encodes the value as JSON, YAML, CSV or NDJSON for a script. The same handler serves both, so an application writes no separate "API" path.

Three decisions come apart, and a flag settles each: --output names the representation, --color decides the escape sequences, and --no-pager and --output-file-path decide where the bytes land.

Representation and style are two decisions

What the run produces is its representation. Whether the rendered human text carries escape sequences is its style mode. They are separate types, and no type combines them:

#![allow(unused)]
fn main() {
pub enum Representation {
    Human,      // Render the command's template: what a bare invocation produces
    TermDebug,  // Render it, keeping style tags as [name]...[/name]
    Json,       // Serialize as JSON (skip template)
    Yaml,       // Serialize as YAML (skip template)
    Csv,        // Serialize as CSV (skip template)
    Ndjson,     // One JSON object per line: a stream, not a document
}

pub enum StyleMode {
    Ansi,   // escape sequences applied
    Plain,  // style tags removed
    Debug,  // style tags kept as literals
}
}

The human representation has no --output name: it is what a bare invocation renders. term-debug stays on the flag as the diagnostic view of the template's style tags, outside the stability contract. Ndjson is the one stream representation, below.

There is no XML representation. --output xml is a clap usage error like any other value the flag does not accept, exit 2.

The style decision

The style mode is resolved per run from the representation, the run's ColorPolicy and the destination's reported color capability:

#![allow(unused)]
fn main() {
Term::stdout().features().colors_supported()
}

Under ColorPolicy::Auto, a capable destination renders with escape sequences and an incapable one renders plain. Detection happens at render time, not startup, so piping to a file or another process turns the page plain:

myapp list              # Colors (if the terminal supports them)
myapp list > file.txt   # No colors (not a TTY)
myapp list | less       # No colors (pipe)

NO_COLOR and TERM=dumb suppress the reported capability, so they turn a bare run plain. CLICOLOR_FORCE is not part of that probe, so it does not turn a plain destination colored. A structured encoding never carries escape sequences, whatever the policy says.

The color policy

ColorPolicy is Auto, Always or Never. The CLI user names it with --color auto|always|never, which is on every command the way --output is and renames or disappears through the same seam (App Configuration). In-process entry points name it too — App::run_with_color, App::run_command, HelpConfig::color, TopicRenderConfig::color, and TestHarness::color.

The policy is resolved in the terminal-setting order: an explicit --color first, then NO_COLOR, then the [term] color key from the application's configuration (Configuration Files), then whether the destination reports color capability. A named output file never does, and a pager inherits stdout's answer.

myapp list --color never              # no escape sequences, on any terminal
myapp list --color always | tee log   # escape sequences into the pipe

The --output Flag

Standout adds a global --output flag naming a structured encoding:

myapp list                      # the human template
myapp list --output=json        # JSON serialization
myapp list --output=yaml        # YAML serialization
myapp list --output=csv         # CSV serialization
myapp list --output=ndjson      # Newline-delimited JSON stream
myapp list --output=term-debug  # Show style tags

The flag is global—it applies to all subcommands. --output accepts a single occurrence: passing it twice on one command line is a clap usage error (ArgumentConflict), so an application cannot inject its own default by appending a second --output to the arguments. To change the default output mode, set it on the builder with output_mode_fallback(mode) rather than rewriting the command line.

The global property is not special to --output. Any flag an application declares with clap's .global(true) is readable from the deepest ArgMatches, which is the one a #[flag] handler parameter reads. A root-declared global --quiet, for example, is visible as #[flag] quiet: bool in a handler for a nested command such as config set.

Literal escape bytes

No style mode touches ANSI bytes that a handler or template writes literally into the rendered text — the framework does not sanitize those bytes and does not promise to. A caller that needs them gone strips them itself.

term-debug is internal (What Is Contract): do not build automation against its output.

TermDebug Mode

TermDebug preserves style tags instead of converting them:

Template: [title]Hello[/title]
Output:   [title]Hello[/title]

Use cases:

  • Debugging template issues
  • Verifying style tag placement
  • Automated testing of template output

TermDebug keeps every tag as literal text and shows tag placement; it does not check whether a tag has a matching style definition. What the other modes do with an unknown tag is in Unknown Style Tags. Use validate_template when validation is required.

Structured Modes

Structured modes bypass the template entirely. Handler data is serialized directly:

#![allow(unused)]
fn main() {
#[derive(Serialize)]
struct ListOutput {
    items: Vec<Item>,
    total: usize,
}

fn list_handler(...) -> HandlerResult<ListOutput> {
    Ok(Output::Render(ListOutput { items, total: items.len() }))
}
}
myapp list --output=json
{
  "items": [...],
  "total": 42
}

Same handler, same types—different output format. This enables:

  • Machine-readable output for scripts
  • Integration with other tools (jq, etc.)
  • API-like behavior from CLI apps

Key ordering

JSON, YAML, CSV and NDJSON emit object keys in the order the handler declared them, not alphabetically. In the example above, a reader sees items before total because the struct lists items first. Field order in your #[derive(Serialize)] struct — or key order in a json!({ ... }) literal — is the output order, and in CSV it is the column order.

This holds because Standout builds serde_json's Value with the preserve_order feature on, so the intermediate map keeps insertion order instead of sorting. The feature is process-wide: it governs every map serde_json builds in the process, including any HashMap a handler serializes. A HashMap has no stable iteration order, so a field of that type emits its keys in an order that varies from run to run — preserve_order preserves that non-determinism rather than hiding it behind a sort. For output whose key order must be stable, give the field a struct or an order-preserving map type (for example indexmap::IndexMap); reach for a raw HashMap only when the order genuinely does not matter.

CSV Output

CSV takes flat records only. A flat record is a map whose values are scalars (strings, numbers, booleans, null); the handler data must be one flat record or an array of flat records. Each record is a row, the columns are the records' keys in first-seen order, and a key a record lacks — or maps to null — is an empty cell.

#![allow(unused)]
fn main() {
#[derive(Serialize)]
struct Row { name: String, count: usize }

// One row per element:   name,count
Ok(Output::Render(vec![Row { .. }, Row { .. }]))
}

Any nested value — an array or object inside a record, or a document that is not a record at all — is a render error, exit 1, whose message names the value and points at CsvProjection:

CSV output takes a flat record or an array of flat records, and `items` is an
array; declare the columns with a CsvProjection

Under --output csv that error is the stdout diagnostic document, kind render (Execution Outcomes). Nothing is flattened: there are no items.0.name columns and no JSON blobs in cells. A command whose canonical response nests its rows declares the columns with a CsvProjection, below. That is the same handler data used by the other structured modes; handlers should not inspect the requested mode or return a CSV-specific shape.

The standalone rendering API also supports direct FlatDataSpec rendering when a caller needs explicit columns and headers:

#![allow(unused)]
fn main() {
use standout::tabular::{Column, FlatDataSpec, Width};
use standout::{ColorPolicy, Representation};
use standout_render::render_auto_with_spec;

let spec = FlatDataSpec::builder()
    .column(Column::new(Width::Fixed(10)).key("name").header("Name"))
    .column(Column::new(Width::Fixed(10)).key("meta.role").header("Role"))
    .build();

render_auto_with_spec(
    template,
    &data,
    &theme,
    Representation::Csv,
    ColorPolicy::Auto,
    Some(&spec),
)?
}

The key field uses dot notation for nested paths ("meta.role" extracts data["meta"]["role"]).

When a command's canonical response is an object containing the CSV rows, attach a presentation-layer projection through CommandConfig:

#![allow(unused)]
fn main() {
use serde_json::json;
use standout::cli::FnHandler;
use standout::tabular::{Column, Width};
use standout::{CsvProjection, StructuredOutputProjection};

let projection = StructuredOutputProjection::csv(
    CsvProjection::builder("items")
        .column(Column::new(Width::default()).key("language").header("LANGUAGE"))
        .column(Column::new(Width::default()).key("code").header("CODE"))
        .derived_column(
            Column::new(Width::default()).header("NET"),
            |row, _root| json!(
                row["code"].as_i64().unwrap_or(0)
                    - row["comments"].as_i64().unwrap_or(0)
            ),
        )
        .synthetic_row(|root| json!({
            "language": "TOTAL",
            "code": root["totals"]["code"],
            "comments": root["totals"]["comments"]
        }))
        .conditional_row(|root| {
            (root["skipped"].as_u64().unwrap_or(0) > 0)
                .then(|| json!({ "language": "SKIPPED" }))
        })
        .build(),
);

App::builder().command_with("summary", FnHandler::new(summary_handler), |config| {
    config.structured_output_projection(projection)
})?;
}

Direct-column dot paths are resolved against each selected row. Derived columns receive both the current row and the root response. Synthetic-row callbacks receive the root response and run in registration order. Column ordering, headers, and null_repr use the existing FlatDataSpec behavior.

The row source is a dot path into the response, or . for the response itself. An array there is one row per element; a single record is one row, which is how the framework's own diagnostic document becomes a CSV row: a CsvProjection over . whose optional range is three columns, range_filename, range_line and range_column, with null_repr("") so they are empty when no range is set.

The projection applies only to CSV. The human representation still uses the template, while JSON and YAML serialize the canonical response. In the pipeline, post-dispatch hooks run before projection and post-output hooks run after it; run, run_with, output-file handling, and final emission therefore all observe the same projected CSV.

See Introduction to Tabular for tabular specifications and layout.

Incremental Commands

A command whose result accrues while it runs emits typed events and then a summary, and every representation on this page carries both: Incremental Commands is the whole mechanism — the Results channel, the .event template, the record array each encoding builds, and what a failure part-way through leaves behind.

NDJSON Mode

--output ndjson makes stdout a stream: every line is one JSON object, and the run may write several. Nothing else changes — the same handler, the same serializable data, the same exit statuses.

Output::Render(data) becomes one result entry where the other structured encodings write their document:

{"type":"result","data":{"items":[...],"total":42}}

A failure is one diagnostic entry at the point in the stream where the run failed, after whatever the handler already emitted, and a warning is a severity: warning diagnostic entry of kind framework after the result or the failure; the document itself and what each stream carries are in Execution Outcomes. Output::Silent writes nothing, so a handler whose events are its whole result leaves only those.

File Output

The --output-file-path flag redirects output to a file:

myapp list --output-file-path=results.txt
myapp list --output=json --output-file-path=data.json

Behavior:

  • Text output: written to file, nothing printed to stdout
  • Binary output: written to the requested file instead of stdout
  • Silent output: no-op
  • An incremental command: the file is the whole run and stdout carries nothing (Incremental Commands)

After writing to file, stdout output is suppressed to prevent double-printing.

Paging

An external pager reads complete human output when the user is at a terminal, so a long listing scrolls instead of running off the top of the screen. The application author says which commands may page; the CLI user declines with --no-pager, which is on every command the way --output is:

myapp log             # human text through the pager on a terminal
myapp log --no-pager  # the same text straight to stdout

A command is eligible when it declares pageable, and every help page and topic page is eligible without declaring anything.

Standout runs a pager only when all of these hold, and writes to stdout otherwise:

  • the command is eligible and the user did not pass --no-pager;
  • the representation is the human template: term-debug and the structured encodings never page, and neither does incremental human output, which arrives while the command runs rather than complete;
  • stdout is a terminal, and the user named no --output-file-path, which wins over paging;
  • an environment variable names a pager.

The pager command comes from the environment alone, <APP>_PAGER then PAGER, so no configuration file can name a program Standout will execute and there is no [term] pager key. <APP> is the name the application gave AppBuilder::name, upper-cased with every character outside A-Z0-9 written as _, so my-app reads MY_APP_PAGER; an application that never named itself reads PAGER only. Neither variable set, or the winning value empty, means nothing pages.

The value is a shell word list rather than a program name, run through sh -c, so less -FRX and sed -n 1p both work as they do for git. Windows has no such shell and never pages. Standout sets LESS=FRX and LV=-c in the pager's environment when the user has not, so colored text pages readably through a bare less.

A pager that cannot start delivers the bytes to stdout unpaged and leaves the run's exit status alone; a pager the user quits early has spent the bytes, and the run still reports the command's own status.

Customizing Flags

Rename or disable the flags via AppBuilder:

#![allow(unused)]
fn main() {
App::builder()
    .output_flag(Some("format"))       // --format instead of --output
    .output_file_flag(Some("out"))     // --out instead of --output-file-path
    .color_flag(Some("colour"))        // --colour instead of --color
    .pager_flag(Some("plain"))         // --plain instead of --no-pager
    .build()?
}
#![allow(unused)]
fn main() {
App::builder()
    .no_output_flag()                  // Disable --output entirely
    .no_output_file_flag()             // Disable file output
    .no_color_flag()                   // Disable --color
    .no_pager_flag()                   // Disable --no-pager
    .build()?
}

Keep the Representation Out of Handlers

The representation is a rendering concern and is deliberately absent from CommandContext. A handler should return the same serializable data whether the caller took the human page or a structured encoding. If a command's behavior genuinely differs, model that as an explicit command or argument rather than an implicit presentation branch.

A handler has nothing to branch on: the results channel exposes emit alone, and an incremental command emits the same events under every representation.

Rendering Without CLI

For standalone rendering with an explicit representation:

#![allow(unused)]
fn main() {
use standout::{render_auto, ColorPolicy, Representation};

// Renders the template for Human, serializes for Json/Yaml
let output = render_auto(template, &data, &theme, Representation::Json, ColorPolicy::Auto)?;
}

The "auto" in render_auto refers to template-vs-serialize dispatch, not color detection.

For control over the representation, the color policy and the color mode:

#![allow(unused)]
fn main() {
use standout::{render_with_mode, ColorMode, ColorPolicy, Representation};

let output = render_with_mode(
    template,
    &data,
    &theme,
    Representation::Human,
    ColorPolicy::Always,
    ColorMode::Dark,
)?;
}