feat(output): support nested table output in human rendering - #72
feat(output): support nested table output in human rendering#72jpage-godaddy wants to merge 5 commits into
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();
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.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).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.🤖 Generated with Claude Code