Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli-engine/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ Human output is designed for readable terminal display:
bounded by a large pathological-value safety cap) — use it for values that
are useless when cut short, such as URLs.
- `TableColumn::align(Alignment::Right)` right-aligns a column's header and cells (the default is `Alignment::Left`) — use it for numeric or price columns so digits and decimal points line up instead of looking ragged on the left. Only affects table rendering; a property-bag `key: value` line has no column width to align against.
- A no-view (dynamic) array column auto-right-aligns without any code needed: when a field is a JSON number on every row it appears in (nulls/missing don't count against it, but any string/bool/array/object value anywhere does), that column renders right-aligned, same as if a view had called `.align(Alignment::Right)` on it. This only applies to the dynamic column catalog described above — a registered view's columns always default to `Alignment::Left` and must opt in explicitly, since a view author may have a numeric-looking field (an ID, say) that shouldn't be right-aligned.
- `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
Expand Down
71 changes: 70 additions & 1 deletion cli-engine/src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,23 @@ fn dynamic_columns(fields: &str, natural_keys: impl FnOnce() -> Vec<String>) ->
.collect()
}

/// True when at least one item has a JSON number at `field`, and no item
/// with a present, non-null value at `field` holds anything else.
fn column_is_all_numeric(items: &[Value], field: &str) -> bool {
let mut saw_number = false;
for item in items {
match item
.as_object()
.and_then(|map| resolve_field_path(map, field))
{
Some(Value::Number(_)) => saw_number = true,
Some(Value::Null) | None => {}
Some(_) => return false,
}
}
saw_number
}

/// Appends footer hints for truncated cells and/or hidden columns to `out`
/// (a no-op when neither happened). Mirrors `append_next_actions`: writes
/// directly into `out` rather than building a separate string.
Expand Down Expand Up @@ -953,7 +970,16 @@ fn render_array(
if !items.iter().all(Value::is_object) {
return (render_array_lines(items), RenderNotes::default());
}
let columns = dynamic_columns(fields, || first_map.keys().cloned().collect());
let columns: Vec<TableColumn> = dynamic_columns(fields, || first_map.keys().cloned().collect())
.into_iter()
.map(|column| {
if column_is_all_numeric(items, &column.field) {
column.align(Alignment::Right)
} else {
column
}
})
.collect();
render_array_with_columns(items, &columns, available_width, pagination)
}

Expand Down Expand Up @@ -1610,6 +1636,49 @@ mod tests {
);
}

#[test]
fn no_view_array_rendering_right_aligns_a_column_that_is_numeric_on_every_row() {
let items = vec![
json!({ "name": "small", "count": 3 }),
json!({ "name": "bigger", "count": 42 }),
];

let (out, _notes) = render_array(&items, "name,count", 80, None);
let mut lines = out.lines();
let header_line = lines.next().expect("header line");
let row_lines: Vec<&str> = lines.skip(1).take(2).collect();

assert!(header_line.ends_with(" COUNT"), "{header_line}");
assert!(row_lines[0].ends_with(" 3"), "{}", row_lines[0]);
assert!(row_lines[1].ends_with(" 42"), "{}", row_lines[1]);
assert!(header_line.starts_with("NAME "), "{header_line}");
}

#[test]
fn no_view_array_rendering_keeps_a_mixed_type_column_left_aligned() {
// Same field is a number on one row and a string on another — a
// single non-number value anywhere disqualifies the whole column,
// matching how right-aligning it would look ragged next to text.
let items = vec![json!({ "code": 1 }), json!({ "code": "default" })];

let (out, _notes) = render_array(&items, "", 80, None);
let header_line = out.lines().next().expect("header line");

assert!(header_line.starts_with("CODE"), "{header_line}");
}

#[test]
fn no_view_array_rendering_keeps_an_all_null_column_left_aligned() {
// No row ever has a number at this field, so there's no positive
// signal to right-align on.
let items = vec![json!({ "note": null }), json!({ "note": null })];

let (out, _notes) = render_array(&items, "", 80, None);
let header_line = out.lines().next().expect("header line");

assert!(header_line.starts_with("NOTE"), "{header_line}");
}

#[test]
fn no_view_array_rendering_follows_requested_field_order() {
// Reproduces the real-world `domain suggest` symptom: a command with
Expand Down
21 changes: 21 additions & 0 deletions cli-engine/tests/exhaustive_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,27 @@ fn human_view_right_aligned_column_lines_up_prices_in_table_output() {
);
}

#[test]
fn human_view_with_no_registered_view_auto_right_aligns_a_numeric_column() {
// No TableColumn list at all — this is the fallback/dynamic-column path
// a command falls into when it never calls `.with_view(...)`. A field
// that's a JSON number on every row (here, an endpoint count) should
// still line up on the right, matching what an explicit view would get
// from `.align(Alignment::Right)`.
let envelope = Envelope::success(
json!([
{"domain": "commerce", "endpoints": 3},
{"domain": "domains", "endpoints": 42}
]),
"api:domain:list",
);

assert_eq!(
render_human_with_view(&envelope, None, "domain,endpoints"),
"DOMAIN ENDPOINTS\n-------- ---------\ncommerce 3\ndomains 42\n\n(2 rows)\n"
);
}

#[test]
fn global_registries_tolerate_repeated_and_concurrent_registration() {
let prefix = format!(
Expand Down
4 changes: 2 additions & 2 deletions cli-engine/tests/foundation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9148,7 +9148,7 @@ fn human_renderer_preserves_json_number_text() {

assert_eq!(
rendered,
"NAME RATIO SCORE\n----- ----- -----\nalpha 1.0 1.25 \n\n(1 rows)\n"
"NAME RATIO SCORE\n----- ----- -----\nalpha 1.0 1.25\n\n(1 rows)\n"
);
}

Expand All @@ -9164,7 +9164,7 @@ fn human_renderer_formats_non_integer_json_floats_with_serde_json_text() {

assert_eq!(
render(OutputFormat::Human, &envelope).expect("floats should render"),
"NAME SCORE \n----- ---------\nlarge 1000000.5\nsmall 1.2345e-7\n\n(2 rows)\n"
"NAME SCORE\n----- ---------\nlarge 1000000.5\nsmall 1.2345e-7\n\n(2 rows)\n"
);
}

Expand Down