diff --git a/cli-engine/docs/concepts.md b/cli-engine/docs/concepts.md index 76f6bdc..eb968ae 100644 --- a/cli-engine/docs/concepts.md +++ b/cli-engine/docs/concepts.md @@ -624,17 +624,9 @@ Human output is designed for readable terminal display: - 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 - preferred over truncating a cell: the lowest-priority (trailing) columns — - see "Column order is priority" below — are hidden one at a time until the - survivors fit in full, or only one column remains. A footer names whatever - 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). 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. +- Hiding and truncation are convenience behaviors reserved for a *default* view — declared column order with no `--fields`, or a command's `default_fields` — because cli-engine, not the user, picked that field set, so it's also free to trim it back down to whatever fits. When the terminal is too narrow for every column in a default view, hiding a column is preferred over truncating a cell: the lowest-priority (trailing) non-essential columns — see "Column order is priority" below — are hidden one at a time until the survivors fit in full, or only one column remains. A footer names whatever 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 non-essential column whose value alone exceeds the 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. +- `TableColumn::essential(true)` exempts a column from both hiding and truncation for width, even if it alone exceeds the terminal — use it for a column a default view is useless without, e.g. a DNS record list needs `type`/`name`/`data` at minimum to mean anything. If every remaining column ends up essential and they still don't fit together, the row simply overflows the terminal rather than losing or shortening any of them. An essential column can still be dropped entirely by an explicit `--fields` selection that simply omits it — `essential` only governs the default view's trimming, not what the user explicitly asked to see. +- An *explicit* `--fields` (as opposed to a command's `default_fields` fallback) disables terminal-width-driven hiding and shrinking altogether for the columns it selects, regardless of their `essential` flag: the user named exactly what they want to see, so it's no longer cli-engine's call whether that fits — every selected column renders at its natural width, and the row overflows the terminal if it must. The `NO_TRUNCATE_MAX_WIDTH` pathological-value safety cap still applies underneath that, same as for an `essential` or `no_truncate` column — a value past that cap is still shortened, just never for terminal-width reasons. This makes `essential` purely a default-view concern; once the user has typed `--fields`, the whole selection already behaves as if every column in it were essential. Views can be assigned to commands. There are two ways to do it. @@ -701,10 +693,35 @@ That `(2 of 5 rows, ...)` footer comes from a `pagination` sibling on the object ### 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). +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 non-essential columns are hidden first — see `TableColumn::essential` above for columns that should never be hidden or truncated, regardless of their priority position). -A view's *declared* order is only the fallback, though: whenever -`--fields`/`default_fields` gives an explicit selection, that order wins instead — for both display and hide-priority — the same way for a view or a no-view command. `--fields` (defaulting to the command's `default_fields`) selects which fields appear and in what order: which of a view's declared columns show (a field the view doesn't declare never appears, no matter what `--fields` says — the view is a closed, complete set), or which JSON fields show when there's no view (open — whatever's named, or present, shows). So a command with a view of `id`/`name`/`status` columns and `default_fields = "id,name"` shows just those two by default, in that order; `--fields status,id` shows `status` before `id`; `--fields all` shows every declared column in its declared order. A custom view renderer receives the full payload and ignores field selection. +A view's *declared* order is only the fallback, though: whenever `--fields`/`default_fields` gives an explicit selection, that order wins instead — for both display and hide-priority — the same way for a view or a no-view command. `--fields` (defaulting to the command's `default_fields`) selects which fields appear and in what order: which of a view's declared columns show (a field the view doesn't declare never appears, no matter what `--fields` says — the view is a closed, complete set), or which JSON fields show when there's no view (open — whatever's named, or present, shows). So a command with a view of `id`/`name`/`status` columns and `default_fields = "id,name"` shows just those two by default, in that order; `--fields status,id` shows `status` before `id`; `--fields all` shows every declared column in its declared order. A custom view renderer receives the full payload and ignores field selection. + +A user-typed `--fields` (not the `default_fields` fallback) additionally disables width-based hiding and truncation for the columns it selects: the user asked for exactly these fields, so the engine won't second-guess that by dropping or shortening one for width — the row overflows the terminal instead if it has to. `default_fields` carries no such guarantee — it's cli-engine's assumed sensible default, not something the user asked for, so hiding and truncation still apply to it the same as to a view's full declared order. + +### Testing a view + +Human rendering happens automatically: a command handler returns data, middleware builds the `Envelope`, and the engine picks JSON/human/TOON based on `--output`. A command module never calls a renderer itself in production code. + +To check that a `TableColumn` list renders sensibly against fixture data — column order, alignment, truncation, nested tables — without running the whole CLI (auth, args, a live handler) or hand-building an `Envelope`, use `preview_human_view(data, columns)`: + +```rust +use cli_engine::{TableColumn, preview_human_view}; +use serde_json::json; + +let columns = vec![ + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), +]; +let rendered = preview_human_view( + json!([{ "name": "alpha", "status": "active" }]), + &columns, +); +assert!(rendered.starts_with("NAME")); +assert!(rendered.contains("alpha")); +``` + +`preview_human_view` renders exactly as a default view would — no `--fields` simulation, normal width-based hiding/truncation — since that's how a view's own columns behave on their own. It's deliberately the only column-aware renderer cli-engine exposes publicly: the lower-level renderers it delegates to are free to keep changing shape as rendering behavior evolves (as happened when `essential` and explicit-`--fields` support were added) without that becoming a breaking change for every command module's tests. ## Guides diff --git a/cli-engine/src/lib.rs b/cli-engine/src/lib.rs index 441abff..c6bd168 100644 --- a/cli-engine/src/lib.rs +++ b/cli-engine/src/lib.rs @@ -171,13 +171,11 @@ pub use output::{ fields_from_json_schema, filter_fields, format_help_section, get_global_schema_by_path, global_human_view_registry_snapshot, global_schema_registry_snapshot, is_valid_output_format, json_schema_for, json_schema_info, lookup_global_human_view_columns, - lookup_global_human_view_func, parse_fields, register_global_human_view, + lookup_global_human_view_func, parse_fields, preview_human_view, register_global_human_view, register_global_human_view_func, register_global_json_schema, register_global_schema, register_global_schema_fields, register_global_schema_info, render, render_data, render_data_format, render_detailed_error, render_detailed_error_format, render_error, - render_error_format, render_format, render_human, render_human_with_registry, - render_human_with_registry_for_schema, render_human_with_registry_selected, - render_human_with_view, render_json, render_toon, write_render, + render_error_format, render_format, render_human, render_json, render_toon, write_render, }; pub use search::{SearchDocument, SearchResult}; pub use tier::Tier; diff --git a/cli-engine/src/middleware/run.rs b/cli-engine/src/middleware/run.rs index 83d2521..771ad9f 100644 --- a/cli-engine/src/middleware/run.rs +++ b/cli-engine/src/middleware/run.rs @@ -567,6 +567,7 @@ impl Middleware { &self.human_views, view_id, effective_fields, + self.fields_explicit, ) } else { crate::output::render(output_format, &prepared)? diff --git a/cli-engine/src/output/human/body.rs b/cli-engine/src/output/human/body.rs index fdefb7a..bfcf0fd 100644 --- a/cli-engine/src/output/human/body.rs +++ b/cli-engine/src/output/human/body.rs @@ -36,12 +36,17 @@ pub(super) fn render_data_body( fields: &str, available_width: usize, pagination: Option<&PaginationMeta>, + fields_explicit: bool, ) -> (String, RenderNotes) { if let Some(columns) = columns { return match data { - Value::Array(items) => { - render_array_with_columns(items, columns, available_width, pagination) - } + Value::Array(items) => render_array_with_columns( + items, + columns, + available_width, + pagination, + fields_explicit, + ), 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()) @@ -49,7 +54,9 @@ pub(super) fn render_data_body( }; } match data { - Value::Array(items) => render_array(items, fields, available_width, pagination), + Value::Array(items) => { + render_array(items, fields, available_width, pagination, fields_explicit) + } Value::Object(map) => { let columns = dynamic_columns(fields, || map.keys().cloned().collect()); render_object_with_columns(map, &columns, available_width) @@ -66,6 +73,7 @@ pub(crate) fn render_array_with_columns( columns: &[TableColumn], available_width: usize, pagination: Option<&PaginationMeta>, + fields_explicit: bool, ) -> (String, RenderNotes) { if items.is_empty() || columns.is_empty() { // Empty columns happens when every item is `{}` (the no-view @@ -79,13 +87,30 @@ pub(crate) fn render_array_with_columns( return (render_array_lines(items), RenderNotes::default()); } // Natural widths (and rows) are computed for every original column - // before deciding what to hide: a `no_truncate` column never shrinks - // below its natural width, so the hiding decision has to know that real - // requirement — using just its header length here could keep a - // low-priority trailing column that would never have fit anyway, - // producing an overflow that hiding it would have avoided. + // before deciding what to hide: a protected column (see `protected_all` + // below) never shrinks below its natural width, so the hiding decision + // has to know that real requirement — using just its header length here + // could keep a low-priority trailing column that would never have fit + // anyway, producing an overflow that hiding it would have avoided. let header_lens: Vec = columns.iter().map(|column| column.header.len()).collect(); - let no_truncate_all: Vec = columns.iter().map(|column| column.no_truncate).collect(); + // An explicit `--fields` selection means the user asked for exactly + // these columns, so none of them should be dropped *or* truncated for + // width — it's no longer the engine's problem if they don't all fit. + // Treating the whole set as essential gets both for free: essential + // columns are never dropped (below) and, as of `protected_all`, never + // truncated either. + let essential_all: Vec = if fields_explicit { + vec![true; columns.len()] + } else { + columns.iter().map(|column| column.essential).collect() + }; + // Essential and `no_truncate` columns both never shrink below their + // natural width — essential additionally exempts a column from being + // *hidden* (via `essential_all` above), while `no_truncate` only exempts + // it from shrinking. + let protected_all: Vec = (0..columns.len()) + .map(|index| columns[index].no_truncate || essential_all[index]) + .collect(); let mut natural = header_lens.clone(); let rows: Vec> = items .iter() @@ -98,7 +123,7 @@ pub(crate) fn render_array_with_columns( .as_object() .and_then(|map| resolve_field_path(map, &column.field)) .map_or_else(String::new, format_value); - let cap = if column.no_truncate { + let cap = if protected_all[index] { NO_TRUNCATE_MAX_WIDTH } else { usize::MAX @@ -112,40 +137,59 @@ pub(crate) fn render_array_with_columns( let min_widths: Vec = (0..columns.len()) .map(|index| { - if no_truncate_all[index] { + if protected_all[index] { natural[index] } else { header_lens[index] } }) .collect(); - let mut kept = columns_fitting_width(&min_widths, available_width); + let mut kept = columns_fitting_width(&min_widths, &essential_all, available_width); // Hiding a column is preferred over truncating a cell: if the survivors // still don't fit their natural width, keep dropping the lowest-priority - // one and re-fitting, until either everyone remaining fits in full or - // only one column is left (which always stays, however it fits). + // droppable (non-essential) one and re-fitting, until either everyone + // remaining fits in full, only one column is left (which always stays, + // however it fits), or every survivor is essential and nothing more can + // be dropped. The last case can't actually still be truncated — once + // every survivor is essential, every survivor is also protected (see + // `protected_all`), so `fit_column_widths` has nothing left to shrink — + // but the fallback stays as a defensive floor against that invariant + // breaking rather than looping forever. let (fitted, truncated) = loop { - let (fitted, truncated) = fit_column_widths( - &header_lens[..kept], - &natural[..kept], - &no_truncate_all[..kept], - available_width, - ); - if !truncated || kept <= 1 { + let sub_headers: Vec = kept.iter().map(|&index| header_lens[index]).collect(); + let sub_natural: Vec = kept.iter().map(|&index| natural[index]).collect(); + let sub_protected: Vec = kept.iter().map(|&index| protected_all[index]).collect(); + let (fitted, truncated) = + fit_column_widths(&sub_headers, &sub_natural, &sub_protected, available_width); + if !truncated || kept.len() <= 1 { break (fitted, truncated); } - kept -= 1; + match kept.iter().rposition(|&index| !essential_all[index]) { + Some(position) => { + kept.remove(position); + } + None => break (fitted, truncated), + } }; - let hidden_columns = columns[kept..] - .iter() - .map(|column| column.header.clone()) + let hidden_columns = (0..columns.len()) + .filter(|index| !kept.contains(index)) + .map(|index| columns[index].header.clone()) .collect::>(); - let columns = &columns[..kept]; + let columns: Vec = kept.iter().map(|&index| columns[index].clone()).collect(); + // Each row is already owned here, so move the kept cells out instead of + // cloning them — a large response's row data would otherwise be + // temporarily duplicated in full just to narrow down to `kept`. let rows: Vec> = rows .into_iter() - .map(|row| row.into_iter().take(kept).collect()) + .map(|row| { + row.into_iter() + .enumerate() + .filter(|(index, _)| kept.contains(index)) + .map(|(_, value)| value) + .collect() + }) .collect(); let table = render_table( @@ -234,6 +278,7 @@ pub(crate) fn render_array( fields: &str, available_width: usize, pagination: Option<&PaginationMeta>, + fields_explicit: bool, ) -> (String, RenderNotes) { if items.is_empty() { return ("(no results)\n".to_owned(), RenderNotes::default()); @@ -254,7 +299,13 @@ pub(crate) fn render_array( } }) .collect(); - render_array_with_columns(items, &columns, available_width, pagination) + render_array_with_columns( + items, + &columns, + available_width, + pagination, + fields_explicit, + ) } fn render_array_lines(items: &[Value]) -> String { @@ -346,8 +397,12 @@ fn render_nested_value( pagination: Option<&PaginationMeta>, ) -> (String, RenderNotes) { match value { + // A nested block's columns are authored by the view, never by a + // top-level `--fields` selection (which only reaches top-level + // declared columns), so width-based dropping here always honors + // each column's `essential` flag rather than being disabled wholesale. Value::Array(items) => { - render_array_with_columns(items, nested_columns, available_width, pagination) + render_array_with_columns(items, nested_columns, available_width, pagination, false) } Value::Object(map) => render_object_with_columns(map, nested_columns, available_width), other => (format!("{}\n", format_value(other)), RenderNotes::default()), diff --git a/cli-engine/src/output/human/columns.rs b/cli-engine/src/output/human/columns.rs index 38a34ac..77f5443 100644 --- a/cli-engine/src/output/human/columns.rs +++ b/cli-engine/src/output/human/columns.rs @@ -71,57 +71,65 @@ pub(crate) fn column_is_all_numeric(items: &[Value], field: &str) -> bool { saw_number } -/// Chooses how many leading columns (priority order, most important first), -/// each contributing at least `min_widths[i]`, fit in `available_width` — so -/// lower-priority trailing columns can be dropped when the terminal is too -/// narrow for all of them. `min_widths[i]` should be the column's header -/// length for a column that can still shrink, or its full natural width for -/// one that can't (e.g. `no_truncate`) — using a shrinkable column's header -/// length here lets it still be counted as fitting even though its eventual -/// rendered width may be larger. Always keeps at least one column, even if -/// it alone exceeds `available_width`. -pub(crate) fn columns_fitting_width(min_widths: &[usize], available_width: usize) -> usize { - let mut used = 0_usize; - let mut kept = 0_usize; - for (index, &min_width) in min_widths.iter().enumerate() { - let gutter = if index == 0 { 0 } else { COLUMN_GUTTER }; - let next_used = used + gutter + min_width; - if next_used > available_width && kept > 0 { - break; +/// Chooses which column indexes fit in `available_width` — preferring to drop +/// the lowest-priority (highest-index) non-essential columns first when the +/// terminal is too narrow for all of them. `min_widths[i]` should be the +/// column's header length for a column that can still shrink, or its full +/// natural width for one that can't (e.g. `no_truncate`) — using a +/// shrinkable column's header length here lets it still be counted as +/// fitting even though its eventual rendered width may be larger. +/// `essential[i]` exempts that column from being dropped at all, even if it +/// alone exceeds `available_width`; at least one column always survives +/// regardless of essential-ness. +pub(crate) fn columns_fitting_width( + min_widths: &[usize], + essential: &[bool], + available_width: usize, +) -> Vec { + let mut kept: Vec = (0..min_widths.len()).collect(); + while kept.len() > 1 && total_width(&kept, min_widths) > available_width { + match kept.iter().rposition(|&index| !essential[index]) { + Some(position) => { + kept.remove(position); + } + None => break, } - used = next_used; - kept += 1; } kept } +/// Sum of `widths` at `indices` plus the gutters between them. +fn total_width(indices: &[usize], widths: &[usize]) -> usize { + let gutters = COLUMN_GUTTER * indices.len().saturating_sub(1); + gutters + indices.iter().map(|&index| widths[index]).sum::() +} + /// Fits `natural` (fully-untruncated) column widths into `available_width`. /// -/// `no_truncate` columns are never shrunk (they keep their natural width -/// unconditionally — that's the whole point of the flag) and their width is -/// reserved out of the budget up front. The remaining columns are never -/// shrunk below their header length, and share whatever budget is left -/// beyond that, smallest-need-first, so a column that wants only a little -/// gets exactly that instead of an equal-but-wasteful split. +/// Columns where `fixed[i]` is true (a `no_truncate` column, an essential +/// column, or every column at once when the caller disabled truncation +/// entirely) are never shrunk — they keep their natural width unconditionally +/// — and their width is reserved out of the budget up front. The remaining +/// columns are never shrunk below their header length, and share whatever +/// budget is left beyond that, smallest-need-first, so a column that wants +/// only a little gets exactly that instead of an equal-but-wasteful split. /// -/// Returns the fitted widths and whether any truncatable column ended up +/// Returns the fitted widths and whether any non-fixed column ended up /// narrower than its natural width (i.e. some cell will actually be cut). pub(crate) fn fit_column_widths( headers: &[usize], natural: &[usize], - no_truncate: &[bool], + fixed: &[bool], available_width: usize, ) -> (Vec, bool) { let mut widths = natural.to_vec(); - let truncatable: Vec = (0..no_truncate.len()) - .filter(|&index| !no_truncate[index]) - .collect(); + let truncatable: Vec = (0..fixed.len()).filter(|&index| !fixed[index]).collect(); if truncatable.is_empty() { return (widths, false); } let gutters = COLUMN_GUTTER * headers.len().saturating_sub(1); - let reserved: usize = (0..no_truncate.len()) - .filter(|&index| no_truncate[index]) + let reserved: usize = (0..fixed.len()) + .filter(|&index| fixed[index]) .map(|index| natural[index]) .sum(); let budget = available_width diff --git a/cli-engine/src/output/human/mod.rs b/cli-engine/src/output/human/mod.rs index 70f1051..3a3d854 100644 --- a/cli-engine/src/output/human/mod.rs +++ b/cli-engine/src/output/human/mod.rs @@ -3,6 +3,7 @@ use std::{ sync::{Arc, OnceLock, RwLock}, }; +use serde::Serialize; use serde_json::Value; use super::Envelope; @@ -39,21 +40,12 @@ pub enum Alignment { /// /// Column order is a priority order, most important first: table rendering /// keeps this order on screen, and when the terminal is too narrow to show -/// every column, the lowest-priority (trailing) columns are hidden first. Put -/// the column a reader most needs — usually an id or name — first. +/// every column, the lowest-priority (trailing) non-essential columns are +/// hidden first — see [`essential`](TableColumn::essential) for a way to +/// exempt a column from hiding entirely. Put the column a reader most +/// needs — usually an id or name — first. /// -/// This declared order is only the *fallback* — whenever a `--fields`/ -/// `default_fields` selection is given, its order wins instead (see -/// [`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. +/// Construct with [`TableColumn::new`], then modify with builder methods. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub struct TableColumn { @@ -71,6 +63,10 @@ pub struct TableColumn { /// values). Use this for values that are useless when cut short, such as /// URLs. pub no_truncate: bool, + /// When true, this column is never hidden *or* shrunk to fit the + /// terminal width, even if there isn't room for it — see + /// [`TableColumn::essential`]. + pub essential: 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 @@ -89,6 +85,7 @@ impl TableColumn { field: field.into(), header: header.into(), no_truncate: false, + essential: false, nested: None, align: Alignment::Left, } @@ -102,6 +99,19 @@ impl TableColumn { self } + /// Marks this column as never hidden *or* shrunk to fit the terminal + /// width, even when the terminal is too narrow for it alongside the + /// other surviving columns (still capped at `NO_TRUNCATE_MAX_WIDTH` to + /// bound pathologically long values). Use this for a column a view is + /// useless without. If every remaining column is essential and they still + /// don't fit together, the row overflows the terminal rather than losing or + /// shortening any of them. + #[must_use] + pub fn essential(mut self, value: bool) -> Self { + self.essential = value; + 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 @@ -301,38 +311,36 @@ pub fn global_human_view_registry_snapshot() -> HumanViewRegistry { /// Renders an envelope using generic human output. /// -/// There's no field-selection concept at this entry point, so a no-view -/// array/object falls back to alphabetical key order — use -/// [`render_human_with_registry_selected`] when a `--fields`/`default_fields` -/// value is available, so its order can drive column order too. +/// There's no field-selection or `TableColumn` concept at this entry point, +/// so an array/object falls back to alphabetical key order — use +/// [`preview_human_view`] instead to test a view's own column order and +/// width-fitting behavior against fixture data. #[must_use] pub fn render_human(envelope: &Envelope) -> String { - render_human_with_view(envelope, None, "") -} - -/// Renders an envelope using a human view registry. -#[must_use] -pub fn render_human_with_registry(envelope: &Envelope, registry: &HumanViewRegistry) -> String { - let system = envelope - .metadata - .as_ref() - .map(|metadata| metadata.system.as_str()) - .unwrap_or_default(); - render_human_with_registry_for_schema(envelope, registry, system) + render_human_with_view(envelope, None, "", false) } -/// Renders an envelope using registry entries for a specific schema id. +/// Renders `data` through `columns` the way the engine's default human view +/// would, without constructing an [`Envelope`] or running the CLI — the +/// right-sized tool for a command module's own tests to check a +/// [`TableColumn`] list renders sensibly (column order, alignment, +/// truncation, nested tables) against fixture data. /// -/// Shows every column of the registered view. Use -/// [`render_human_with_registry_selected`] to narrow the columns to a field -/// selection. +/// This is intentionally the *only* entry point into column-aware human +/// rendering exposed outside the crate: the lower-level renderers it +/// delegates to are free to keep changing shape as rendering behavior +/// evolves (as happened when `essential`/explicit-`--fields` support was +/// added) without that becoming a breaking change for every command +/// module's tests. It always renders as a default view would — no +/// `--fields` selection, normal width-based hiding/truncation — since +/// that's how a view's own columns render on their own; there's no next- +/// steps footer or pagination summary either, since there's no `Envelope` +/// carrying that data. Use [`render_human`] instead to test next-step/fix/ +/// error formatting at the envelope level. #[must_use] -pub fn render_human_with_registry_for_schema( - envelope: &Envelope, - registry: &HumanViewRegistry, - schema_id: &str, -) -> String { - render_human_with_registry_selected(envelope, registry, schema_id, "") +pub fn preview_human_view(data: impl Serialize, columns: &[TableColumn]) -> String { + let envelope = Envelope::success(data, ""); + render_human_with_view(&envelope, Some(columns), "", false) } /// Renders an envelope using a registered view, narrowed to `fields`. @@ -341,12 +349,20 @@ pub fn render_human_with_registry_for_schema( /// string, `all`, or `*` keeps every column; otherwise only the view columns /// whose `field` is listed are shown. A custom view renderer receives the full /// data and ignores `fields`. +/// +/// `fields_explicit` should be `true` only when `fields` came from a user- +/// typed `--fields` flag rather than a command's `default_fields` fallback — +/// it disables width-based column hiding *and* truncation entirely for the +/// selected columns, since the user named exactly what they want to see; the +/// row overflows the terminal rather than losing or shortening a column. +/// `NO_TRUNCATE_MAX_WIDTH` still caps a pathologically long value either way. #[must_use] -pub fn render_human_with_registry_selected( +pub(crate) fn render_human_with_registry_selected( envelope: &Envelope, registry: &HumanViewRegistry, schema_id: &str, fields: &str, + fields_explicit: bool, ) -> String { if let Some(error) = &envelope.error { return format!("Error: {}\n", error.message); @@ -361,9 +377,9 @@ pub fn render_human_with_registry_selected( match registry.columns(schema_id) { Some(columns) => { let selected = select_columns(columns, fields); - render_human_with_view(envelope, Some(&selected), fields) + render_human_with_view(envelope, Some(&selected), fields, fields_explicit) } - None => render_human_with_view(envelope, None, fields), + None => render_human_with_view(envelope, None, fields, fields_explicit), } } @@ -396,11 +412,17 @@ fn select_columns(columns: &[TableColumn], fields: &str) -> Vec { /// `columns` is `None`, to give the dynamically-derived, no-view column /// catalog the same field selection and order a view would have gotten. Pass /// `""` when no field-selection value is available. +/// +/// `fields_explicit` carries the same meaning as in +/// [`render_human_with_registry_selected`]: pass `true` only when `fields` +/// came from a user-typed `--fields` flag, to disable width-based hiding and +/// truncation for these columns. #[must_use] -pub fn render_human_with_view( +pub(crate) fn render_human_with_view( envelope: &Envelope, columns: Option<&[TableColumn]>, fields: &str, + fields_explicit: bool, ) -> String { // Errors render on their own; success output gets the data body plus, when // present, a "Next steps:" footer built from the envelope's next_actions @@ -423,6 +445,7 @@ pub fn render_human_with_view( fields, available_width, envelope.pagination.as_ref(), + fields_explicit, ), }; // Footers are appended in place: the common no-footer path leaves `body` diff --git a/cli-engine/src/output/human/tests.rs b/cli-engine/src/output/human/tests.rs deleted file mode 100644 index 476e16e..0000000 --- a/cli-engine/src/output/human/tests.rs +++ /dev/null @@ -1,978 +0,0 @@ -use serde_json::{Value, json}; - -use super::body::{ - NO_TRUNCATE_MAX_WIDTH, render_array, render_array_with_columns, render_object_with_columns, -}; -use super::columns::{dynamic_columns, fit_column_widths}; -use super::value_format::{ - format_value, resolve_field_parent, resolve_field_path, resolve_nested_pagination, -}; -use super::{ - Alignment, HumanViewDef, HumanViewRegistry, TableColumn, render_human, - render_human_with_registry_selected, render_human_with_view, select_columns, -}; -use crate::output::{Envelope, NextAction, NextActionParam, PaginationMeta}; - -#[test] -fn format_plain_value_round_trips_a_bare_string_verbatim() { - // No quoting/escaping — the exact convention `raw_output` bypass - // relies on to render a `CommandResult` string byte-for-byte. - assert_eq!( - super::value_format::format_plain_value(&Value::String("some\nverbatim\ntext".to_owned())), - "some\nverbatim\ntext" - ); -} - -#[test] -fn human_output_appends_next_steps_footer() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") - .with_next_actions(vec![NextAction::new( - "domain purchase --quote-token --agree --confirm", - "Register at the quoted price", - )]); - let out = render_human(&envelope); - // Data still renders as before… - assert!(out.contains("domain: example.com"), "{out}"); - // …followed by a Next steps footer with the command and its description. - assert!(out.contains("\nNext steps:\n"), "{out}"); - assert!( - out.contains("domain purchase --quote-token --agree --confirm"), - "{out}" - ); - assert!(out.contains("Register at the quoted price"), "{out}"); -} - -#[test] -fn human_output_substitutes_known_next_action_params() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") - .with_next_actions(vec![ - NextAction::new( - "domain purchase --quote-token --agree --confirm", - "Register at the quoted price", - ) - .with_param("quote-token", NextActionParam::value("abc-123")), - ]); - let out = render_human(&envelope); - assert!( - out.contains("domain purchase --quote-token abc-123 --agree --confirm"), - "{out}" - ); - assert!(!out.contains(""), "{out}"); -} - -#[test] -fn human_output_leaves_placeholder_without_a_known_value() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") - .with_next_actions(vec![ - NextAction::new("domain quote ", "Price a registration") - .with_param("domain", NextActionParam::required()), - ]); - let out = render_human(&envelope); - assert!(out.contains("domain quote "), "{out}"); -} - -#[test] -fn human_output_has_no_footer_without_next_actions() { - let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain"); - let out = render_human(&envelope); - assert!(out.contains("domain: example.com"), "{out}"); - assert!( - !out.contains("Next steps"), - "no footer when there are no actions: {out}" - ); -} - -#[test] -fn error_output_has_no_next_steps_footer() { - // An error envelope carries no next_actions and must render only the error. - let envelope = Envelope::error("ERROR", "boom", "domain"); - let out = render_human(&envelope); - assert!(out.starts_with("Error:"), "{out}"); - assert!(!out.contains("Next steps"), "{out}"); - assert!(!out.contains("Fix:"), "{out}"); -} - -#[test] -fn error_output_appends_fix_line() { - let envelope = - Envelope::error("AUTH_REQUIRED", "not logged in", "auth").with_fix("Run auth login"); - let out = render_human(&envelope); - assert_eq!(out, "Error: not logged in\nFix: Run auth login\n"); -} - -#[test] -fn no_truncate_column_keeps_long_values_intact() { - let long_url = "https://example.com/legal/agreements/registration-agreement-v2"; - assert!(long_url.len() > 40, "fixture must exceed the default cap"); - let items = vec![json!({ "title": long_url, "url": long_url })]; - let columns = vec![ - // Declared first (higher priority) so it survives hide-before- - // truncate rather than the lower-priority title column - // absorbing truncation instead — with only two columns, any - // truncation now cascades to hiding the lower-priority one. - TableColumn::new("url", "URL").no_truncate(true), - TableColumn::new("title", "Title"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 80, None); - - assert!( - out.contains(long_url), - "no_truncate column must keep the full value: {out}" - ); - assert!( - !out.contains("..."), - "hiding the lower-priority column avoided any truncation: {out}" - ); - assert_eq!( - notes.hidden_columns, - vec!["Title".to_owned()], - "the lower-priority truncatable column is hidden rather than shown truncated: {out}" - ); -} - -#[test] -fn no_truncate_column_still_caps_pathologically_long_values() { - let huge_value = "x".repeat(NO_TRUNCATE_MAX_WIDTH * 2); - let items = vec![json!({ "url": huge_value })]; - let columns = vec![TableColumn::new("url", "URL").no_truncate(true)]; - - let (out, _notes) = render_array_with_columns(&items, &columns, 80, None); - - assert!( - out.contains("..."), - "values far beyond the no_truncate cap should still be truncated: {out}" - ); - assert!( - !out.contains(&huge_value), - "the full pathological value should not be rendered verbatim: {out}" - ); -} - -#[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"; - let items = vec![json!({ "field": "short" })]; - let columns = vec![TableColumn::new("field", long_header)]; - - // Deliberately far narrower than the header: the header must still - // render in full even though the row ends up wider than the terminal. - let (out, _notes) = render_array_with_columns(&items, &columns, 10, None); - let header_line = out.lines().next().expect("header line"); - let separator_line = out.lines().nth(1).expect("separator line"); - - assert_eq!( - header_line.len(), - separator_line.len(), - "header and separator must stay aligned even when the header alone exceeds the terminal: {out}" - ); - assert!( - header_line.len() >= long_header.len(), - "header must not be cut short: {out}" - ); -} - -#[test] -fn wide_terminal_shows_full_values_without_truncation() { - let description = "a description that is well past the old forty-character cap"; - assert!(description.len() > 40, "fixture must exceed the old cap"); - let items = vec![json!({ "id": "1", "description": description })]; - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("description", "Description"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 200, None); - - assert!( - !notes.truncated, - "plenty of room, nothing to shorten: {out}" - ); - assert!(notes.hidden_columns.is_empty(), "{out}"); - assert!(out.contains(description), "{out}"); - assert!(!out.contains("..."), "{out}"); -} - -#[test] -fn narrow_terminal_truncates_and_reports_it() { - // A single column whose value is far longer than the terminal - // allows: there's nothing else to hide (hide-before-truncate has no - // lower-priority column to drop), so truncation is the only option - // and it must still be reported. - let description = "a description that is well past the old forty-character cap"; - let items = vec![json!({ "description": description })]; - let columns = vec![TableColumn::new("description", "Description")]; - - let (out, notes) = render_array_with_columns(&items, &columns, 20, None); - - assert!( - notes.truncated, - "narrow terminal must shorten a cell: {out}" - ); - assert!( - notes.hidden_columns.is_empty(), - "only one column exists to begin with: {out}" - ); - assert!(out.contains("..."), "{out}"); -} - -#[test] -fn narrow_terminal_hides_columns_before_truncating_any_of_the_survivors() { - // Three equally-competing columns: at this width, showing all three - // (or even two) would require truncating every survivor a little. - // Hide-before-truncate should instead cascade down to the single - // highest-priority column and show it in full. - let items = vec![json!({ "a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5) })]; - let columns = vec![ - TableColumn::new("a", "A"), - TableColumn::new("b", "B"), - TableColumn::new("c", "C"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 10, None); - - assert!( - !notes.truncated, - "hiding B and C should leave A fully shown, untruncated: {out}" - ); - assert_eq!( - notes.hidden_columns, - vec!["B".to_owned(), "C".to_owned()], - "should cascade down to the single highest-priority column: {out}" - ); - assert!(!out.contains("..."), "{out}"); -} - -#[test] -fn overflow_hides_lowest_priority_columns_first() { - let items = vec![json!({ - "id": "1", - "name": "acme", - "status": "active", - "created_at": "2026-01-01", - })]; - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - TableColumn::new("created_at", "Created At"), - ]; - - let (out, notes) = render_array_with_columns(&items, &columns, 10, None); - - assert_eq!( - notes.hidden_columns, - vec!["Status".to_owned(), "Created At".to_owned()], - "lowest-priority (trailing) columns are dropped first: {out}" - ); - let header_line = out.lines().next().expect("header line"); - assert!(header_line.contains("ID"), "{out}"); - assert!(header_line.contains("NAME"), "{out}"); - assert!(!header_line.contains("STATUS"), "{out}"); - assert!(!header_line.contains("CREATED"), "{out}"); -} - -#[test] -fn render_human_with_view_reports_hidden_columns_in_footer() { - let envelope = Envelope::success( - json!([{ - "id": "1", - "name": "acme", - "status": "active", - "region": "us-west", - "created_at": "2026-01-01", - "updated_at": "2026-01-02", - "notes": "irrelevant, lowest priority", - }]), - "resource", - ); - let columns = 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"), - // Deliberately long enough that, combined with the columns above, - // it can't fit alongside them at the fallback 80-column width. - TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"), - ]; - - // In test runs stdout is not a TTY, so `terminal_width()` deterministically - // falls back to 80 — these headers don't all fit at that width. - let out = render_human_with_view(&envelope, Some(&columns), ""); - - assert!(out.contains("hidden to fit the display width"), "{out}"); - assert!( - out.contains("This Is An Extremely Long Trailing Column Header"), - "{out}" - ); - assert!(out.contains("--fields"), "{out}"); - assert!(out.contains("--json"), "{out}"); -} - -#[test] -fn select_columns_orders_by_requested_fields_not_declared_order() { - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - ]; - - let selected = select_columns(&columns, "status,id"); - - assert_eq!( - selected - .iter() - .map(|c| c.field.as_str()) - .collect::>(), - vec!["status", "id"], - "order should follow the requested fields, not declaration order" - ); -} - -#[test] -fn select_columns_dedupes_and_skips_unknown_fields() { - let columns = vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - ]; - - let selected = select_columns(&columns, "status,bogus,status,id"); - - assert_eq!( - selected - .iter() - .map(|c| c.field.as_str()) - .collect::>(), - vec!["status", "id"], - "duplicates collapse to first occurrence; unknown fields are dropped" - ); -} - -#[test] -fn dynamic_columns_orders_by_requested_fields() { - let columns = dynamic_columns("price1Year,domain", || { - vec![ - "domain".to_owned(), - "currency".to_owned(), - "price1Year".to_owned(), - ] - }); - - assert_eq!( - columns.iter().map(|c| c.field.as_str()).collect::>(), - vec!["price1Year", "domain"] - ); -} - -#[test] -fn dynamic_columns_falls_back_to_alphabetical_without_fields() { - let columns = dynamic_columns("", || vec!["currency".to_owned(), "domain".to_owned()]); - - assert_eq!( - columns.iter().map(|c| c.field.as_str()).collect::>(), - vec!["currency", "domain"], - "no fields signal at all: alphabetical is the only order available" - ); -} - -#[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 - // no registered view whose default_fields lists `domain` first must - // not silently reorder it after `currency` just because "c" < "d". - let envelope = Envelope::success( - json!([{ "domain": "example.com", "currency": "USD", "price1Year": "12.99" }]), - "domain:suggest", - ); - let registry = HumanViewRegistry::new(); - - let rendered = render_human_with_registry_selected( - &envelope, - ®istry, - "domain:suggest", - "domain,price1Year,currency", - ); - - let header_line = rendered.lines().next().expect("header line"); - assert!(header_line.contains("DOMAIN"), "{rendered}"); - let domain_pos = header_line.find("DOMAIN").expect("domain header"); - let price_pos = header_line.find("PRICE1YEAR").expect("price1Year header"); - let currency_pos = header_line.find("CURRENCY").expect("currency header"); - assert!( - domain_pos < price_pos && price_pos < currency_pos, - "expected DOMAIN, PRICE1YEAR, CURRENCY in that order: {header_line}" - ); -} - -#[test] -fn registered_view_rendering_follows_requested_field_order() { - let mut registry = HumanViewRegistry::new(); - registry.register(HumanViewDef::new( - "things", - vec![ - TableColumn::new("id", "ID"), - TableColumn::new("name", "Name"), - TableColumn::new("status", "Status"), - ], - )); - let envelope = Envelope::success( - json!([{ "id": "1", "name": "acme", "status": "active" }]), - "things", - ); - - let rendered = render_human_with_registry_selected(&envelope, ®istry, "things", "status,id"); - - let header_line = rendered.lines().next().expect("header line"); - assert!(!header_line.contains("NAME"), "{rendered}"); - let status_pos = header_line.find("STATUS").expect("status header"); - let id_pos = header_line.find("ID").expect("id header"); - assert!( - status_pos < id_pos, - "expected STATUS before ID per the requested field order: {header_line}" - ); -} - -#[test] -fn fit_column_widths_gives_small_wants_priority_over_larger_ones() { - // Regression: a naive `leftover / remaining` split can floor a small - // want to zero (denying a column that needed only 1 more char) - // while a much larger want absorbs that same unit and stays - // truncated anyway — net truncation is identical, but a column that - // could have been fully satisfied wasn't. - let headers = [1, 1, 1]; - let natural = [2, 2, 6]; // wants: 1, 1, 5 - let no_truncate = [false, false, false]; - - let (widths, truncated) = fit_column_widths(&headers, &natural, &no_truncate, 8); - - assert_eq!( - widths[0], natural[0], - "a column that only wanted 1 more char should get it in full: {widths:?}" - ); - assert!(truncated, "budget is still too small overall: {widths:?}"); -} - -#[test] -fn overflow_hiding_accounts_for_no_truncate_columns_true_width() { - // Regression: deciding what to hide from header length alone - // under-counts a `no_truncate` column (it never shrinks below its - // natural width), which could keep a short-header trailing column - // that would never have fit anyway — overflowing when hiding it - // would have let the row fit. - let url = "x".repeat(40); - let items = vec![json!({ "url": url, "notes": "irrelevant, lowest priority" })]; - let columns = vec![ - TableColumn::new("url", "URL").no_truncate(true), - TableColumn::new("notes", "X"), - ]; - - // Exactly enough room for the URL alone (40 chars), not enough for - // the URL plus even a 1-char trailing column and its gutter (43). - let (out, notes) = render_array_with_columns(&items, &columns, 42, None); - - assert_eq!( - notes.hidden_columns, - vec!["X".to_owned()], - "the trailing column must be hidden so the no_truncate URL column fits: {out}" - ); - let header_line = out.lines().next().expect("header line"); - assert!( - header_line.len() <= 42, - "must not overflow once the trailing column is hidden: {out}" - ); -} - -#[test] -fn render_array_with_columns_handles_no_columns_gracefully() { - // A view's `--fields` filtered out every declared column: nothing to - // build a table from, so this must report "no results" rather than - // a blank header/rows table. - let items = vec![json!({ "a": "1" })]; - let (out, notes) = render_array_with_columns(&items, &[], 80, None); - - assert_eq!(out, "(no results)\n"); - assert!(!notes.truncated, "{out}"); - 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 - // keys to derive columns from — same "no columns" case as above, - // reached through the no-view path instead. - let items = vec![json!({}), json!({})]; - let (out, notes) = render_array(&items, "", 80, None); - - 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 resolve_field_parent_returns_parent_object_for_dotted_and_bare_fields() { - let map = json!({ - "parameters": { "items": [], "total": 2 }, - "owner": "not-an-object", - }); - let map = map.as_object().expect("object fixture"); - - assert_eq!( - resolve_field_parent(map, "parameters.items"), - map.get("parameters").and_then(Value::as_object) - ); - assert_eq!( - resolve_field_parent(map, "items"), - Some(map), - "a field with no dot has the object being rendered as its own parent" - ); - assert_eq!( - resolve_field_parent(map, "owner.name"), - None, - "intermediate value is a string, not an object" - ); - assert_eq!(resolve_field_parent(map, "missing.items"), None); -} - -#[test] -fn resolve_nested_pagination_deserializes_a_pagination_meta_shaped_sibling() { - let parent = json!({ - "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, - }); - let parent = parent.as_object().expect("object fixture"); - - let meta = resolve_nested_pagination(parent).expect("pagination sibling present"); - assert_eq!( - meta, - PaginationMeta { - total: 26, - offset: 0, - limit: 2, - count: 2, - has_more: true, - } - ); -} - -#[test] -fn resolve_nested_pagination_is_none_when_the_sibling_is_absent_or_malformed() { - let no_sibling = json!({ "items": [] }); - assert_eq!( - resolve_nested_pagination(no_sibling.as_object().expect("object fixture")), - None, - "no pagination field at all" - ); - - let wrong_shape = json!({ "pagination": { "total": 26 } }); - assert_eq!( - resolve_nested_pagination(wrong_shape.as_object().expect("object fixture")), - None, - "missing required PaginationMeta fields fails to deserialize" - ); - - let not_an_object = json!({ "pagination": "26 total" }); - assert_eq!( - resolve_nested_pagination(not_an_object.as_object().expect("object fixture")), - None, - "pagination field present but not object-shaped" - ); -} - -#[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"}, - ], - }, - }); - 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}"); - assert!( - out.contains("(2 rows)"), - "no pagination sibling means the plain row-count footer, unchanged: {out}" - ); -} - -#[test] -fn nested_array_with_pagination_sibling_renders_pagination_style_footer() { - let map = json!({ - "name": "getPets", - "parameters": { - "items": [ - {"name": "limit", "in": "query"}, - {"name": "id", "in": "path"}, - ], - "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, - }, - }); - 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.contains("(2 of 26 rows, offset 0, limit 2)"), - "nested table should reuse the pagination sibling's PaginationMeta facts: {out}" - ); -} - -#[test] -fn nested_array_without_pagination_sibling_keeps_the_plain_row_count_footer() { - let map = json!({ "items": [{"name": "limit"}] }); - let columns = - vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert!( - out.contains("(1 rows)"), - "no pagination sibling means no opt-in — behavior is unchanged: {out}" - ); -} - -#[test] -fn nested_array_with_malformed_pagination_sibling_keeps_the_plain_row_count_footer() { - let map = json!({ "items": [{"name": "limit"}], "pagination": { "total": "not-a-number" } }); - let columns = - vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; - - let (out, _notes) = - render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); - - assert!( - out.contains("(1 rows)"), - "a pagination sibling that fails to deserialize degrades to the plain footer: {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}" - ); - 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] -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/cli-engine/src/output/human/tests/alignment.rs b/cli-engine/src/output/human/tests/alignment.rs new file mode 100644 index 0000000..73c04ae --- /dev/null +++ b/cli-engine/src/output/human/tests/alignment.rs @@ -0,0 +1,87 @@ +use serde_json::json; + +use crate::output::human::body::{render_array, render_array_with_columns}; +use crate::output::human::{Alignment, TableColumn}; + +#[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, false); + 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, false); + 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 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, false); + 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, false); + 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, false); + let header_line = out.lines().next().expect("header line"); + + assert!(header_line.starts_with("NOTE"), "{header_line}"); +} diff --git a/cli-engine/src/output/human/tests/field_selection.rs b/cli-engine/src/output/human/tests/field_selection.rs new file mode 100644 index 0000000..8116774 --- /dev/null +++ b/cli-engine/src/output/human/tests/field_selection.rs @@ -0,0 +1,134 @@ +use serde_json::json; + +use crate::output::Envelope; +use crate::output::human::columns::dynamic_columns; +use crate::output::human::{ + HumanViewDef, HumanViewRegistry, TableColumn, render_human_with_registry_selected, + select_columns, +}; + +#[test] +fn select_columns_orders_by_requested_fields_not_declared_order() { + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + ]; + + let selected = select_columns(&columns, "status,id"); + + assert_eq!( + selected + .iter() + .map(|c| c.field.as_str()) + .collect::>(), + vec!["status", "id"], + "order should follow the requested fields, not declaration order" + ); +} + +#[test] +fn select_columns_dedupes_and_skips_unknown_fields() { + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + ]; + + let selected = select_columns(&columns, "status,bogus,status,id"); + + assert_eq!( + selected + .iter() + .map(|c| c.field.as_str()) + .collect::>(), + vec!["status", "id"], + "duplicates collapse to first occurrence; unknown fields are dropped" + ); +} + +#[test] +fn dynamic_columns_orders_by_requested_fields() { + let columns = dynamic_columns("price1Year,domain", || { + vec![ + "domain".to_owned(), + "currency".to_owned(), + "price1Year".to_owned(), + ] + }); + + assert_eq!( + columns.iter().map(|c| c.field.as_str()).collect::>(), + vec!["price1Year", "domain"] + ); +} + +#[test] +fn dynamic_columns_falls_back_to_alphabetical_without_fields() { + let columns = dynamic_columns("", || vec!["currency".to_owned(), "domain".to_owned()]); + + assert_eq!( + columns.iter().map(|c| c.field.as_str()).collect::>(), + vec!["currency", "domain"], + "no fields signal at all: alphabetical is the only order available" + ); +} + +#[test] +fn no_view_array_rendering_follows_requested_field_order() { + // Reproduces the real-world `domain suggest` symptom: a command with + // no registered view whose default_fields lists `domain` first must + // not silently reorder it after `currency` just because "c" < "d". + let envelope = Envelope::success( + json!([{ "domain": "example.com", "currency": "USD", "price1Year": "12.99" }]), + "domain:suggest", + ); + let registry = HumanViewRegistry::new(); + + let rendered = render_human_with_registry_selected( + &envelope, + ®istry, + "domain:suggest", + "domain,price1Year,currency", + true, + ); + + let header_line = rendered.lines().next().expect("header line"); + assert!(header_line.contains("DOMAIN"), "{rendered}"); + let domain_pos = header_line.find("DOMAIN").expect("domain header"); + let price_pos = header_line.find("PRICE1YEAR").expect("price1Year header"); + let currency_pos = header_line.find("CURRENCY").expect("currency header"); + assert!( + domain_pos < price_pos && price_pos < currency_pos, + "expected DOMAIN, PRICE1YEAR, CURRENCY in that order: {header_line}" + ); +} + +#[test] +fn registered_view_rendering_follows_requested_field_order() { + let mut registry = HumanViewRegistry::new(); + registry.register(HumanViewDef::new( + "things", + vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + ], + )); + let envelope = Envelope::success( + json!([{ "id": "1", "name": "acme", "status": "active" }]), + "things", + ); + + let rendered = + render_human_with_registry_selected(&envelope, ®istry, "things", "status,id", true); + + let header_line = rendered.lines().next().expect("header line"); + assert!(!header_line.contains("NAME"), "{rendered}"); + let status_pos = header_line.find("STATUS").expect("status header"); + let id_pos = header_line.find("ID").expect("id header"); + assert!( + status_pos < id_pos, + "expected STATUS before ID per the requested field order: {header_line}" + ); +} diff --git a/cli-engine/src/output/human/tests/footer.rs b/cli-engine/src/output/human/tests/footer.rs new file mode 100644 index 0000000..7a83c12 --- /dev/null +++ b/cli-engine/src/output/human/tests/footer.rs @@ -0,0 +1,92 @@ +use serde_json::{Value, json}; + +use crate::output::human::render_human; +use crate::output::human::value_format::format_plain_value; +use crate::output::{Envelope, NextAction, NextActionParam}; + +#[test] +fn format_plain_value_round_trips_a_bare_string_verbatim() { + // No quoting/escaping — the exact convention `raw_output` bypass + // relies on to render a `CommandResult` string byte-for-byte. + assert_eq!( + format_plain_value(&Value::String("some\nverbatim\ntext".to_owned())), + "some\nverbatim\ntext" + ); +} + +#[test] +fn human_output_appends_next_steps_footer() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![NextAction::new( + "domain purchase --quote-token --agree --confirm", + "Register at the quoted price", + )]); + let out = render_human(&envelope); + // Data still renders as before… + assert!(out.contains("domain: example.com"), "{out}"); + // …followed by a Next steps footer with the command and its description. + assert!(out.contains("\nNext steps:\n"), "{out}"); + assert!( + out.contains("domain purchase --quote-token --agree --confirm"), + "{out}" + ); + assert!(out.contains("Register at the quoted price"), "{out}"); +} + +#[test] +fn human_output_substitutes_known_next_action_params() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![ + NextAction::new( + "domain purchase --quote-token --agree --confirm", + "Register at the quoted price", + ) + .with_param("quote-token", NextActionParam::value("abc-123")), + ]); + let out = render_human(&envelope); + assert!( + out.contains("domain purchase --quote-token abc-123 --agree --confirm"), + "{out}" + ); + assert!(!out.contains(""), "{out}"); +} + +#[test] +fn human_output_leaves_placeholder_without_a_known_value() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain") + .with_next_actions(vec![ + NextAction::new("domain quote ", "Price a registration") + .with_param("domain", NextActionParam::required()), + ]); + let out = render_human(&envelope); + assert!(out.contains("domain quote "), "{out}"); +} + +#[test] +fn human_output_has_no_footer_without_next_actions() { + let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain"); + let out = render_human(&envelope); + assert!(out.contains("domain: example.com"), "{out}"); + assert!( + !out.contains("Next steps"), + "no footer when there are no actions: {out}" + ); +} + +#[test] +fn error_output_has_no_next_steps_footer() { + // An error envelope carries no next_actions and must render only the error. + let envelope = Envelope::error("ERROR", "boom", "domain"); + let out = render_human(&envelope); + assert!(out.starts_with("Error:"), "{out}"); + assert!(!out.contains("Next steps"), "{out}"); + assert!(!out.contains("Fix:"), "{out}"); +} + +#[test] +fn error_output_appends_fix_line() { + let envelope = + Envelope::error("AUTH_REQUIRED", "not logged in", "auth").with_fix("Run auth login"); + let out = render_human(&envelope); + assert_eq!(out, "Error: not logged in\nFix: Run auth login\n"); +} diff --git a/cli-engine/src/output/human/tests/mod.rs b/cli-engine/src/output/human/tests/mod.rs new file mode 100644 index 0000000..bd7b428 --- /dev/null +++ b/cli-engine/src/output/human/tests/mod.rs @@ -0,0 +1,7 @@ +mod alignment; +mod field_selection; +mod footer; +mod nested; +mod registry; +mod value_format; +mod width_fitting; diff --git a/cli-engine/src/output/human/tests/nested.rs b/cli-engine/src/output/human/tests/nested.rs new file mode 100644 index 0000000..1672119 --- /dev/null +++ b/cli-engine/src/output/human/tests/nested.rs @@ -0,0 +1,267 @@ +use serde_json::json; + +use crate::output::Envelope; +use crate::output::human::body::render_object_with_columns; +use crate::output::human::value_format::format_value; +use crate::output::human::{TableColumn, render_human_with_view}; + +#[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"}, + ], + }, + }); + 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}"); + assert!( + out.contains("(2 rows)"), + "no pagination sibling means the plain row-count footer, unchanged: {out}" + ); +} + +#[test] +fn nested_array_with_pagination_sibling_renders_pagination_style_footer() { + let map = json!({ + "name": "getPets", + "parameters": { + "items": [ + {"name": "limit", "in": "query"}, + {"name": "id", "in": "path"}, + ], + "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, + }, + }); + 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.contains("(2 of 26 rows, offset 0, limit 2)"), + "nested table should reuse the pagination sibling's PaginationMeta facts: {out}" + ); +} + +#[test] +fn nested_array_without_pagination_sibling_keeps_the_plain_row_count_footer() { + let map = json!({ "items": [{"name": "limit"}] }); + let columns = + vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert!( + out.contains("(1 rows)"), + "no pagination sibling means no opt-in — behavior is unchanged: {out}" + ); +} + +#[test] +fn nested_array_with_malformed_pagination_sibling_keeps_the_plain_row_count_footer() { + let map = json!({ "items": [{"name": "limit"}], "pagination": { "total": "not-a-number" } }); + let columns = + vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])]; + + let (out, _notes) = + render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80); + + assert!( + out.contains("(1 rows)"), + "a pagination sibling that fails to deserialize degrades to the plain footer: {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}" + ); + 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. + 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), "", false); + + 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] +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. Rendering must stay the + // same flat `header: value` line a column with `nested: None` would have + // produced. + 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/cli-engine/src/output/human/tests/registry.rs b/cli-engine/src/output/human/tests/registry.rs new file mode 100644 index 0000000..2dff969 --- /dev/null +++ b/cli-engine/src/output/human/tests/registry.rs @@ -0,0 +1,58 @@ +use serde_json::json; + +use crate::output::human::{ + HumanViewDef, HumanViewRegistry, TableColumn, render_human_with_registry_selected, +}; +use crate::output::{Envelope, NextAction, NextActionParam}; + +// These test the registry's own dispatch contract (custom-renderer-vs-columns +// precedence, next-steps footer wrapping) rather than anything a command +// module configures per-command, so they stay as crate-internal unit tests +// against the pub(crate) renderers instead of `preview_human_view` — that +// function is scoped to the common "render my TableColumn view" case. + +#[test] +fn custom_human_output_appends_next_steps_footer() { + let mut registry = HumanViewRegistry::new(); + registry.register_func("shopping-cart", |_| "Cart: ready\n".to_owned()); + let envelope = + Envelope::success(json!({ "id": "cart-1" }), "shopping-cart").with_next_actions(vec![ + NextAction::new( + "shopping checkout complete --agree", + "Place the order", + ) + .with_param("cart-id", NextActionParam::value("cart-1")), + ]); + + let out = render_human_with_registry_selected(&envelope, ®istry, "shopping-cart", "", false); + + assert!(out.starts_with("Cart: ready\n"), "{out}"); + assert!(out.contains("\nNext steps:\n"), "{out}"); + assert!( + out.contains("shopping checkout complete cart-1 --agree"), + "{out}" + ); + assert!(out.contains("Place the order"), "{out}"); +} + +#[test] +fn human_view_registry_custom_renderer_wins_over_columns() { + let mut registry = HumanViewRegistry::new(); + registry.register(HumanViewDef { + schema_id: "things".to_owned(), + columns: vec![TableColumn::new("name", "Name")], + }); + registry.register_func("things", |data| { + format!( + "custom:{}\n", + data.get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + ) + }); + let envelope = Envelope::success(json!({"name": "alpha"}), "things"); + + let rendered = render_human_with_registry_selected(&envelope, ®istry, "things", "", false); + + assert_eq!(rendered, "custom:alpha\n"); +} diff --git a/cli-engine/src/output/human/tests/value_format.rs b/cli-engine/src/output/human/tests/value_format.rs new file mode 100644 index 0000000..b6ef5ce --- /dev/null +++ b/cli-engine/src/output/human/tests/value_format.rs @@ -0,0 +1,104 @@ +use serde_json::{Value, json}; + +use crate::output::PaginationMeta; +use crate::output::human::value_format::{ + resolve_field_parent, resolve_field_path, resolve_nested_pagination, +}; + +#[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 resolve_field_parent_returns_parent_object_for_dotted_and_bare_fields() { + let map = json!({ + "parameters": { "items": [], "total": 2 }, + "owner": "not-an-object", + }); + let map = map.as_object().expect("object fixture"); + + assert_eq!( + resolve_field_parent(map, "parameters.items"), + map.get("parameters").and_then(Value::as_object) + ); + assert_eq!( + resolve_field_parent(map, "items"), + Some(map), + "a field with no dot has the object being rendered as its own parent" + ); + assert_eq!( + resolve_field_parent(map, "owner.name"), + None, + "intermediate value is a string, not an object" + ); + assert_eq!(resolve_field_parent(map, "missing.items"), None); +} + +#[test] +fn resolve_nested_pagination_deserializes_a_pagination_meta_shaped_sibling() { + let parent = json!({ + "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true }, + }); + let parent = parent.as_object().expect("object fixture"); + + let meta = resolve_nested_pagination(parent).expect("pagination sibling present"); + assert_eq!( + meta, + PaginationMeta { + total: 26, + offset: 0, + limit: 2, + count: 2, + has_more: true, + } + ); +} + +#[test] +fn resolve_nested_pagination_is_none_when_the_sibling_is_absent_or_malformed() { + let no_sibling = json!({ "items": [] }); + assert_eq!( + resolve_nested_pagination(no_sibling.as_object().expect("object fixture")), + None, + "no pagination field at all" + ); + + let wrong_shape = json!({ "pagination": { "total": 26 } }); + assert_eq!( + resolve_nested_pagination(wrong_shape.as_object().expect("object fixture")), + None, + "missing required PaginationMeta fields fails to deserialize" + ); + + let not_an_object = json!({ "pagination": "26 total" }); + assert_eq!( + resolve_nested_pagination(not_an_object.as_object().expect("object fixture")), + None, + "pagination field present but not object-shaped" + ); +} diff --git a/cli-engine/src/output/human/tests/width_fitting.rs b/cli-engine/src/output/human/tests/width_fitting.rs new file mode 100644 index 0000000..e5ef2aa --- /dev/null +++ b/cli-engine/src/output/human/tests/width_fitting.rs @@ -0,0 +1,434 @@ +use serde_json::json; + +use crate::output::Envelope; +use crate::output::human::body::{ + NO_TRUNCATE_MAX_WIDTH, render_array, render_array_with_columns, render_object_with_columns, +}; +use crate::output::human::columns::fit_column_widths; +use crate::output::human::{TableColumn, render_human_with_view}; + +#[test] +fn no_truncate_column_keeps_long_values_intact() { + let long_url = "https://example.com/legal/agreements/registration-agreement-v2"; + assert!(long_url.len() > 40, "fixture must exceed the default cap"); + let items = vec![json!({ "title": long_url, "url": long_url })]; + let columns = vec![ + // Declared first (higher priority) so it survives hide-before- + // truncate rather than the lower-priority title column + // absorbing truncation instead — with only two columns, any + // truncation now cascades to hiding the lower-priority one. + TableColumn::new("url", "URL").no_truncate(true), + TableColumn::new("title", "Title"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 80, None, false); + + assert!( + out.contains(long_url), + "no_truncate column must keep the full value: {out}" + ); + assert!( + !out.contains("..."), + "hiding the lower-priority column avoided any truncation: {out}" + ); + assert_eq!( + notes.hidden_columns, + vec!["Title".to_owned()], + "the lower-priority truncatable column is hidden rather than shown truncated: {out}" + ); +} + +#[test] +fn no_truncate_column_still_caps_pathologically_long_values() { + let huge_value = "x".repeat(NO_TRUNCATE_MAX_WIDTH * 2); + let items = vec![json!({ "url": huge_value })]; + let columns = vec![TableColumn::new("url", "URL").no_truncate(true)]; + + let (out, _notes) = render_array_with_columns(&items, &columns, 80, None, false); + + assert!( + out.contains("..."), + "values far beyond the no_truncate cap should still be truncated: {out}" + ); + assert!( + !out.contains(&huge_value), + "the full pathological value should not be rendered verbatim: {out}" + ); +} + +#[test] +fn column_width_never_shrinks_below_a_long_header() { + let long_header = "A Very Long Header That Exceeds The Default Width Cap"; + let items = vec![json!({ "field": "short" })]; + let columns = vec![TableColumn::new("field", long_header)]; + + // Deliberately far narrower than the header: the header must still + // render in full even though the row ends up wider than the terminal. + let (out, _notes) = render_array_with_columns(&items, &columns, 10, None, false); + let header_line = out.lines().next().expect("header line"); + let separator_line = out.lines().nth(1).expect("separator line"); + + assert_eq!( + header_line.len(), + separator_line.len(), + "header and separator must stay aligned even when the header alone exceeds the terminal: {out}" + ); + assert!( + header_line.len() >= long_header.len(), + "header must not be cut short: {out}" + ); +} + +#[test] +fn wide_terminal_shows_full_values_without_truncation() { + let description = "a description that is well past the old forty-character cap"; + assert!(description.len() > 40, "fixture must exceed the old cap"); + let items = vec![json!({ "id": "1", "description": description })]; + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("description", "Description"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 200, None, false); + + assert!( + !notes.truncated, + "plenty of room, nothing to shorten: {out}" + ); + assert!(notes.hidden_columns.is_empty(), "{out}"); + assert!(out.contains(description), "{out}"); + assert!(!out.contains("..."), "{out}"); +} + +#[test] +fn narrow_terminal_truncates_and_reports_it() { + // A single column whose value is far longer than the terminal + // allows: there's nothing else to hide (hide-before-truncate has no + // lower-priority column to drop), so truncation is the only option + // and it must still be reported. + let description = "a description that is well past the old forty-character cap"; + let items = vec![json!({ "description": description })]; + let columns = vec![TableColumn::new("description", "Description")]; + + let (out, notes) = render_array_with_columns(&items, &columns, 20, None, false); + + assert!( + notes.truncated, + "narrow terminal must shorten a cell: {out}" + ); + assert!( + notes.hidden_columns.is_empty(), + "only one column exists to begin with: {out}" + ); + assert!(out.contains("..."), "{out}"); +} + +#[test] +fn narrow_terminal_hides_columns_before_truncating_any_of_the_survivors() { + // Three equally-competing columns: at this width, showing all three + // (or even two) would require truncating every survivor a little. + // Hide-before-truncate should instead cascade down to the single + // highest-priority column and show it in full. + let items = vec![json!({ "a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5) })]; + let columns = vec![ + TableColumn::new("a", "A"), + TableColumn::new("b", "B"), + TableColumn::new("c", "C"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 10, None, false); + + assert!( + !notes.truncated, + "hiding B and C should leave A fully shown, untruncated: {out}" + ); + assert_eq!( + notes.hidden_columns, + vec!["B".to_owned(), "C".to_owned()], + "should cascade down to the single highest-priority column: {out}" + ); + assert!(!out.contains("..."), "{out}"); +} + +#[test] +fn overflow_hides_lowest_priority_columns_first() { + let items = vec![json!({ + "id": "1", + "name": "acme", + "status": "active", + "created_at": "2026-01-01", + })]; + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + TableColumn::new("created_at", "Created At"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 10, None, false); + + assert_eq!( + notes.hidden_columns, + vec!["Status".to_owned(), "Created At".to_owned()], + "lowest-priority (trailing) columns are dropped first: {out}" + ); + let header_line = out.lines().next().expect("header line"); + assert!(header_line.contains("ID"), "{out}"); + assert!(header_line.contains("NAME"), "{out}"); + assert!(!header_line.contains("STATUS"), "{out}"); + assert!(!header_line.contains("CREATED"), "{out}"); +} + +#[test] +fn essential_column_survives_even_when_a_higher_priority_column_is_dropped() { + // "Notes" is declared first (highest priority) but isn't essential; + // type/name/data are declared after it but are essential. At this + // width, keeping all four doesn't fit, but keeping just the three + // essential ones does — so hiding must drop the non-essential column + // even though normal priority order would hide the trailing ones first. + let items = vec![json!({ + "notes": "irrelevant", + "type": "A", + "name": "www", + "data": "1234", + })]; + let columns = vec![ + TableColumn::new("notes", "Notes"), + TableColumn::new("type", "Type").essential(true), + TableColumn::new("name", "Name").essential(true), + TableColumn::new("data", "Data").essential(true), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 16, None, false); + + assert_eq!( + notes.hidden_columns, + vec!["Notes".to_owned()], + "the non-essential column is hidden to make room for the essential ones: {out}" + ); + assert!(!notes.truncated, "{out}"); + let header_line = out.lines().next().expect("header line"); + assert!(header_line.contains("TYPE"), "{out}"); + assert!(header_line.contains("NAME"), "{out}"); + assert!(header_line.contains("DATA"), "{out}"); +} + +#[test] +fn essential_columns_overflow_instead_of_hidden_or_truncated_when_the_terminal_is_too_narrow() { + // Same fixture as `narrow_terminal_hides_columns_before_truncating_any_of_the_survivors`, + // but every column is essential this time: none may be hidden or + // shrunk, so all three survive in full and the row simply overflows the + // cramped terminal instead. + let items = vec![json!({ "a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5) })]; + let columns = vec![ + TableColumn::new("a", "A").essential(true), + TableColumn::new("b", "B").essential(true), + TableColumn::new("c", "C").essential(true), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 10, None, false); + + assert!( + notes.hidden_columns.is_empty(), + "essential columns must never be hidden: {out}" + ); + assert!( + !notes.truncated, + "essential columns must never be truncated either: {out}" + ); + assert!(!out.contains("..."), "{out}"); + let header_line = out.lines().next().expect("header line"); + assert!( + header_line.contains('A') && header_line.contains('B') && header_line.contains('C'), + "{out}" + ); + assert!( + header_line.len() > 10, + "overflows the 10-column terminal rather than shrinking any essential column: {out}" + ); +} + +#[test] +fn explicit_fields_selection_disables_column_hiding_entirely() { + // Same fixture and width as `overflow_hides_lowest_priority_columns_first`, + // but this simulates an explicit `--fields` selection: the caller asked + // for exactly these columns, so none of them may be dropped for width. + // Every value here is no longer than its header, so there's nothing left + // to shrink either (headers never truncate) — the row simply overflows + // the 10-column terminal instead of losing a column. + let items = vec![json!({ + "id": "1", + "name": "acme", + "status": "active", + "created_at": "2026-01-01", + })]; + let columns = vec![ + TableColumn::new("id", "ID"), + TableColumn::new("name", "Name"), + TableColumn::new("status", "Status"), + TableColumn::new("created_at", "Created At"), + ]; + + let (out, notes) = render_array_with_columns(&items, &columns, 10, None, true); + + assert!( + notes.hidden_columns.is_empty(), + "an explicit --fields selection must never drop a column for width: {out}" + ); + assert!(!notes.truncated, "{out}"); + let header_line = out.lines().next().expect("header line"); + assert!(header_line.contains("STATUS"), "{out}"); + assert!(header_line.contains("CREATED AT"), "{out}"); + assert!( + header_line.len() > 10, + "overflows rather than dropping a column to fit the 10-column terminal: {out}" + ); +} + +#[test] +fn explicit_fields_selection_disables_truncation_even_when_a_value_outgrows_its_header() { + // Unlike the sibling test above, this value is longer than its header, + // so hiding-before-truncating would normally still shrink it to fit. + // An explicit `--fields` selection must skip that entirely: the user + // asked to see this field, so it renders at full natural width (and the + // row overflows the terminal) rather than losing any characters. + let description = "a description that is well past the old forty-character cap"; + let items = vec![json!({ "description": description })]; + let columns = vec![TableColumn::new("description", "Description")]; + + let (out, notes) = render_array_with_columns(&items, &columns, 20, None, true); + + assert!(!notes.truncated, "{out}"); + assert!(notes.hidden_columns.is_empty(), "{out}"); + assert!(out.contains(description), "{out}"); + assert!(!out.contains("..."), "{out}"); +} + +#[test] +fn render_human_with_view_reports_hidden_columns_in_footer() { + let envelope = Envelope::success( + json!([{ + "id": "1", + "name": "acme", + "status": "active", + "region": "us-west", + "created_at": "2026-01-01", + "updated_at": "2026-01-02", + "notes": "irrelevant, lowest priority", + }]), + "resource", + ); + let columns = 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"), + // Deliberately long enough that, combined with the columns above, + // it can't fit alongside them at the fallback 80-column width. + TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"), + ]; + + // In test runs stdout is not a TTY, so `terminal_width()` deterministically + // falls back to 80 — these headers don't all fit at that width. + let out = render_human_with_view(&envelope, Some(&columns), "", false); + + assert!(out.contains("hidden to fit the display width"), "{out}"); + assert!( + out.contains("This Is An Extremely Long Trailing Column Header"), + "{out}" + ); + assert!(out.contains("--fields"), "{out}"); + assert!(out.contains("--json"), "{out}"); +} + +#[test] +fn fit_column_widths_gives_small_wants_priority_over_larger_ones() { + // Regression: a naive `leftover / remaining` split can floor a small + // want to zero (denying a column that needed only 1 more char) + // while a much larger want absorbs that same unit and stays + // truncated anyway — net truncation is identical, but a column that + // could have been fully satisfied wasn't. + let headers = [1, 1, 1]; + let natural = [2, 2, 6]; // wants: 1, 1, 5 + let no_truncate = [false, false, false]; + + let (widths, truncated) = fit_column_widths(&headers, &natural, &no_truncate, 8); + + assert_eq!( + widths[0], natural[0], + "a column that only wanted 1 more char should get it in full: {widths:?}" + ); + assert!(truncated, "budget is still too small overall: {widths:?}"); +} + +#[test] +fn overflow_hiding_accounts_for_no_truncate_columns_true_width() { + // Regression: deciding what to hide from header length alone + // under-counts a `no_truncate` column (it never shrinks below its + // natural width), which could keep a short-header trailing column + // that would never have fit anyway — overflowing when hiding it + // would have let the row fit. + let url = "x".repeat(40); + let items = vec![json!({ "url": url, "notes": "irrelevant, lowest priority" })]; + let columns = vec![ + TableColumn::new("url", "URL").no_truncate(true), + TableColumn::new("notes", "X"), + ]; + + // Exactly enough room for the URL alone (40 chars), not enough for + // the URL plus even a 1-char trailing column and its gutter (43). + let (out, notes) = render_array_with_columns(&items, &columns, 42, None, false); + + assert_eq!( + notes.hidden_columns, + vec!["X".to_owned()], + "the trailing column must be hidden so the no_truncate URL column fits: {out}" + ); + let header_line = out.lines().next().expect("header line"); + assert!( + header_line.len() <= 42, + "must not overflow once the trailing column is hidden: {out}" + ); +} + +#[test] +fn render_array_with_columns_handles_no_columns_gracefully() { + // A view's `--fields` filtered out every declared column: nothing to + // build a table from, so this must report "no results" rather than + // a blank header/rows table. + let items = vec![json!({ "a": "1" })]; + let (out, notes) = render_array_with_columns(&items, &[], 80, None, false); + + assert_eq!(out, "(no results)\n"); + assert!(!notes.truncated, "{out}"); + 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 + // keys to derive columns from — same "no columns" case as above, + // reached through the no-view path instead. + let items = vec![json!({}), json!({})]; + let (out, notes) = render_array(&items, "", 80, None, false); + + assert_eq!(out, "(no results)\n"); + assert!(notes.hidden_columns.is_empty(), "{out}"); +} diff --git a/cli-engine/src/output/mod.rs b/cli-engine/src/output/mod.rs index 93fe354..8893628 100644 --- a/cli-engine/src/output/mod.rs +++ b/cli-engine/src/output/mod.rs @@ -24,14 +24,13 @@ pub use envelope::{ }; pub(crate) use fields::project_fields; pub use fields::{FieldTree, filter_fields, parse_fields}; -pub(crate) use human::terminal_width; pub use human::{ 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, - render_human_with_registry_selected, render_human_with_view, + lookup_global_human_view_func, preview_human_view, register_global_human_view, + register_global_human_view_func, render_human, }; +pub(crate) use human::{render_human_with_registry_selected, terminal_width}; pub use json::render_json; pub(crate) use pipeline::unknown_fields_message; pub use pipeline::{PipelineOpts, apply_pipeline}; diff --git a/cli-engine/tests/custom_human_views.rs b/cli-engine/tests/custom_human_views.rs deleted file mode 100644 index c44d8d3..0000000 --- a/cli-engine/tests/custom_human_views.rs +++ /dev/null @@ -1,28 +0,0 @@ -use cli_engine::{ - Envelope, HumanViewRegistry, NextAction, NextActionParam, render_human_with_registry_selected, -}; -use serde_json::json; - -#[test] -fn custom_human_output_appends_next_steps_footer() { - let mut registry = HumanViewRegistry::new(); - registry.register_func("shopping-cart", |_| "Cart: ready\n".to_owned()); - let envelope = - Envelope::success(json!({ "id": "cart-1" }), "shopping-cart").with_next_actions(vec![ - NextAction::new( - "shopping checkout complete --agree", - "Place the order", - ) - .with_param("cart-id", NextActionParam::value("cart-1")), - ]); - - let out = render_human_with_registry_selected(&envelope, ®istry, "shopping-cart", ""); - - assert!(out.starts_with("Cart: ready\n"), "{out}"); - assert!(out.contains("\nNext steps:\n"), "{out}"); - assert!( - out.contains("shopping checkout complete cart-1 --agree"), - "{out}" - ); - assert!(out.contains("Place the order"), "{out}"); -} diff --git a/cli-engine/tests/exhaustive_output.rs b/cli-engine/tests/exhaustive_output.rs index 154051b..bbbd30e 100644 --- a/cli-engine/tests/exhaustive_output.rs +++ b/cli-engine/tests/exhaustive_output.rs @@ -4,8 +4,8 @@ use cli_engine::{ 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, + preview_human_view, register_global_human_view, register_global_schema_info, render, + render_format, }; use serde_json::{Value, json}; @@ -203,16 +203,13 @@ fn human_view_columns_resolve_dotted_paths_and_preserve_shape_for_empty_and_miss TableColumn::new("owner.name", "Owner"), TableColumn::new("missing", "Missing"), ]; - let envelope = Envelope::success( - json!([ - {"id": "p1", "owner": {"name": "Ada"}}, - {"id": "p2", "owner": {}} - ]), - "project:list", - ); + let data = json!([ + {"id": "p1", "owner": {"name": "Ada"}}, + {"id": "p2", "owner": {}} + ]); assert_eq!( - render_human_with_view(&envelope, Some(&columns), ""), + preview_human_view(data, &columns), "ID OWNER MISSING\n-- ----- -------\np1 Ada \np2 \n\n(2 rows)\n" ); } @@ -227,12 +224,9 @@ fn human_view_no_truncate_column_preserves_long_values_in_table_output() { ]; // Short enough that, alongside the no_truncate URL column, both columns // still fit within the fallback 80-column width used in non-TTY test runs. - let envelope = Envelope::success( - json!([{"title": "Agreement", "url": long_url}]), - "agreements:list", - ); + let data = json!([{"title": "Agreement", "url": long_url}]); - let rendered = render_human_with_view(&envelope, Some(&columns), ""); + let rendered = preview_human_view(data, &columns); assert!( rendered.contains(long_url), @@ -250,41 +244,17 @@ fn human_view_right_aligned_column_lines_up_prices_in_table_output() { 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", - ); + let data = json!([ + {"period": "1 year", "price": "71.99"}, + {"period": "2 years", "price": "143.99"} + ]); assert_eq!( - render_human_with_view(&envelope, Some(&columns), ""), + preview_human_view(data, &columns), "PERIOD PRICE\n------- ------\n1 year 71.99\n2 years 143.99\n\n(2 rows)\n" ); } -#[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!( diff --git a/cli-engine/tests/foundation.rs b/cli-engine/tests/foundation.rs index b993e71..8b56417 100644 --- a/cli-engine/tests/foundation.rs +++ b/cli-engine/tests/foundation.rs @@ -28,9 +28,8 @@ use cli_engine::{ derive_value_flags, extract_command_path, extract_output_format, format_help_section, guide::guide_content, has_true_schema_flag, - output::render_human_with_view, output::{Envelope, OutputFormat, PipelineOpts, apply_pipeline, filter_fields, render}, - register_global_flags, register_global_human_view, register_global_schema, + preview_human_view, register_global_flags, register_global_human_view, register_global_schema, register_reason_flag, render_tree_human, search::{SearchDocument, SearchIndex, tokenize}, transport::{ @@ -9433,16 +9432,13 @@ 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::new("name", "Name")]; - let envelope = Envelope::success( - json!([ - {"name": "alpha"}, - true - ]), - "things-api", - ); + let data = json!([ + {"name": "alpha"}, + true + ]); assert_eq!( - render_human_with_view(&envelope, Some(&columns), ""), + preview_human_view(data, &columns), "{\"name\":\"alpha\"}\ntrue\n" ); } @@ -9457,15 +9453,15 @@ fn human_view_registry_renders_registered_columns_for_lists() { TableColumn::new("enabled", "Enabled"), ], }); - let envelope = Envelope::success( - json!([ - {"name": "alpha", "enabled": true, "ignored": "x"}, - {"name": "beta", "enabled": false, "ignored": "y"} - ]), - "things", - ); + let data = json!([ + {"name": "alpha", "enabled": true, "ignored": "x"}, + {"name": "beta", "enabled": false, "ignored": "y"} + ]); - let rendered = render_human_with_view(&envelope, registry.columns("things"), ""); + let rendered = preview_human_view( + data, + registry.columns("things").expect("registered columns"), + ); assert_eq!( rendered, @@ -9479,34 +9475,17 @@ fn human_view_registry_renders_registered_columns_for_objects() { TableColumn::new("name", "Name"), TableColumn::new("missing", "Missing"), ]; - let envelope = Envelope::success(json!({"name": "alpha", "ignored": "x"}), "things"); + let data = json!({"name": "alpha", "ignored": "x"}); - let rendered = render_human_with_view(&envelope, Some(&columns), ""); + let rendered = preview_human_view(data, &columns); assert_eq!(rendered, "Name: alpha\nMissing: \n"); } -#[test] -fn human_view_registry_custom_renderer_wins_over_columns_preserves_legacy_view_func() { - let mut registry = HumanViewRegistry::new(); - registry.register(HumanViewDef { - schema_id: "things".to_owned(), - columns: vec![TableColumn::new("name", "Name")], - }); - registry.register_func("things", |data| { - format!( - "custom:{}\n", - data.get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - ) - }); - let envelope = Envelope::success(json!({"name": "alpha"}), "things"); - - let rendered = cli_engine::render_human_with_registry(&envelope, ®istry); - - assert_eq!(rendered, "custom:alpha\n"); -} +// Moved to `cli-engine/src/output/human/tests/registry.rs` as +// `human_view_registry_custom_renderer_wins_over_columns` — it exercises +// `render_human_with_registry_selected`, which is crate-internal now that +// `preview_human_view` is the public surface for view-rendering tests. #[test] fn global_human_view_func_registration_can_be_looked_up_and_rendered() {