Skip to content

feat(output): support nested table output in human rendering - #72

Merged
jpage-godaddy merged 8 commits into
mainfrom
cli-engine-nested-tables
Aug 3, 2026
Merged

feat(output): support nested table output in human rendering#72
jpage-godaddy merged 8 commits into
mainfrom
cli-engine-nested-tables

Conversation

@jpage-godaddy

@jpage-godaddy jpage-godaddy commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • DEVEX-968: the human-output renderer supported exactly 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/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.
  • Nesting only applies inside an object's property bag; a row cell inside an array-of-objects table always stays a single flat value, since a table row is one monospace line and can't contain a rendered sub-block. A nested child's own columns may set .nested(...) again for a grandchild table/property bag.
  • Nested child tables reuse the existing width-fitting/hide-before-truncate algorithm, narrowed by two spaces of indent per level; hidden/truncated notes bubble up into the parent's footer, prefixed "{ParentHeader} > {ChildHeader}".
  • TableColumn::field now 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, a Summary<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. TableColumn gains a new public nested field, which would normally force every literal TableColumn { ... } construction site to add nested: None. Audited both real consumers (gdx: 140+ command/group sites, gddy: 67+) and found zero literal construction of TableColumn anywhere — both exclusively use TableColumn::new(...).no_truncate(...). The only literal-construction sites that existed were in this crate's own tests/foundation.rs (16 of them, all using the same defaults TableColumn::new(...) already produces), which is why the field addition originally needed to touch them. Rewrote those 16 sites to TableColumn::new(...) and marked TableColumn #[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 to TableColumn won't need a breaking release either.

Example

A gddy consumer command (api describe, in godaddy/cli) opts its parameters column into nested rendering:

CommandSpec::new("describe", "Show schema and parameters for an endpoint")
    .with_output_schema::<ApiOperation>()
    .with_view(vec![
        TableColumn::new("domain", "Domain"),
        TableColumn::new("operationId", "Operation ID"),
        TableColumn::new("method", "Method"),
        TableColumn::new("path", "Path"),
        TableColumn::new("summary", "Summary"),
        TableColumn::new("parameters", "Parameters").nested(vec![
            TableColumn::new("name", "Name"),
            TableColumn::new("in", "In"),
            TableColumn::new("required", "Required"),
            TableColumn::new("description", "Description"),
        ]),
    ])
    // ...

$ gddy api describe getBusinesses --output human:

Domain: businesses
Operation ID: getBusinesses
Method: GET
Path: /businesses
Summary: Get all businesses
Parameters:
  NAME               IN      REQUIRED
  -----------------  ------  --------
  X-Request-Id       header  no      
  page               query   no      
  pageSize           query   no      
  totalRequired      query   no      
  includeFields      query   no      
  If-Modified-Since  header  no      

  (6 rows)

1 column hidden to fit the display width (Parameters > Description) — use --json for full output.

Next steps:
  gddy api call /businesses --method GET
      Make an authenticated call to this endpoint

parameters is 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: the Description column doesn't fit at 80 columns, so it's hidden and reported in the footer as Parameters > Description, exactly the way a top-level view reports a hidden column. Note the footer suggests only --json, not --fields--fields selects among this view's top-level columns (domain, operationId, ..., parameters) and could drop the whole Parameters block, but can't reach inside it to narrow which of its columns show. --output json for the same command is untouched — parameters is still the full, unprojected array.

Test plan

  • New unit tests in src/output/human.rs covering: 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, merged RenderNotes from 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.
  • Updated tests/exhaustive_output.rs's dotted-path test to reflect the (intentional) behavior change — "owner.name" now resolves instead of silently rendering blank.
  • New end-to-end test in tests/consumer_cli.rs proving the full CommandSpec::with_viewHumanViewRegistryrender_human_with_registry_selected wiring 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 --doc all pass.
  • Manually verified end-to-end against a real consumer CLI (gddy api describe getBusinesses --output human): the Parameters field renders as an indented child table, and the width-narrowing footer correctly reports Parameters > Description when a nested column gets hidden; --output json is unaffected (full nested structure, unchanged). See the Example section above.
  • Reviewed by copilot-pull-request-reviewer — flagged a real shape-drift bug (an opted-in nested column changed output shape even for non-nestable values) and a docs inaccuracy; both fixed, re-reviewed clean.
  • Audited gdx/gddy/this crate's own tests+examples for literal TableColumn construction, rewrote the 16 internal sites found, and marked TableColumn #[non_exhaustive] — re-ran the full verification suite clean afterward.
  • A second look at the Example output above caught the footer wrongly suggesting --fields as a fix for a nested hidden column, which --fields can't actually do (it only selects top-level columns). Added a RenderNotes::nested_narrowing flag so the footer suggests only --json when the narrowing happened inside a nested block, with a new regression test locking in the wording for both cases.
  • Simplified that message's wording per review feedback — dropped the explanatory parenthetical, since "use --json for full output" says enough on its own.
  • render_object_with_columns now matches render_array_with_columns's existing fallback for an empty column list (e.g. --fields filtering out every declared column) — reports "(no data)" instead of silently rendering an empty string. Caught by qcai-godaddy's review; added render_object_with_columns_handles_no_columns_gracefully mirroring the existing array-path test.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TableColumn with nested(...) 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.

Comment thread src/output/human.rs
Comment thread docs/concepts.md
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

…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>
@jpage-godaddy jpage-godaddy changed the title feat(output)!: support nested table output in human rendering feat(output): support nested table output in human rendering Aug 1, 2026
@jpage-godaddy
jpage-godaddy requested a review from Copilot August 1, 2026 02:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TableColumn rustdoc mentions chaining with_* methods, but TableColumn currently exposes builder-style methods named no_truncate and nested (no with_* methods). This can mislead users looking for a with_* 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_columns returns an empty body when columns is empty (e.g., when --fields filters 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 constructs TableColumn via 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
    );

Comment thread src/output/human.rs
… 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@jpage-godaddy
jpage-godaddy merged commit 8ca7479 into main Aug 3, 2026
4 checks passed
@jpage-godaddy
jpage-godaddy deleted the cli-engine-nested-tables branch August 3, 2026 19:05
@github-actions github-actions Bot mentioned this pull request Aug 3, 2026
jpage-godaddy added a commit that referenced this pull request Aug 3, 2026
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.
jpage-godaddy added a commit that referenced this pull request Aug 3, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants