Skip to content

Why the output helpers are a pair

Two functions, one shape

render_table and render_json take the same argument — a slice of rows — and return a string ending in a newline. That symmetry is the whole point: a command produces rows, and something downstream picks a renderer from a flag. The command itself never formats anything.

match format {
    OutputFormat::Text => Ok(render_table(rows)),
    OutputFormat::Json => render_json(rows),
}

The alternative is each command formatting its own output, which is how CLIs end up with three subcommands that pad columns differently and a fourth whose --json emits an object where the others emit an array. Machine-readable output is only useful if it is predictable, and predictability comes from there being one implementation.

Why they are not one function

The two signatures differ in a way that would be dishonest to hide. render_table cannot fail; render_json can. Merging them behind a format parameter would force the table path to return a Result it never uses, and every caller would handle an error that cannot happen on that branch.

They also carry different bounds — Tabled for one, Serialize for the other — and a merged function would demand both from every caller even when only one rendering is ever requested.

Why the table style is not configurable

render_table applies tabled's Style::psql() unconditionally. There is no parameter, no builder and no configuration key.

The style is a decision made once for a whole toolkit rather than a knob on each call. Every tool built on these helpers renders tables the same way, which means a user who learns to read one tool's output can read all of them, and a script that gets away with cutting on | keeps working across them.

psql specifically because it is the least decorated style that still has a visible column separator: no box-drawing characters to mangle in a terminal with the wrong font or a log file with the wrong encoding, and a header rule that makes the boundary obvious.

Adding a theming parameter would be easy and is not planned. It would turn a fixed contract into a per-call choice, and the first tool to choose differently would break the property that made the helper worth having.

If you need a different style, call tabled directly — it is a published crate and render_table is thin enough to reimplement in about ten lines.

Why JSON output is always an array

render_json serialises the slice, so the top level is a JSON array even for one row and for none. A script can therefore write one code path — iterate the array — instead of branching on whether today's result happened to be a single object.

An empty result is [], not an error and not an empty string. "No rows matched" is a valid answer to a query, and a consumer that has to distinguish "nothing found" from "the command failed" gets that from the exit code rather than from a mangled document.

Why render_json returns a stringified error

RenderError::Json carries a String rather than the original serde_json::Error. That loses the ability to match on the underlying cause, which is a real cost, and it is accepted because of who makes this error happen.

serde_json fails on very little: not on non-finite floats, which become null, and not on integer map keys, which get stringified. What does fail is a map keyed by something with no string form, or a hand-written Serialize impl that refuses a value. Both are bugs in the row type — decided at compile time by whoever wrote the struct, never by anything a user typed.

An error that only a programmer can cause does not need to be matchable at runtime. It needs to be reportable, and a string is enough for that.

The rustdoc on render_json names non-finite floats as a typical cause, which is not true of serde_json; see Known documentation defects.