diff --git a/docs/concepts.md b/docs/concepts.md index 4c6e252..285273c 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -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. 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 — 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..889a7a5 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -22,9 +22,22 @@ 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 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. #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] 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 +46,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 +62,7 @@ impl TableColumn { field: field.into(), header: header.into(), no_truncate: false, + nested: None, } } @@ -53,6 +73,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 +397,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 +406,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 +521,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 +675,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 +747,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)) 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); + 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 +838,83 @@ 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" +} + +/// 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. 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) => 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 +1467,184 @@ 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}"); + } + + #[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"); + } } 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..c6a1078 100644 --- a/tests/foundation.rs +++ b/tests/foundation.rs @@ -1126,16 +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, - }, - TableColumn { - field: "enabled".to_owned(), - header: "Enabled".to_owned(), - no_truncate: false, - }, + TableColumn::new("name", "Name"), + TableColumn::new("enabled", "Enabled"), ], }); ctx.add_guide(GuideEntry { @@ -1259,11 +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, - }], + columns: vec![TableColumn::new("name", "Name")], }] } @@ -1560,16 +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, - }, - TableColumn { - field: "enabled".to_owned(), - header: "Enabled".to_owned(), - no_truncate: false, - }, + TableColumn::new("name", "Name"), + TableColumn::new("enabled", "Enabled"), ], }); let global_schema = @@ -7812,16 +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, - }, - TableColumn { - field: "status".to_owned(), - header: "Status".to_owned(), - no_truncate: false, - }, + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), ], }); @@ -7867,16 +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, - }, - TableColumn { - field: "status".to_owned(), - header: "Status".to_owned(), - no_truncate: false, - }, + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), ], }); @@ -7914,11 +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, - }], + 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)) @@ -9172,11 +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, - }]; + let columns = vec![TableColumn::new("name", "Name")]; let envelope = Envelope::success( json!([ {"name": "alpha"}, @@ -9197,16 +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, - }, - TableColumn { - field: "enabled".to_owned(), - header: "Enabled".to_owned(), - no_truncate: false, - }, + TableColumn::new("name", "Name"), + TableColumn::new("enabled", "Enabled"), ], }); let envelope = Envelope::success( @@ -9228,16 +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, - }, - TableColumn { - field: "missing".to_owned(), - header: "Missing".to_owned(), - no_truncate: false, - }, + TableColumn::new("name", "Name"), + TableColumn::new("missing", "Missing"), ]; let envelope = Envelope::success(json!({"name": "alpha", "ignored": "x"}), "things"); @@ -9251,11 +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, - }], + columns: vec![TableColumn::new("name", "Name")], }); registry.register_func("things", |data| { format!(