diff --git a/cli-engine/docs/concepts.md b/cli-engine/docs/concepts.md index cf24f44..03ca4e4 100644 --- a/cli-engine/docs/concepts.md +++ b/cli-engine/docs/concepts.md @@ -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 diff --git a/cli-engine/src/lib.rs b/cli-engine/src/lib.rs index 8125eb3..08cc629 100644 --- a/cli-engine/src/lib.rs +++ b/cli-engine/src/lib.rs @@ -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, diff --git a/cli-engine/src/output/human.rs b/cli-engine/src/output/human.rs index 243b151..a15c7de 100644 --- a/cli-engine/src/output/human.rs +++ b/cli-engine/src/output/human.rs @@ -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 @@ -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>, + /// Header and cell text alignment — see [`TableColumn::align`]. + pub align: Alignment, } impl TableColumn { @@ -63,6 +81,7 @@ impl TableColumn { header: header.into(), no_truncate: false, nested: None, + align: Alignment::Left, } } @@ -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(...)`). @@ -834,6 +863,10 @@ fn render_array_with_columns( .map(|column| column.header.clone()) .collect::>(), &fitted, + &columns + .iter() + .map(|column| column.align) + .collect::>(), &rows, pagination, ); @@ -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: format!("{text:>width$}"), + } +} + fn render_table( headers: &[String], widths: &[usize], + alignments: &[Alignment], rows: &[Vec], pagination: Option<&PaginationMeta>, ) -> String { @@ -943,10 +987,10 @@ fn render_table( if index > 0 { out.push_str(" "); } - out.push_str(&format!( - "{: 0 { out.push_str(" "); } - out.push_str(&format!( - "{: = 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"; diff --git a/cli-engine/src/output/mod.rs b/cli-engine/src/output/mod.rs index a24f78c..d2056a3 100644 --- a/cli-engine/src/output/mod.rs +++ b/cli-engine/src/output/mod.rs @@ -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, diff --git a/cli-engine/tests/exhaustive_output.rs b/cli-engine/tests/exhaustive_output.rs index 39aeacf..3092462 100644 --- a/cli-engine/tests/exhaustive_output.rs +++ b/cli-engine/tests/exhaustive_output.rs @@ -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}; @@ -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!(