feat(output): support nested table output in human rendering - #72
Conversation
Human output previously supported one flat table (arrays) or one flat property bag (objects) per command; nested objects/arrays inside either shape fell through `format_value` and rendered as raw JSON text in a single cell. This matters for entities that are naturally "an object with some list-shaped properties" — e.g. an API operation with a list of parameters and a list of responses (DEVEX-968). `TableColumn::nested(columns)` opts a column into rendering its value as an indented child table (list of objects) or child property bag (single object) instead of the raw-JSON fallback. It's a strict opt-in: a column with no `.nested(...)` renders exactly as before. Nesting only applies inside an object's property bag, never inside an array row, since a table row is one monospace line and can't contain a rendered sub-block. `TableColumn::field` also now supports simple dotted paths (e.g. "parameters.items"), generalized to every column, so a column can reach through a wrapper shape (a pagination envelope, a `Summary<T>`, etc.) that cli-engine itself has no opinion about. BREAKING CHANGE: `TableColumn` gains a new public `nested` field. Code constructing it via struct literal instead of `TableColumn::new(...)` needs to add `nested: None`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds opt-in nested rendering for human output so commands can present nested objects/arrays as indented child tables/property bags, and supports dotted field paths to reach through wrapper shapes (e.g. parameters.items) in TableColumn::field.
Changes:
- Extend
TableColumnwithnested(...)and dotted-path field resolution for human rendering. - Update the human renderer to render nested child blocks with width narrowing and merged render notes.
- Add/adjust unit + integration tests and update docs to describe the new behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/output/human.rs |
Implements dotted-path resolution and nested child table/property-bag rendering in the human renderer. |
tests/foundation.rs |
Updates TableColumn struct literals for the new nested: None field (breaking API change). |
tests/exhaustive_output.rs |
Updates human output expectations to reflect dotted-path resolution behavior. |
tests/consumer_cli.rs |
Adds an end-to-end test proving nested columns flow through the CLI registry and render as indented child tables. |
docs/concepts.md |
Documents dotted paths and TableColumn::nested(...) in the human output section. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…alues Address Copilot review feedback on PR #72: an opted-in nested column changed the outer `header: value` line to a multi-line `header:\n value` block for *any* present value, including scalars and mixed (non-uniform) arrays — contradicting the "nested is a no-op unless the value is actually list-of-objects or object shaped" contract. Gate nested rendering on the value's shape (`is_nestable`, the same predicate `render_nested_value` already used internally) so an opted-in column renders identically to an unopted-in one whenever the runtime value doesn't qualify. Also drops a docs/concepts.md bullet's incorrect claim that the mixed- array fallback path (`render_array_lines`) is affected by `nested` — that path never consults view columns at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ests Neither real consumer (gdx: 140+ command/group sites, gddy: 67+) ever constructs TableColumn via struct literal — both exclusively use TableColumn::new(...).no_truncate(...). The only literal construction anywhere was in this crate's own tests/foundation.rs (16 sites, all using the same default no_truncate/nested values TableColumn::new already produces), which is why the preceding nested-field commit needed to touch them at all. Rewriting those 16 sites to TableColumn::new(...) and marking the struct non_exhaustive closes the loop within this same PR: no known consumer, and no code in this crate, is affected by either the new nested field or the non_exhaustive marker, so this PR carries no real breaking change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/output/human.rs:28
- The
TableColumnrustdoc mentions chainingwith_*methods, butTableColumncurrently exposes builder-style methods namedno_truncateandnested(nowith_*methods). This can mislead users looking for awith_*API.
/// Construct with [`TableColumn::new`], then chain `with_*`/builder methods —
/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine
/// can add fields (as it did for `nested`) without a breaking release.
Its rustdoc said "chain with_*/builder methods", but the actual builder methods are named no_truncate/nested, not with_*. Flagged by Copilot's review as a suppressed comment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/output/human.rs:29
- The rustdoc here implies
#[non_exhaustive]avoids a breaking release, but adding#[non_exhaustive]to a public struct is itself a breaking API change for any downstream crate that previously used struct literals or exhaustive matches. Consider rewording to clarify it's a one-time break that enables non-breaking field additions going forward.
/// Construct with [`TableColumn::new`], then chain builder methods like
/// [`no_truncate`](TableColumn::no_truncate)/[`nested`](TableColumn::nested)
/// — never as a struct literal. `#[non_exhaustive]` enforces this so the
/// engine can add fields (as it did for `nested`) without a breaking release.
docs/concepts.md:583
- This mentions dotted-path support, but doesn’t note the new limitation that literal JSON keys containing
.can no longer be addressed (they’re interpreted as path separators). Adding a short note here would prevent surprises for consumers with such field names.
- `TableColumn::field` supports a dotted path (`"parameters.items"`) to reach a value nested under intermediate objects — useful when a response wraps a list in a pagination/summary envelope.
…h caveat - Clarify that #[non_exhaustive] on TableColumn carries no real breaking impact today specifically because no known consumer constructs it via struct literal (not a general claim that non_exhaustive is never breaking). - Note in docs/concepts.md that a literal field name containing a "." is not addressable via the new dotted-path support, matching what TableColumn::field's own rustdoc already says. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/output/human.rs:756
render_object_with_columnsreturns an empty body whencolumnsis empty (e.g., when--fieldsfilters out every declared view column). This leads to blank human output for object-shaped responses, unlike the array/table path which falls back to "(no results)" when there are no columns to render.
) -> (String, RenderNotes) {
if map.is_empty() {
return ("(no data)\n".to_owned(), RenderNotes::default());
}
let mut out = String::new();
let mut notes = RenderNotes::default();
The hidden-columns/truncated footer unconditionally suggested --fields as a fix, but --fields only ever selects among a view's top-level declared columns — it can drop a TableColumn::nested column entirely, but can't narrow what's shown inside one. When the reported narrowing happened inside a nested child (e.g. `Parameters > Description` in the DEVEX-968 gddy example), the old message wrongly implied `--fields` would help. RenderNotes gains a `nested_narrowing` flag, set whenever a nested child's own truncated/hidden_columns (or its own nested_narrowing, for grandchild nesting) get merged into the parent. The footer now names --fields only when the narrowing is at the view's own top-level columns; otherwise it points at --json and explains why --fields doesn't apply. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Drop the explanatory parenthetical from the footer text — "use --json for full output" says enough; the internal RenderNotes.nested_narrowing doc comment already carries the fuller rationale for anyone reading the code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/output/human.rs:31
- The doc comment suggests
#[non_exhaustive]has “no real breaking impact today”, but#[non_exhaustive]is a breaking change for any external consumer that constructsTableColumnvia a struct literal or exhaustively matches it. It’s fine to make this tradeoff, but the comment should describe the actual compatibility impact accurately (and avoid implying it’s non-breaking in general).
/// Construct with [`TableColumn::new`], then chain builder methods like
/// [`no_truncate`](TableColumn::no_truncate)/[`nested`](TableColumn::nested)
/// — never as a struct literal. No known consumer constructs `TableColumn`
/// via struct literal, so marking it `#[non_exhaustive]` carries no real
/// breaking impact today; going forward it means the engine can add fields
/// (as it did for `nested`) without that becoming a breaking release either.
tests/consumer_cli.rs:696
- This assertion checks the entire rendered output for
{, which makes the test brittle (any future unrelated output, e.g. guides/next-steps, could legitimately include braces). Since the intent is to ensure the nested Parameters block doesn’t fall back to raw JSON, scope the check to the substring after the "Parameters:" header.
assert!(
!human.rendered.contains('{'),
"no raw JSON should leak into human output: {}",
human.rendered
);
… for objects render_object_with_columns only guarded against an empty map, so a view's --fields filtering out every declared column on an object-shaped response rendered a silent empty string instead of a message — unlike the array path, which already falls back to "(no results)" for exactly this case. Mirror that: empty columns now reports "(no data)", the same message an empty map already gets. Addresses PR review feedback from qcai-godaddy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The move to cli-engine/ (#77) changed release-please's per-path commit filtering — file diffs for commits made before the move (#72, #73) point at the old src/ paths, not cli-engine/, so release-please's regenerated changelog only picked up commits made after the move. Nothing was lost from git history or the shipped code; this just restores the visible changelog entry to the full set of changes since 0.6.0.
🤖 I have created a release *beep* *boop* --- <details><summary>cli-engine: 0.6.1</summary> ## [0.6.1](cli-engine-v0.6.0...cli-engine-v0.6.1) (2026-08-03) ### Features * **command:** add context-aware typed constructors and ArgGroup support ([#73](#73)) ([748efc6](748efc6)) * **output:** support nested table output in human rendering ([#72](#72)) ([8ca7479](8ca7479)) ### Bug Fixes * **workspace:** drop redundant "." self-reference from workspace members ([#75](#75)) ([b769528](b769528)) * **workspace:** restructure into a true sibling Cargo workspace ([#77](#77)) ([05b8b29](05b8b29)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jacob Page <jpage@godaddy.com>
Summary
format_valueand rendered as raw JSON text in a single cell/line instead of their own table.TableColumn::nested(columns)opts a column into rendering its value as an indented child table (list of objects) or child property bag (single object). Strict opt-in — a column with no.nested(...)renders exactly as before, and (fixed during review, see below) so does an opted-in column whose runtime value turns out to be a scalar or a non-uniform array..nested(...)again for a grandchild table/property bag."{ParentHeader} > {ChildHeader}".TableColumn::fieldnow supports simple dotted paths (e.g."parameters.items"), generalized to every column (not just nested ones), so a column can reach through a wrapper shape (a pagination envelope, aSummary<T>, etc.) that cli-engine itself has no opinion about — this is what makes the driving use case (a paginated parameters/responses list) actually renderable.Not a breaking change.
TableColumngains a new publicnestedfield, which would normally force every literalTableColumn { ... }construction site to addnested: None. Audited both real consumers (gdx: 140+ command/group sites,gddy: 67+) and found zero literal construction ofTableColumnanywhere — both exclusively useTableColumn::new(...).no_truncate(...). The only literal-construction sites that existed were in this crate's owntests/foundation.rs(16 of them, all using the same defaultsTableColumn::new(...)already produces), which is why the field addition originally needed to touch them. Rewrote those 16 sites toTableColumn::new(...)and markedTableColumn#[non_exhaustive]in the same PR, so no known consumer and no code in this crate is affected by either change — and future field additions toTableColumnwon't need a breaking release either.Example
A
gddyconsumer command (api describe, ingodaddy/cli) opts itsparameterscolumn into nested rendering:$ gddy api describe getBusinesses --output human:parametersis a flat JSON array of OpenAPI parameter objects — previously that field rendered as one line of raw JSON. It's now an indented child table, at the same terminal width as the top-level view: theDescriptioncolumn doesn't fit at 80 columns, so it's hidden and reported in the footer asParameters > Description, exactly the way a top-level view reports a hidden column. Note the footer suggests only--json, not--fields—--fieldsselects among this view's top-level columns (domain,operationId, ...,parameters) and could drop the wholeParametersblock, but can't reach inside it to narrow which of its columns show.--output jsonfor the same command is untouched —parametersis still the full, unprojected array.Test plan
src/output/human.rscovering: dotted-path resolution (happy path, missing intermediate key, non-object intermediate, empty/malformed path), nested array-of-objects rendering, nested object rendering, empty nested array, mergedRenderNotesfrom a narrowed child table, a backward-compat guard that an un-opted-in nested value still renders as raw JSON, and (added during review) a guard that an opted-in column with a wrong-shaped runtime value renders identically to an unopted-in one.tests/exhaustive_output.rs's dotted-path test to reflect the (intentional) behavior change —"owner.name"now resolves instead of silently rendering blank.tests/consumer_cli.rsproving the fullCommandSpec::with_view→HumanViewRegistry→render_human_with_registry_selectedwiring carries a nested column through to rendered output.cargo fmt --all --check,cargo clippy --all-targets -- -D warnings,RUSTDOCFLAGS='-D warnings' cargo doc --no-deps,cargo rustdoc --lib -- -W missing-docs(zero missing-docs),cargo test --all-targets,cargo test --docall pass.gddy api describe getBusinesses --output human): theParametersfield renders as an indented child table, and the width-narrowing footer correctly reportsParameters > Descriptionwhen a nested column gets hidden;--output jsonis unaffected (full nested structure, unchanged). See the Example section above.copilot-pull-request-reviewer— flagged a real shape-drift bug (an opted-innestedcolumn changed output shape even for non-nestable values) and a docs inaccuracy; both fixed, re-reviewed clean.gdx/gddy/this crate's owntests+examplesfor literalTableColumnconstruction, rewrote the 16 internal sites found, and markedTableColumn#[non_exhaustive]— re-ran the full verification suite clean afterward.--fieldsas a fix for a nested hidden column, which--fieldscan't actually do (it only selects top-level columns). Added aRenderNotes::nested_narrowingflag so the footer suggests only--jsonwhen the narrowing happened inside a nested block, with a new regression test locking in the wording for both cases.render_object_with_columnsnow matchesrender_array_with_columns's existing fallback for an empty column list (e.g.--fieldsfiltering out every declared column) — reports "(no data)" instead of silently rendering an empty string. Caught by qcai-godaddy's review; addedrender_object_with_columns_handles_no_columns_gracefullymirroring the existing array-path test.🤖 Generated with Claude Code