Skip to content

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

Open
jpage-godaddy wants to merge 5 commits into
mainfrom
cli-engine-nested-tables
Open

feat(output): support nested table output in human rendering#72
jpage-godaddy wants to merge 5 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.

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).
  • 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.

🤖 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();

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.

2 participants