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 @@ -620,6 +620,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::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.
- `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
2 changes: 1 addition & 1 deletion cli-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ pub use middleware::{
};
pub use module::{CommandModule, Module, ModuleContext, ModuleRegister, build_module_group};
pub use output::{
Envelope, ErrorEnvelope, FieldInfo, HumanViewDef, HumanViewFn, HumanViewRegistry,
Alignment, Envelope, ErrorEnvelope, FieldInfo, HumanViewDef, HumanViewFn, HumanViewRegistry,
HumanViewRenderer, Metadata, NextAction, NextActionParam, OutputField, OutputFormat,
OutputSchema, PaginationMeta, PipelineOpts, RendererFactory, SchemaInfo, SchemaRegistry,
TableColumn, apply_pipeline, build_detailed_error_envelope, build_error_envelope, fields_for,
Expand Down
100 changes: 92 additions & 8 deletions cli-engine/src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ use serde_json::Value;

use super::{Envelope, NextAction, NextActionParam, PaginationMeta};

/// Column text alignment for the human table view.
///
/// Only affects the array/table rendering path (`render_array_with_columns`
/// via `render_table`) — property-bag rendering (`render_object_with_columns`)
/// prints `header: value` with no column widths to align, so alignment is a
/// no-op there.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Alignment {
/// Left-aligned (the default) — appropriate for text-like columns.
#[default]
Left,
/// Right-aligned — use for numeric/price columns so values line up on
/// their least-significant digit.
Right,
}

/// Column definition for registered human table views.
///
/// Column order is a priority order, most important first: table rendering
Expand Down Expand Up @@ -52,6 +68,8 @@ pub struct TableColumn {
/// default from [`TableColumn::new`]) is a complete no-op: rendering is
/// identical to a column with no opinion about nesting.
pub nested: Option<Vec<TableColumn>>,
/// Header and cell text alignment — see [`TableColumn::align`].
pub align: Alignment,
}

impl TableColumn {
Expand All @@ -63,6 +81,7 @@ impl TableColumn {
header: header.into(),
no_truncate: false,
nested: None,
align: Alignment::Left,
}
}

Expand All @@ -74,6 +93,16 @@ impl TableColumn {
self
}

/// Sets this column's header and cell alignment. Defaults to
/// `Alignment::Left`; use `Alignment::Right` for numeric or price
/// columns so decimal points and digits line up instead of looking
/// ragged on the left.
#[must_use]
pub fn align(mut self, alignment: Alignment) -> Self {
self.align = alignment;
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(...)`).
Expand Down Expand Up @@ -834,6 +863,10 @@ fn render_array_with_columns(
.map(|column| column.header.clone())
.collect::<Vec<_>>(),
&fitted,
&columns
.iter()
.map(|column| column.align)
.collect::<Vec<_>>(),
&rows,
pagination,
);
Expand Down Expand Up @@ -932,9 +965,20 @@ fn render_array_lines(items: &[Value]) -> String {
out
}

/// Pads `text` to `width`, on the left for `Alignment::Right` and on the
/// right otherwise — matching how the header row is padded so a column's
/// header and cells share the same alignment.
fn pad_column(text: &str, width: usize, alignment: Alignment) -> String {
match alignment {
Alignment::Left => format!("{text:<width$}"),
Alignment::Right => format!("{text:>width$}"),
}
}

fn render_table(
headers: &[String],
widths: &[usize],
alignments: &[Alignment],
rows: &[Vec<String>],
pagination: Option<&PaginationMeta>,
) -> String {
Expand All @@ -943,10 +987,10 @@ fn render_table(
if index > 0 {
out.push_str(" ");
}
out.push_str(&format!(
"{:<width$}",
header.to_uppercase(),
width = widths[index]
out.push_str(&pad_column(
&header.to_uppercase(),
widths[index],
alignments[index],
));
}
out.push('\n');
Expand All @@ -962,10 +1006,10 @@ fn render_table(
if index > 0 {
out.push_str(" ");
}
out.push_str(&format!(
"{:<width$}",
truncate(value, widths[index]),
width = widths[index]
out.push_str(&pad_column(
&truncate(value, widths[index]),
widths[index],
alignments[index],
));
}
out.push('\n');
Expand Down Expand Up @@ -1297,6 +1341,46 @@ mod tests {
);
}

#[test]
fn right_aligned_column_pads_header_and_cells_on_the_left() {
let items = vec![
json!({ "period": "1 year", "price": "71.99" }),
json!({ "period": "2 years", "price": "143.99" }),
];
let columns = vec![
TableColumn::new("period", "Period"),
TableColumn::new("price", "Price").align(Alignment::Right),
];

let (out, _notes) = render_array_with_columns(&items, &columns, 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();

// "PRICE" (5 chars) right-aligned in a 6-wide column ("143.99")
// leaves one leading space and no trailing space.
assert!(header_line.ends_with(" PRICE"), "{header_line}");
assert!(row_lines[0].ends_with(" 71.99"), "{}", row_lines[0]);
assert!(row_lines[1].ends_with("143.99"), "{}", row_lines[1]);
// The unaligned leading column is untouched (still left-aligned).
assert!(header_line.starts_with("PERIOD "), "{header_line}");
}

#[test]
fn column_alignment_defaults_to_left() {
let items = vec![json!({ "name": "a" }), json!({ "name": "bb" })];
let columns = vec![TableColumn::new("name", "Name")];

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

assert!(
header_line.starts_with("NAME"),
"Alignment::Left is the default: {header_line}"
);
}

#[test]
fn column_width_never_shrinks_below_a_long_header() {
let long_header = "A Very Long Header That Exceeds The Default Width Cap";
Expand Down
2 changes: 1 addition & 1 deletion cli-engine/src/output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub use envelope::{
pub use fields::{FieldTree, filter_fields, parse_fields};
pub(crate) use human::terminal_width;
pub use human::{
HumanViewDef, HumanViewFn, HumanViewRegistry, HumanViewRenderer, TableColumn,
Alignment, HumanViewDef, HumanViewFn, HumanViewRegistry, HumanViewRenderer, TableColumn,
global_human_view_registry_snapshot, lookup_global_human_view_columns,
lookup_global_human_view_func, register_global_human_view, register_global_human_view_func,
render_human, render_human_with_registry, render_human_with_registry_for_schema,
Expand Down
29 changes: 25 additions & 4 deletions cli-engine/tests/exhaustive_output.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use std::{sync::Arc, thread};

use cli_engine::{
Envelope, FieldInfo, HumanViewDef, OutputFormat, PaginationMeta, PipelineOpts, SchemaInfo,
TableColumn, TreeNode, apply_pipeline, filter_fields, global_human_view_registry_snapshot,
global_schema_registry_snapshot, is_valid_output_format, register_global_human_view,
register_global_schema_info, render, render_format, render_human_with_view,
Alignment, Envelope, FieldInfo, HumanViewDef, OutputFormat, PaginationMeta, PipelineOpts,
SchemaInfo, TableColumn, TreeNode, apply_pipeline, filter_fields,
global_human_view_registry_snapshot, global_schema_registry_snapshot, is_valid_output_format,
register_global_human_view, register_global_schema_info, render, render_format,
render_human_with_view,
};
use serde_json::{Value, json};

Expand Down Expand Up @@ -243,6 +244,26 @@ fn human_view_no_truncate_column_preserves_long_values_in_table_output() {
);
}

#[test]
fn human_view_right_aligned_column_lines_up_prices_in_table_output() {
let columns = vec![
TableColumn::new("period", "Period"),
TableColumn::new("price", "Price").align(Alignment::Right),
];
let envelope = Envelope::success(
json!([
{"period": "1 year", "price": "71.99"},
{"period": "2 years", "price": "143.99"}
]),
"domain:terms",
);

assert_eq!(
render_human_with_view(&envelope, Some(&columns), ""),
"PERIOD PRICE\n------- ------\n1 year 71.99\n2 years 143.99\n\n(2 rows)\n"
);
}

#[test]
fn global_registries_tolerate_repeated_and_concurrent_registration() {
let prefix = format!(
Expand Down