From f40db8907024603ef742a12e8caf10c0c0b7b2e3 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 31 Jul 2026 17:54:08 -0700 Subject: [PATCH 1/8] feat(output)!: support nested table output in human rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`, 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 --- docs/concepts.md | 31 +++- src/output/human.rs | 293 ++++++++++++++++++++++++++++++++++--- tests/consumer_cli.rs | 66 +++++++++ tests/exhaustive_output.rs | 8 +- tests/foundation.rs | 16 ++ 5 files changed, 391 insertions(+), 23 deletions(-) diff --git a/docs/concepts.md b/docs/concepts.md index 4c6e252..99af198 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -571,7 +571,7 @@ Human output is designed for readable terminal display: hiding all work identically either way. - Objects render as `key: value` lines. - Mixed object/scalar arrays fall back to line-per-item rendering. -- Objects in fallback lines render as compact JSON. +- Objects in fallback lines render as compact JSON, unless a column opts into `nested` rendering — see below. - JSON numbers use `serde_json` number text. - Table columns size to the live terminal width (falling back to a fixed 80 columns when stdout isn't a TTY, e.g. when piped) rather than a fixed @@ -580,6 +580,8 @@ Human output is designed for readable terminal display: - `TableColumn::no_truncate` opts a column out of shrinking entirely (still bounded by a large pathological-value safety cap) — use it for values that are useless when cut short, such as URLs. +- `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. +- `TableColumn::nested(columns)` opts a column into rendering its value as an indented child table (when the value is a list of objects) or an indented child property bag (when it's a single object), instead of the raw-JSON fallback every other column gets. It's a strict opt-in: a column with no `.nested(...)` renders exactly as before even if its runtime value happens to be list/object shaped. Nesting only applies inside an object's property bag — a row cell inside an array-of-objects table always renders as a single flat value, since a table row is one monospace line and can't itself contain a rendered sub-block. A nested child's own columns may set `.nested(...)` again for a grandchild table or property bag; the width budget and hide-before-truncate behavior below apply to every nesting level, narrowed by two spaces of indent per level. - When the terminal is too narrow for every column, hiding a column is preferred over truncating a cell: the lowest-priority (trailing) columns — see "Column order is priority" below — are hidden one at a time until the @@ -623,6 +625,33 @@ let shared = HumanViewDef::new( let spec = CommandSpec::new("get", "Get a project").with_view_id("projects-table"); ``` +A column can nest a child table under an object field. Given a response shaped like `{ "name": "getPets", "parameters": { "items": [...], "total": 2 } }`: + +```rust +use cli_engine::{CommandSpec, TableColumn}; + +let spec = CommandSpec::new("get", "Get an operation").with_view(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("parameters.items", "Parameters").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("in", "In"), + ]), +]); +``` + +which renders as: + +``` +Name: getPets +Parameters: + NAME IN + ----- ----- + limit query + id path + + (2 rows) +``` + ### Column order is priority Column order is a priority order, most important first — put the column a reader most needs (usually an id or name) first. This drives two things: display order, and which columns survive when the terminal is too narrow to show all of them (lowest-priority, trailing columns are hidden first). diff --git a/src/output/human.rs b/src/output/human.rs index cacb0c4..edb5d45 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -24,7 +24,12 @@ use super::{Envelope, NextAction, NextActionParam}; /// given at all. #[derive(Clone, Debug, Eq, PartialEq)] pub struct TableColumn { - /// JSON field path. + /// JSON field path. Supports simple dotted paths to reach a value nested + /// under intermediate objects, so a column can point through a wrapper + /// shape (a pagination envelope, a `Summary`, etc.). A literal field + /// name containing a `.` is not supported — this mirrors the dotted-path + /// convention `crate::output::fields` already uses for `--fields` + /// projection. pub field: String, /// Display header. pub header: String, @@ -33,6 +38,12 @@ pub struct TableColumn { /// values). Use this for values that are useless when cut short, such as /// URLs. pub no_truncate: bool, + /// When set, and the resolved value is list-of-objects or object shaped, + /// this column renders as an indented child table or child property bag + /// instead of a one-line dump — see [`TableColumn::nested`]. `None` (the + /// default from [`TableColumn::new`]) is a complete no-op: rendering is + /// identical to a column with no opinion about nesting. + pub nested: Option>, } impl TableColumn { @@ -43,6 +54,7 @@ impl TableColumn { field: field.into(), header: header.into(), no_truncate: false, + nested: None, } } @@ -53,6 +65,24 @@ impl TableColumn { self.no_truncate = value; self } + + /// Opts this column into rendering a nested list/object value as an + /// indented child table or property bag, using `columns` as that child's + /// own column definitions (which may themselves set `.nested(...)`). + /// + /// Nesting is only consulted when this column is rendered inside an + /// object property bag (top-level, or itself a nested property bag) — a + /// row cell inside an array-of-objects table always renders as a single + /// flat value, ignoring `nested`, because a table row is one monospace + /// line and can't itself contain a rendered sub-block without breaking + /// column alignment. Recursion is otherwise unbounded through the object + /// chain: a nested column's own child columns may set `.nested(...)` + /// again for a grandchild table or property bag. + #[must_use] + pub fn nested(mut self, columns: impl Into>) -> Self { + self.nested = Some(columns.into()); + self + } } /// Human view definition keyed by schema id. @@ -359,10 +389,7 @@ fn render_data_body( if let Some(columns) = columns { return match data { Value::Array(items) => render_array_with_columns(items, columns, available_width), - Value::Object(map) => ( - render_object_with_columns(map, columns), - RenderNotes::default(), - ), + Value::Object(map) => render_object_with_columns(map, columns, available_width), Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { (format!("{}\n", format_value(data)), RenderNotes::default()) } @@ -371,14 +398,8 @@ fn render_data_body( match data { Value::Array(items) => render_array(items, fields, available_width), Value::Object(map) => { - if map.is_empty() { - return ("(no data)\n".to_owned(), RenderNotes::default()); - } let columns = dynamic_columns(fields, || map.keys().cloned().collect()); - ( - render_object_with_columns(map, &columns), - RenderNotes::default(), - ) + render_object_with_columns(map, &columns, available_width) } other => ( format!("{}\n", format_plain_value(other)), @@ -492,6 +513,12 @@ const NO_TRUNCATE_MAX_WIDTH: usize = 4096; /// is left for column content) has to agree with what gets printed. const COLUMN_GUTTER: usize = 2; +/// Indent applied to a nested table/property-bag block under a parent +/// object's field. Matches the two-space depth-step the TOON encoder already +/// uses (`crate::output::toon`'s `push_line`), for a consistent look across +/// human and TOON nested rendering. +const NESTED_INDENT: &str = " "; + /// Detects how wide to render human-output tables and guides. /// /// An interactive terminal gets its live width (via `termimad`); anything @@ -640,7 +667,7 @@ fn render_array_with_columns( .map(|(index, column)| { let value = item .as_object() - .and_then(|map| map.get(&column.field)) + .and_then(|map| resolve_field_path(map, &column.field)) .map_or_else(String::new, format_value); let cap = if column.no_truncate { NO_TRUNCATE_MAX_WIDTH @@ -712,18 +739,36 @@ fn render_array_with_columns( fn render_object_with_columns( map: &serde_json::Map, columns: &[TableColumn], -) -> String { + available_width: usize, +) -> (String, RenderNotes) { if map.is_empty() { - return "(no data)\n".to_owned(); + return ("(no data)\n".to_owned(), RenderNotes::default()); } let mut out = String::new(); + let mut notes = RenderNotes::default(); for column in columns { - let value = map - .get(&column.field) - .map_or_else(String::new, format_value); - out.push_str(&format!("{}: {value}\n", column.header)); + let value = resolve_field_path(map, &column.field); + match (&column.nested, value) { + (Some(nested_columns), Some(value)) => { + out.push_str(&format!("{}:\n", column.header)); + let child_width = available_width.saturating_sub(NESTED_INDENT.len()); + let (block, child_notes) = render_nested_value(value, nested_columns, child_width); + out.push_str(&indent_block(&block, NESTED_INDENT)); + notes.truncated |= child_notes.truncated; + notes.hidden_columns.extend( + child_notes + .hidden_columns + .into_iter() + .map(|hidden| format!("{} > {hidden}", column.header)), + ); + } + (_, value) => { + let value_str = value.map_or_else(String::new, format_value); + out.push_str(&format!("{}: {value_str}\n", column.header)); + } + } } - out + (out, notes) } fn render_array(items: &[Value], fields: &str, available_width: usize) -> (String, RenderNotes) { @@ -785,6 +830,74 @@ fn render_table(headers: &[String], widths: &[usize], rows: &[Vec]) -> S out } +/// Resolves a column's (possibly dotted) field path against an object, +/// walking down through nested objects one segment at a time — e.g. +/// `"parameters.items"` reaches `map["parameters"]["items"]`. +/// +/// Returns `None` when: `field` is empty; any segment (including a +/// leading/trailing/doubled `.`) is empty; an intermediate or leaf segment is +/// missing; or an intermediate segment's value is not an object. The leaf +/// segment's value is returned as-is whatever its `Value` variant is — +/// callers decide what to do with that. +fn resolve_field_path<'value>( + map: &'value serde_json::Map, + field: &str, +) -> Option<&'value Value> { + let mut segments = field.split('.'); + let first = segments.next()?; + if first.is_empty() { + return None; + } + let mut current = map.get(first)?; + for segment in segments { + if segment.is_empty() { + return None; + } + current = current.as_object()?.get(segment)?; + } + Some(current) +} + +/// Prefixes every non-empty line of `block` with `indent`, leaving blank +/// lines (e.g. the blank line before a table's `(N rows)` footer) bare so no +/// line ever carries trailing-whitespace-only indent. Round-trips a block's +/// existing single-trailing-newline convention. +fn indent_block(block: &str, indent: &str) -> String { + block + .lines() + .map(|line| { + if line.is_empty() { + line.to_owned() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") + + "\n" +} + +/// Renders a nested column's resolved value as a child block, reusing the +/// same renderers a top-level array/object would use, just at a narrowed +/// width. Anything that isn't list-of-objects or object shaped (scalar, +/// non-uniform array, etc.) falls back to the exact one-line `format_value` +/// rendering an un-opted-in nested column would have produced — opting a +/// column into `nested` is a no-op for any run where the runtime value +/// doesn't actually have that shape. +fn render_nested_value( + value: &Value, + nested_columns: &[TableColumn], + available_width: usize, +) -> (String, RenderNotes) { + match value { + Value::Array(items) if items.iter().all(Value::is_object) => { + render_array_with_columns(items, nested_columns, available_width) + } + Value::Object(map) => render_object_with_columns(map, nested_columns, available_width), + other => (format!("{}\n", format_value(other)), RenderNotes::default()), + } +} + fn format_value(value: &Value) -> String { match value { Value::Null => String::new(), @@ -1337,4 +1450,144 @@ mod tests { assert_eq!(out, "(no results)\n"); assert!(notes.hidden_columns.is_empty(), "{out}"); } + + #[test] + fn resolve_field_path_walks_dotted_wrapper_and_reports_missing_or_wrong_shape() { + let map = json!({ + "parameters": { "items": [{"name": "limit"}], "total": 1 }, + "owner": "not-an-object", + }); + let map = map.as_object().expect("object fixture"); + + assert_eq!( + resolve_field_path(map, "parameters.items"), + map.get("parameters").and_then(|value| value.get("items")) + ); + assert_eq!(resolve_field_path(map, "parameters.missing"), None); + assert_eq!( + resolve_field_path(map, "owner.name"), + None, + "intermediate value is a string, not an object" + ); + assert_eq!(resolve_field_path(map, "missing"), None); + assert_eq!(resolve_field_path(map, ""), None, "empty field"); + assert_eq!(resolve_field_path(map, ".parameters"), None, "leading dot"); + assert_eq!(resolve_field_path(map, "parameters."), None, "trailing dot"); + assert_eq!( + resolve_field_path(map, "parameters..items"), + None, + "doubled dot" + ); + } + + #[test] + fn nested_array_of_objects_renders_as_indented_child_table() { + let map = json!({ + "name": "getPets", + "parameters": { + "items": [ + {"name": "limit", "in": "query"}, + {"name": "id", "in": "path"}, + ], + "total": 2, + }, + }); + let columns = vec![ + TableColumn::new("name", "Name"), + TableColumn::new("parameters.items", "Parameters").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("in", "In"), + ]), + ]; + + let (out, notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert!(out.starts_with("Name: getPets\nParameters:\n"), "{out}"); + assert!( + out.contains(" NAME"), + "child header must be indented: {out}" + ); + assert!(out.contains(" limit"), "child row must be indented: {out}"); + assert!( + !out.contains('{'), + "no raw JSON should leak into output: {out}" + ); + assert!(!notes.truncated, "{out}"); + } + + #[test] + fn nested_child_table_narrows_and_reports_via_merged_render_notes() { + let map = json!({ + "items": [ + {"a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5)}, + ], + }); + let columns = vec![TableColumn::new("items", "Items").nested(vec![ + TableColumn::new("a", "A"), + TableColumn::new("b", "B"), + TableColumn::new("c", "C"), + ])]; + + // Narrow enough to force the child table's own hide-before-truncate + // cascade (mirrors `narrow_terminal_hides_columns_before_truncating_any_of_the_survivors`). + let (out, notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 12); + + assert_eq!( + notes.hidden_columns, + vec!["Items > B".to_owned(), "Items > C".to_owned()], + "hidden columns bubble up prefixed with the parent header: {out}" + ); + } + + #[test] + fn empty_nested_array_renders_no_results_indented() { + let map = json!({ "items": [] }); + let columns = vec![ + TableColumn::new("items", "Parameters").nested(vec![TableColumn::new("name", "Name")]), + ]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert_eq!(out, "Parameters:\n (no results)\n"); + } + + #[test] + fn nested_object_field_renders_as_indented_property_bag() { + let map = json!({ "owner": {"name": "Ada", "email": "ada@example.test"} }); + let columns = vec![TableColumn::new("owner", "Owner").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("email", "Email"), + ])]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert_eq!(out, "Owner:\n Name: Ada\n Email: ada@example.test\n"); + } + + #[test] + fn unopted_in_nested_value_still_renders_as_raw_json_line() { + // A column with no `.nested(...)` is a strict no-op even when the + // runtime value happens to be list/object shaped — locks in the + // "opt-in, never automatic" guarantee. + let map = json!({ + "parameters": {"items": [{"name": "limit"}], "total": 1}, + }); + let columns = vec![TableColumn::new("parameters", "Parameters")]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert_eq!( + out, + format!( + "Parameters: {}\n", + format_value(map.get("parameters").expect("parameters")) + ) + ); + assert!(out.contains('{'), "unchanged raw-JSON fallback: {out}"); + } } diff --git a/tests/consumer_cli.rs b/tests/consumer_cli.rs index 48c0969..e08a011 100644 --- a/tests/consumer_cli.rs +++ b/tests/consumer_cli.rs @@ -629,3 +629,69 @@ async fn completion_print_unknown_shell_exits_nonzero() { out.rendered ); } + +// A command returning an object whose "parameters" field wraps a list under +// `items` (the pagination/`Summary`-style shape a consumer CLI might use) +// — a nested view column reaches through that wrapper via a dotted field path. +fn nested_view_operation_cli() -> Cli { + Cli::new( + CliConfig::new("my-cli", "Team CLI", "my-cli") + .with_build(BuildInfo::new("0.1.0")) + .with_module(Module::new("Demo", |_context| { + RuntimeGroupSpec::new(GroupSpec::new("operation", "Inspect operations")) + .with_command(RuntimeCommandSpec::new( + CommandSpec::new("get", "Get an operation") + .with_view(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("parameters.items", "Parameters").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("in", "In"), + ]), + ]) + .no_auth(true), + async |_credential, _args| { + Ok(CommandResult::new(json!({ + "name": "getPets", + "parameters": { + "items": [ + {"name": "limit", "in": "query"}, + {"name": "id", "in": "path"}, + ], + "total": 2, + }, + }))) + }, + )) + })), + ) +} + +#[tokio::test] +async fn human_output_renders_nested_view_column_as_indented_child_table() { + // Proves the full wiring, not just the human.rs-internal path: `with_view` + // registration, `HumanViewRegistry` lookup by command path, and + // `render_human_with_registry_selected` all carry the nested column + // through to a rendered indented child table. + let cli = nested_view_operation_cli(); + let human = cli + .run(["my-cli", "operation", "get", "--output", "human"]) + .await; + assert_eq!(human.exit_code, 0, "{}", human.rendered); + assert!( + human.rendered.contains("Name: getPets"), + "{}", + human.rendered + ); + assert!( + human.rendered.contains("Parameters:\n"), + "{}", + human.rendered + ); + assert!(human.rendered.contains(" NAME"), "{}", human.rendered); + assert!(human.rendered.contains(" limit"), "{}", human.rendered); + assert!( + !human.rendered.contains('{'), + "no raw JSON should leak into human output: {}", + human.rendered + ); +} diff --git a/tests/exhaustive_output.rs b/tests/exhaustive_output.rs index a13c543..74e6aa8 100644 --- a/tests/exhaustive_output.rs +++ b/tests/exhaustive_output.rs @@ -191,7 +191,11 @@ fn renderer_format_matrix_has_stable_success_and_error_shapes() { } #[test] -fn human_view_columns_preserve_shape_for_empty_missing_and_nested_values() { +fn human_view_columns_resolve_dotted_paths_and_preserve_shape_for_empty_and_missing_values() { + // A dotted `field` path walks down into a nested object (row 1's + // "owner.name" resolves to "Ada"); a missing intermediate key or a + // present-but-empty nested object (row 2's `owner: {}`) still renders as + // an empty cell, same as a top-level missing field. let columns = vec![ TableColumn::new("id", "ID"), TableColumn::new("owner.name", "Owner"), @@ -207,7 +211,7 @@ fn human_view_columns_preserve_shape_for_empty_missing_and_nested_values() { assert_eq!( render_human_with_view(&envelope, Some(&columns), ""), - "ID OWNER MISSING\n-- ----- -------\np1 \np2 \n\n(2 rows)\n" + "ID OWNER MISSING\n-- ----- -------\np1 Ada \np2 \n\n(2 rows)\n" ); } diff --git a/tests/foundation.rs b/tests/foundation.rs index 8b20cba..06c7fb3 100644 --- a/tests/foundation.rs +++ b/tests/foundation.rs @@ -1130,11 +1130,13 @@ async fn cli_config_registers_modules_guides_views_and_init_once() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }, TableColumn { field: "enabled".to_owned(), header: "Enabled".to_owned(), no_truncate: false, + nested: None, }, ], }); @@ -1263,6 +1265,7 @@ async fn cli_config_accepts_trait_based_command_modules() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }], }] } @@ -1564,11 +1567,13 @@ async fn cli_seeds_schema_and_human_views_from_global_registries() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }, TableColumn { field: "enabled".to_owned(), header: "Enabled".to_owned(), no_truncate: false, + nested: None, }, ], }); @@ -7816,11 +7821,13 @@ async fn middleware_human_output_default_fields_narrows_view_columns() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }, TableColumn { field: "status".to_owned(), header: "Status".to_owned(), no_truncate: false, + nested: None, }, ], }); @@ -7871,11 +7878,13 @@ async fn middleware_human_output_resolves_declared_view_id() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }, TableColumn { field: "status".to_owned(), header: "Status".to_owned(), no_truncate: false, + nested: None, }, ], }); @@ -7918,6 +7927,7 @@ async fn middleware_human_output_uses_custom_view_function_before_columns() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }], }); middleware.human_views.register_func("things:list", |data| { @@ -9176,6 +9186,7 @@ fn human_renderer_column_mixed_object_scalar_array_falls_back_to_lines() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }]; let envelope = Envelope::success( json!([ @@ -9201,11 +9212,13 @@ fn human_view_registry_renders_registered_columns_for_lists() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }, TableColumn { field: "enabled".to_owned(), header: "Enabled".to_owned(), no_truncate: false, + nested: None, }, ], }); @@ -9232,11 +9245,13 @@ fn human_view_registry_renders_registered_columns_for_objects() { field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }, TableColumn { field: "missing".to_owned(), header: "Missing".to_owned(), no_truncate: false, + nested: None, }, ]; let envelope = Envelope::success(json!({"name": "alpha", "ignored": "x"}), "things"); @@ -9255,6 +9270,7 @@ fn human_view_registry_custom_renderer_wins_over_columns_preserves_legacy_view_f field: "name".to_owned(), header: "Name".to_owned(), no_truncate: false, + nested: None, }], }); registry.register_func("things", |data| { From e6dad6562cb0c908fb00eb79a46233e7ff7e2228 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 31 Jul 2026 18:01:45 -0700 Subject: [PATCH 2/8] fix(output): make TableColumn::nested a true no-op for wrong-shaped values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/concepts.md | 2 +- src/output/human.rs | 67 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/concepts.md b/docs/concepts.md index 99af198..c4b54aa 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -571,7 +571,7 @@ Human output is designed for readable terminal display: hiding all work identically either way. - Objects render as `key: value` lines. - Mixed object/scalar arrays fall back to line-per-item rendering. -- Objects in fallback lines render as compact JSON, unless a column opts into `nested` rendering — see below. +- Objects in fallback lines render as compact JSON. - JSON numbers use `serde_json` number text. - Table columns size to the live terminal width (falling back to a fixed 80 columns when stdout isn't a TTY, e.g. when piped) rather than a fixed diff --git a/src/output/human.rs b/src/output/human.rs index edb5d45..a5b90fd 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -749,7 +749,7 @@ fn render_object_with_columns( for column in columns { let value = resolve_field_path(map, &column.field); match (&column.nested, value) { - (Some(nested_columns), Some(value)) => { + (Some(nested_columns), Some(value)) if is_nestable(value) => { out.push_str(&format!("{}:\n", column.header)); let child_width = available_width.saturating_sub(NESTED_INDENT.len()); let (block, child_notes) = render_nested_value(value, nested_columns, child_width); @@ -877,22 +877,31 @@ fn indent_block(block: &str, indent: &str) -> String { + "\n" } +/// Whether `value` is a shape [`TableColumn::nested`] can render as a child +/// block: a single object, or an array whose items are all objects (an empty +/// array trivially qualifies, rendering as an indented "no results"). Gates +/// entry into nested rendering in [`render_object_with_columns`] so a column +/// with `.nested(...)` set is a true no-op — the exact same single-line +/// `format_value` rendering an un-opted-in column would have produced — +/// whenever the runtime value doesn't actually have this shape (a scalar, or +/// an array mixing objects with non-objects). +fn is_nestable(value: &Value) -> bool { + matches!(value, Value::Object(_)) + || matches!(value, Value::Array(items) if items.iter().all(Value::is_object)) +} + /// Renders a nested column's resolved value as a child block, reusing the /// same renderers a top-level array/object would use, just at a narrowed -/// width. Anything that isn't list-of-objects or object shaped (scalar, -/// non-uniform array, etc.) falls back to the exact one-line `format_value` -/// rendering an un-opted-in nested column would have produced — opting a -/// column into `nested` is a no-op for any run where the runtime value -/// doesn't actually have that shape. +/// width. Only called once [`is_nestable`] has confirmed `value`'s shape, so +/// the array/object arms below are the only ones a real caller reaches; the +/// scalar fallback keeps this function total on its own. fn render_nested_value( value: &Value, nested_columns: &[TableColumn], available_width: usize, ) -> (String, RenderNotes) { match value { - Value::Array(items) if items.iter().all(Value::is_object) => { - render_array_with_columns(items, nested_columns, available_width) - } + Value::Array(items) => render_array_with_columns(items, nested_columns, available_width), Value::Object(map) => render_object_with_columns(map, nested_columns, available_width), other => (format!("{}\n", format_value(other)), RenderNotes::default()), } @@ -1590,4 +1599,44 @@ mod tests { ); assert!(out.contains('{'), "unchanged raw-JSON fallback: {out}"); } + + #[test] + fn nested_column_is_a_no_op_when_the_value_is_not_actually_nestable() { + // A column can opt into `.nested(...)` while still receiving a + // scalar or a mixed (non-uniform) array at runtime — e.g. a field + // that's usually a list of objects but is empty/absent for this row, + // or simply the wrong shape. Rendering must stay the same flat + // `header: value` line a column with `nested: None` would have + // produced, not a `header:\n value` block — regression guard for a + // shape-drift bug where the header line alone changed to multi-line + // even though the value itself fell back to `format_value`. + let map = json!({ + "scalar": "just a string", + "mixed": ["a", {"b": 1}], + }); + let nested_columns = vec![TableColumn::new("x", "X")]; + let columns = vec![ + TableColumn::new("scalar", "Scalar").nested(nested_columns.clone()), + TableColumn::new("mixed", "Mixed").nested(nested_columns), + ]; + let unnested_columns = vec![ + TableColumn::new("scalar", "Scalar"), + TableColumn::new("mixed", "Mixed"), + ]; + + let (nested_out, _) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + let (unnested_out, _) = render_object_with_columns( + map.as_object().expect("object fixture"), + &unnested_columns, + 80, + ); + + assert_eq!( + nested_out, unnested_out, + "an opted-in column must render identically to an unopted-in one \ + when the runtime value isn't list-of-objects or object shaped" + ); + assert_eq!(nested_out, "Scalar: just a string\nMixed: a, {\"b\":1}\n"); + } } From 0e8200c70cdeb76d28f36cc284520cb188f0980e Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 31 Jul 2026 19:13:11 -0700 Subject: [PATCH 3/8] fix(output): mark TableColumn non_exhaustive and de-literal its own tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/output/human.rs | 5 ++ tests/foundation.rs | 112 +++++++------------------------------------- 2 files changed, 21 insertions(+), 96 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index a5b90fd..8245c2b 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -22,7 +22,12 @@ use super::{Envelope, NextAction, NextActionParam}; /// [`crate::output::render_human_with_registry_selected`]), for both display /// and hide-priority. Declared order only governs output when no selection is /// given at all. +/// +/// 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. #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub struct TableColumn { /// JSON field path. Supports simple dotted paths to reach a value nested /// under intermediate objects, so a column can point through a wrapper diff --git a/tests/foundation.rs b/tests/foundation.rs index 06c7fb3..c6a1078 100644 --- a/tests/foundation.rs +++ b/tests/foundation.rs @@ -1126,18 +1126,8 @@ async fn cli_config_registers_modules_guides_views_and_init_once() { ctx.register_view(HumanViewDef { schema_id: "things".to_owned(), columns: vec![ - TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }, - TableColumn { - field: "enabled".to_owned(), - header: "Enabled".to_owned(), - no_truncate: false, - nested: None, - }, + TableColumn::new("name", "Name"), + TableColumn::new("enabled", "Enabled"), ], }); ctx.add_guide(GuideEntry { @@ -1261,12 +1251,7 @@ async fn cli_config_accepts_trait_based_command_modules() { fn views(&self) -> Vec { vec![HumanViewDef { schema_id: "trait-things".to_owned(), - columns: vec![TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }], + columns: vec![TableColumn::new("name", "Name")], }] } @@ -1563,18 +1548,8 @@ async fn cli_seeds_schema_and_human_views_from_global_registries() { register_global_human_view(HumanViewDef { schema_id: "global-things".to_owned(), columns: vec![ - TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }, - TableColumn { - field: "enabled".to_owned(), - header: "Enabled".to_owned(), - no_truncate: false, - nested: None, - }, + TableColumn::new("name", "Name"), + TableColumn::new("enabled", "Enabled"), ], }); let global_schema = @@ -7817,18 +7792,8 @@ async fn middleware_human_output_default_fields_narrows_view_columns() { middleware.human_views.register(HumanViewDef { schema_id: "things".to_owned(), columns: vec![ - TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }, - TableColumn { - field: "status".to_owned(), - header: "Status".to_owned(), - no_truncate: false, - nested: None, - }, + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), ], }); @@ -7874,18 +7839,8 @@ async fn middleware_human_output_resolves_declared_view_id() { middleware.human_views.register(HumanViewDef { schema_id: "projects-table".to_owned(), columns: vec![ - TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }, - TableColumn { - field: "status".to_owned(), - header: "Status".to_owned(), - no_truncate: false, - nested: None, - }, + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), ], }); @@ -7923,12 +7878,7 @@ async fn middleware_human_output_uses_custom_view_function_before_columns() { middleware.output_format = "human".to_owned(); middleware.human_views.register(HumanViewDef { schema_id: "things:list".to_owned(), - columns: vec![TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }], + columns: vec![TableColumn::new("name", "Name")], }); middleware.human_views.register_func("things:list", |data| { format!("custom:{}\n", data.as_array().map_or(0, Vec::len)) @@ -9182,12 +9132,7 @@ fn human_renderer_mixed_object_scalar_array_falls_back_to_lines() { #[test] fn human_renderer_column_mixed_object_scalar_array_falls_back_to_lines() { - let columns = vec![TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }]; + let columns = vec![TableColumn::new("name", "Name")]; let envelope = Envelope::success( json!([ {"name": "alpha"}, @@ -9208,18 +9153,8 @@ fn human_view_registry_renders_registered_columns_for_lists() { registry.register(HumanViewDef { schema_id: "things".to_owned(), columns: vec![ - TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }, - TableColumn { - field: "enabled".to_owned(), - header: "Enabled".to_owned(), - no_truncate: false, - nested: None, - }, + TableColumn::new("name", "Name"), + TableColumn::new("enabled", "Enabled"), ], }); let envelope = Envelope::success( @@ -9241,18 +9176,8 @@ fn human_view_registry_renders_registered_columns_for_lists() { #[test] fn human_view_registry_renders_registered_columns_for_objects() { let columns = vec![ - TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }, - TableColumn { - field: "missing".to_owned(), - header: "Missing".to_owned(), - no_truncate: false, - nested: None, - }, + TableColumn::new("name", "Name"), + TableColumn::new("missing", "Missing"), ]; let envelope = Envelope::success(json!({"name": "alpha", "ignored": "x"}), "things"); @@ -9266,12 +9191,7 @@ fn human_view_registry_custom_renderer_wins_over_columns_preserves_legacy_view_f let mut registry = HumanViewRegistry::new(); registry.register(HumanViewDef { schema_id: "things".to_owned(), - columns: vec![TableColumn { - field: "name".to_owned(), - header: "Name".to_owned(), - no_truncate: false, - nested: None, - }], + columns: vec![TableColumn::new("name", "Name")], }); registry.register_func("things", |data| { format!( From c1919e5f14372d0924f9bff7724841705b233b9e Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 31 Jul 2026 19:18:58 -0700 Subject: [PATCH 4/8] docs(output): fix TableColumn's builder-method doc reference 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 --- src/output/human.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index 8245c2b..3097623 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -23,9 +23,10 @@ use super::{Envelope, NextAction, NextActionParam}; /// and hide-priority. Declared order only governs output when no selection is /// given at all. /// -/// 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. +/// 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. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct TableColumn { From 65560bc201cf306d86dbddae9aa2a4c564ab79c1 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 31 Jul 2026 19:24:20 -0700 Subject: [PATCH 5/8] docs(output): address Copilot's non_exhaustive framing and dotted-path 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 --- docs/concepts.md | 2 +- src/output/human.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/concepts.md b/docs/concepts.md index c4b54aa..285273c 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -580,7 +580,7 @@ Human output is designed for readable terminal display: - `TableColumn::no_truncate` opts a column out of shrinking entirely (still bounded by a large pathological-value safety cap) — use it for values that are useless when cut short, such as URLs. -- `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. +- `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. A literal field name containing a `.` is not addressable this way (the `.` is always read as a path separator), matching the same convention `crate::output::fields` already uses for `--fields` projection. - `TableColumn::nested(columns)` opts a column into rendering its value as an indented child table (when the value is a list of objects) or an indented child property bag (when it's a single object), instead of the raw-JSON fallback every other column gets. It's a strict opt-in: a column with no `.nested(...)` renders exactly as before even if its runtime value happens to be list/object shaped. Nesting only applies inside an object's property bag — a row cell inside an array-of-objects table always renders as a single flat value, since a table row is one monospace line and can't itself contain a rendered sub-block. A nested child's own columns may set `.nested(...)` again for a grandchild table or property bag; the width budget and hide-before-truncate behavior below apply to every nesting level, narrowed by two spaces of indent per level. - When the terminal is too narrow for every column, hiding a column is preferred over truncating a cell: the lowest-priority (trailing) columns — diff --git a/src/output/human.rs b/src/output/human.rs index 3097623..889a7a5 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -25,8 +25,10 @@ use super::{Envelope, NextAction, NextActionParam}; /// /// 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. +/// — 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. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct TableColumn { From 0571e9d65f43d0a177d5a7b4bc76f73aa7e64cd4 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Mon, 3 Aug 2026 08:46:42 -0700 Subject: [PATCH 6/8] fix(output): don't suggest --fields for narrowing inside a nested column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/concepts.md | 5 ++- src/output/human.rs | 106 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/docs/concepts.md b/docs/concepts.md index 285273c..7199fb0 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -589,7 +589,10 @@ Human output is designed for readable terminal display: got hidden and suggests `--fields`/`--json`. A similar footer appears if a cell still had to be shortened (only possible once hiding can't help further — e.g. a single remaining column whose value alone exceeds the - display width). + display width). When the narrowing happened inside a `TableColumn::nested` + column's own child table or property bag, the footer suggests only + `--json` — `--fields` selects among top-level declared columns and can + drop a nested column entirely, but can't narrow what's shown inside one. Views can be assigned to commands. There are two ways to do it. diff --git a/src/output/human.rs b/src/output/human.rs index 889a7a5..06f90a5 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -445,16 +445,38 @@ fn dynamic_columns(fields: &str, natural_keys: impl FnOnce() -> Vec) -> /// (a no-op when neither happened). Mirrors `append_next_actions`: writes /// directly into `out` rather than building a separate string. fn append_render_notes(out: &mut String, notes: &RenderNotes) { + // `--fields` only ever selects among top-level declared columns: it can + // drop a `TableColumn::nested` column entirely, but can't narrow what + // shows *inside* one. Suggesting it as a fix once any of the reported + // narrowing happened inside a nested block would be wrong — there's no + // flag that reaches that fine-grained, so `--json` is the only real + // remedy in that case. + let fields_helps = !notes.nested_narrowing; if notes.truncated { - out.push_str( - "\nOutput truncated to fit the display width — use --fields to show fewer columns, or --json for full values.\n", - ); + if fields_helps { + out.push_str( + "\nOutput truncated to fit the display width — use --fields to show fewer columns, or --json for full values.\n", + ); + } else { + out.push_str( + "\nOutput truncated to fit the display width — use --json for full values (--fields only selects top-level columns, not columns nested under them).\n", + ); + } } if !notes.hidden_columns.is_empty() { + let suggestion = if fields_helps { + "use --fields to choose columns, or --json for full output" + } else { + "use --json for full output (--fields only selects top-level columns, not columns nested under them)" + }; out.push_str(&format!( - "\n{} column{} hidden to fit the display width ({}) — use --fields to choose columns, or --json for full output.\n", + "\n{} column{} hidden to fit the display width ({}) — {suggestion}.\n", notes.hidden_columns.len(), - if notes.hidden_columns.len() == 1 { "" } else { "s" }, + if notes.hidden_columns.len() == 1 { + "" + } else { + "s" + }, notes.hidden_columns.join(", "), )); } @@ -553,6 +575,15 @@ struct RenderNotes { /// would have appeared in the table, had they fit) — not reverse /// priority order. hidden_columns: Vec, + /// Whether any of the truncation/hiding captured above happened inside a + /// nested child block (a `TableColumn::nested` column's own table or + /// property bag) rather than at this level's own top-level columns. + /// `--fields` only ever selects among top-level declared columns — it + /// can drop a nested column entirely, but can't narrow what's shown + /// *inside* one — so [`append_render_notes`] must not suggest `--fields` + /// as a fix when this is set, even though `hidden_columns`/`truncated` + /// are otherwise reported identically either way. + nested_narrowing: bool, } /// Chooses how many leading columns (priority order, most important first), @@ -740,6 +771,7 @@ fn render_array_with_columns( RenderNotes { truncated, hidden_columns, + nested_narrowing: false, }, ) } @@ -762,6 +794,12 @@ fn render_object_with_columns( let child_width = available_width.saturating_sub(NESTED_INDENT.len()); let (block, child_notes) = render_nested_value(value, nested_columns, child_width); out.push_str(&indent_block(&block, NESTED_INDENT)); + if child_notes.truncated + || !child_notes.hidden_columns.is_empty() + || child_notes.nested_narrowing + { + notes.nested_narrowing = true; + } notes.truncated |= child_notes.truncated; notes.hidden_columns.extend( child_notes @@ -1556,6 +1594,64 @@ mod tests { vec!["Items > B".to_owned(), "Items > C".to_owned()], "hidden columns bubble up prefixed with the parent header: {out}" ); + assert!( + notes.nested_narrowing, + "narrowing happened inside the nested child, not at this level's own columns: {out}" + ); + } + + #[test] + fn footer_does_not_suggest_fields_for_narrowing_inside_a_nested_column() { + // `--fields` only selects among top-level declared columns — it + // cannot narrow what shows *inside* a `TableColumn::nested` column. + // When a nested child's own columns get hidden, the footer must not + // claim `--fields` fixes it (regression: it used to say so + // unconditionally, misleading users into trying a flag that does + // nothing for this case — see PR review discussion). Same fixture + // shape as `render_human_with_view_reports_hidden_columns_in_footer` + // (proven to overflow the fallback 80-column width), just nested + // one level under an "items" field instead of being the top-level + // view directly. + let envelope = Envelope::success( + json!({ + "items": [{ + "id": "1", + "name": "acme", + "status": "active", + "region": "us-west", + "created_at": "2026-01-01", + "updated_at": "2026-01-02", + "notes": "irrelevant, lowest priority", + }], + }), + "thing", + ); + let columns = vec![TableColumn::new("items", "Items").nested(vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + TableColumn::new("region", "Region"), + TableColumn::new("created_at", "Created At"), + TableColumn::new("updated_at", "Updated At"), + TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"), + ])]; + + let out = render_human_with_view(&envelope, Some(&columns), ""); + + assert!(out.contains("hidden to fit the display width"), "{out}"); + assert!( + out.contains("Items > This Is An Extremely Long Trailing Column Header"), + "{out}" + ); + assert!( + !out.contains("use --fields"), + "must not suggest --fields as a fix when the narrowing is inside a nested column \ + (mentioning it to explain why it won't help is fine): {out}" + ); + assert!( + out.contains("--json"), + "must still point at --json as the real remedy: {out}" + ); } #[test] From 9a21b688527e6020d76617a66574ada02af012d4 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Mon, 3 Aug 2026 09:16:41 -0700 Subject: [PATCH 7/8] docs(output): simplify the nested-narrowing footer's --json suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/output/human.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index 06f90a5..81c4014 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -459,7 +459,7 @@ fn append_render_notes(out: &mut String, notes: &RenderNotes) { ); } else { out.push_str( - "\nOutput truncated to fit the display width — use --json for full values (--fields only selects top-level columns, not columns nested under them).\n", + "\nOutput truncated to fit the display width — use --json for full values.\n", ); } } @@ -467,7 +467,7 @@ fn append_render_notes(out: &mut String, notes: &RenderNotes) { let suggestion = if fields_helps { "use --fields to choose columns, or --json for full output" } else { - "use --json for full output (--fields only selects top-level columns, not columns nested under them)" + "use --json for full output" }; out.push_str(&format!( "\n{} column{} hidden to fit the display width ({}) — {suggestion}.\n", From 2c25599043c21db51b26bd483d9fdea24204c5a0 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Mon, 3 Aug 2026 11:59:10 -0700 Subject: [PATCH 8/8] fix(output): match render_array_with_columns's empty-columns fallback for objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/output/human.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/output/human.rs b/src/output/human.rs index 81c4014..1726057 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -781,7 +781,12 @@ fn render_object_with_columns( columns: &[TableColumn], available_width: usize, ) -> (String, RenderNotes) { - if map.is_empty() { + if map.is_empty() || columns.is_empty() { + // Empty columns happens the same way it does in + // `render_array_with_columns`: a view's `--fields` filtered out + // every declared column. Nothing to render either way, so this + // reports the same "(no data)" a genuinely empty object gets, + // rather than an unlabeled blank line. return ("(no data)\n".to_owned(), RenderNotes::default()); } let mut out = String::new(); @@ -1494,6 +1499,21 @@ mod tests { assert!(notes.hidden_columns.is_empty(), "{out}"); } + #[test] + fn render_object_with_columns_handles_no_columns_gracefully() { + // Sibling of the array-path test above (Copilot/human review caught + // this asymmetry): a view's `--fields` filtered out every declared + // column on an object-shaped response must report "(no data)" + // rather than silently rendering an empty string. + let map = json!({ "a": "1" }); + let (out, notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &[], 80); + + assert_eq!(out, "(no data)\n"); + assert!(!notes.truncated, "{out}"); + assert!(notes.hidden_columns.is_empty(), "{out}"); + } + #[test] fn no_view_array_of_empty_objects_reports_no_results() { // Every item is `{}`, so the dynamic (no-view) column catalog has no