Skip to main content

cli_engine/output/
human.rs

1use std::{
2    borrow::Cow,
3    collections::{BTreeMap, BTreeSet, HashMap},
4    fmt,
5    io::IsTerminal,
6    sync::{Arc, OnceLock, RwLock},
7};
8
9use serde_json::Value;
10
11use super::{Envelope, NextAction, NextActionParam, PaginationMeta};
12
13/// Column text alignment for the human table view.
14///
15/// Only affects the array/table rendering path (`render_array_with_columns`
16/// via `render_table`) — property-bag rendering (`render_object_with_columns`)
17/// prints `header: value` with no column widths to align, so alignment is a
18/// no-op there.
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
20pub enum Alignment {
21    /// Left-aligned (the default) — appropriate for text-like columns.
22    #[default]
23    Left,
24    /// Right-aligned — use for numeric/price columns so values line up on
25    /// their least-significant digit.
26    Right,
27}
28
29/// Column definition for registered human table views.
30///
31/// Column order is a priority order, most important first: table rendering
32/// keeps this order on screen, and when the terminal is too narrow to show
33/// every column, the lowest-priority (trailing) columns are hidden first. Put
34/// the column a reader most needs — usually an id or name — first.
35///
36/// This declared order is only the *fallback* — whenever a `--fields`/
37/// `default_fields` selection is given, its order wins instead (see
38/// [`crate::output::render_human_with_registry_selected`]), for both display
39/// and hide-priority. Declared order only governs output when no selection is
40/// given at all.
41///
42/// Construct with [`TableColumn::new`], then chain builder methods like
43/// [`no_truncate`](TableColumn::no_truncate)/[`nested`](TableColumn::nested)
44/// — never as a struct literal. No known consumer constructs `TableColumn`
45/// via struct literal, so marking it `#[non_exhaustive]` carries no real
46/// breaking impact today; going forward it means the engine can add fields
47/// (as it did for `nested`) without that becoming a breaking release either.
48#[derive(Clone, Debug, Eq, PartialEq)]
49#[non_exhaustive]
50pub struct TableColumn {
51    /// JSON field path. Supports simple dotted paths to reach a value nested
52    /// under intermediate objects, so a column can point through a wrapper
53    /// shape (a pagination envelope, a `Summary<T>`, etc.). A literal field
54    /// name containing a `.` is not supported — this mirrors the dotted-path
55    /// convention `crate::output::fields` already uses for `--fields`
56    /// projection.
57    pub field: String,
58    /// Display header.
59    pub header: String,
60    /// When true, this column's values are never shrunk to fit the terminal
61    /// (still capped at `NO_TRUNCATE_MAX_WIDTH` to bound pathologically long
62    /// values). Use this for values that are useless when cut short, such as
63    /// URLs.
64    pub no_truncate: bool,
65    /// When set, and the resolved value is list-of-objects or object shaped,
66    /// this column renders as an indented child table or child property bag
67    /// instead of a one-line dump — see [`TableColumn::nested`]. `None` (the
68    /// default from [`TableColumn::new`]) is a complete no-op: rendering is
69    /// identical to a column with no opinion about nesting.
70    pub nested: Option<Vec<TableColumn>>,
71    /// Header and cell text alignment — see [`TableColumn::align`].
72    pub align: Alignment,
73}
74
75impl TableColumn {
76    /// Creates a table column from a JSON field path and display header.
77    #[must_use]
78    pub fn new(field: impl Into<String>, header: impl Into<String>) -> Self {
79        Self {
80            field: field.into(),
81            header: header.into(),
82            no_truncate: false,
83            nested: None,
84            align: Alignment::Left,
85        }
86    }
87
88    /// Opts this column out of terminal-width-driven shrinking. Values are
89    /// still capped at `NO_TRUNCATE_MAX_WIDTH`.
90    #[must_use]
91    pub fn no_truncate(mut self, value: bool) -> Self {
92        self.no_truncate = value;
93        self
94    }
95
96    /// Sets this column's header and cell alignment. Defaults to
97    /// `Alignment::Left`; use `Alignment::Right` for numeric or price
98    /// columns so decimal points and digits line up instead of looking
99    /// ragged on the left.
100    #[must_use]
101    pub fn align(mut self, alignment: Alignment) -> Self {
102        self.align = alignment;
103        self
104    }
105
106    /// Opts this column into rendering a nested list/object value as an
107    /// indented child table or property bag, using `columns` as that child's
108    /// own column definitions (which may themselves set `.nested(...)`).
109    ///
110    /// Nesting is only consulted when this column is rendered inside an
111    /// object property bag (top-level, or itself a nested property bag) — a
112    /// row cell inside an array-of-objects table always renders as a single
113    /// flat value, ignoring `nested`, because a table row is one monospace
114    /// line and can't itself contain a rendered sub-block without breaking
115    /// column alignment. Recursion is otherwise unbounded through the object
116    /// chain: a nested column's own child columns may set `.nested(...)`
117    /// again for a grandchild table or property bag.
118    #[must_use]
119    pub fn nested(mut self, columns: impl Into<Vec<TableColumn>>) -> Self {
120        self.nested = Some(columns.into());
121        self
122    }
123}
124
125/// Human view definition keyed by schema id.
126///
127/// `columns` order is a priority order — see [`TableColumn`].
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct HumanViewDef {
130    /// Schema id, usually the command path.
131    pub schema_id: String,
132    /// Columns rendered for matching object or list data, most important
133    /// first.
134    pub columns: Vec<TableColumn>,
135}
136
137impl HumanViewDef {
138    /// Creates a column-based human view for a schema id or command path.
139    #[must_use]
140    pub fn new(schema_id: impl Into<String>, columns: impl Into<Vec<TableColumn>>) -> Self {
141        Self {
142            schema_id: schema_id.into(),
143            columns: columns.into(),
144        }
145    }
146}
147
148/// Function used to render custom human output for a JSON value.
149pub type HumanViewFn = Arc<dyn Fn(&Value) -> String + Send + Sync>;
150
151/// Custom human renderer wrapper.
152#[derive(Clone)]
153pub struct HumanViewRenderer {
154    render: HumanViewFn,
155}
156
157impl HumanViewRenderer {
158    /// Creates a custom renderer.
159    #[must_use]
160    pub fn new(render: impl Fn(&Value) -> String + Send + Sync + 'static) -> Self {
161        Self {
162            render: Arc::new(render),
163        }
164    }
165
166    /// Renders data with the custom renderer.
167    #[must_use]
168    pub fn render(&self, data: &Value) -> String {
169        (self.render)(data)
170    }
171}
172
173impl fmt::Debug for HumanViewRenderer {
174    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
175        formatter
176            .debug_struct("HumanViewRenderer")
177            .finish_non_exhaustive()
178    }
179}
180
181/// Registry of human column and custom-renderer views.
182#[derive(Clone, Debug, Default)]
183pub struct HumanViewRegistry {
184    by_schema_id: BTreeMap<String, Vec<TableColumn>>,
185    custom_by_schema_id: BTreeMap<String, HumanViewRenderer>,
186}
187
188impl HumanViewRegistry {
189    /// Creates an empty registry.
190    #[must_use]
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Registers a column-based human view.
196    pub fn register(&mut self, view: HumanViewDef) {
197        self.by_schema_id.insert(view.schema_id, view.columns);
198    }
199
200    /// Registers a custom renderer for a schema id.
201    pub fn register_func(
202        &mut self,
203        schema_id: impl Into<String>,
204        render: impl Fn(&Value) -> String + Send + Sync + 'static,
205    ) {
206        self.custom_by_schema_id
207            .insert(schema_id.into(), HumanViewRenderer::new(render));
208    }
209
210    /// Merges another registry into this one.
211    pub fn merge(&mut self, other: &Self) {
212        self.by_schema_id.extend(other.by_schema_id.clone());
213        self.custom_by_schema_id
214            .extend(other.custom_by_schema_id.clone());
215    }
216
217    /// Returns column definitions for a schema id.
218    #[must_use]
219    pub fn columns(&self, schema_id: &str) -> Option<&[TableColumn]> {
220        self.by_schema_id.get(schema_id).map(Vec::as_slice)
221    }
222
223    /// Returns the custom renderer for a schema id.
224    #[must_use]
225    pub fn custom(&self, schema_id: &str) -> Option<&HumanViewRenderer> {
226        self.custom_by_schema_id.get(schema_id)
227    }
228
229    /// Whether any human view (column-based or custom) is registered for a
230    /// schema id. Such a view selects its own columns from the full payload, so
231    /// callers must not pre-project the data before handing it to the renderer.
232    #[must_use]
233    pub fn has_view(&self, schema_id: &str) -> bool {
234        self.by_schema_id.contains_key(schema_id)
235            || self.custom_by_schema_id.contains_key(schema_id)
236    }
237}
238
239static GLOBAL_HUMAN_VIEW_REGISTRY: OnceLock<RwLock<HumanViewRegistry>> = OnceLock::new();
240
241fn global_human_view_registry() -> &'static RwLock<HumanViewRegistry> {
242    GLOBAL_HUMAN_VIEW_REGISTRY.get_or_init(|| RwLock::new(HumanViewRegistry::new()))
243}
244
245/// Registers a process-global column view.
246pub fn register_global_human_view(view: HumanViewDef) {
247    let mut registry = global_human_view_registry()
248        .write()
249        .unwrap_or_else(|poisoned| poisoned.into_inner());
250    registry.register(view);
251}
252
253/// Registers a process-global custom human renderer.
254pub fn register_global_human_view_func(
255    schema_id: impl Into<String>,
256    render: impl Fn(&Value) -> String + Send + Sync + 'static,
257) {
258    let mut registry = global_human_view_registry()
259        .write()
260        .unwrap_or_else(|poisoned| poisoned.into_inner());
261    registry.register_func(schema_id, render);
262}
263
264/// Looks up global columns for a schema id.
265#[must_use]
266pub fn lookup_global_human_view_columns(schema_id: &str) -> Option<Vec<TableColumn>> {
267    global_human_view_registry()
268        .read()
269        .unwrap_or_else(|poisoned| poisoned.into_inner())
270        .columns(schema_id)
271        .map(<[TableColumn]>::to_vec)
272}
273
274/// Looks up a global custom renderer for a schema id.
275#[must_use]
276pub fn lookup_global_human_view_func(schema_id: &str) -> Option<HumanViewRenderer> {
277    global_human_view_registry()
278        .read()
279        .unwrap_or_else(|poisoned| poisoned.into_inner())
280        .custom(schema_id)
281        .cloned()
282}
283
284/// Returns a snapshot of the process-global human view registry.
285#[must_use]
286pub fn global_human_view_registry_snapshot() -> HumanViewRegistry {
287    global_human_view_registry()
288        .read()
289        .unwrap_or_else(|poisoned| poisoned.into_inner())
290        .clone()
291}
292
293/// Renders an envelope using generic human output.
294///
295/// There's no field-selection concept at this entry point, so a no-view
296/// array/object falls back to alphabetical key order — use
297/// [`render_human_with_registry_selected`] when a `--fields`/`default_fields`
298/// value is available, so its order can drive column order too.
299#[must_use]
300pub fn render_human(envelope: &Envelope) -> String {
301    render_human_with_view(envelope, None, "")
302}
303
304/// Renders an envelope using a human view registry.
305#[must_use]
306pub fn render_human_with_registry(envelope: &Envelope, registry: &HumanViewRegistry) -> String {
307    let system = envelope
308        .metadata
309        .as_ref()
310        .map(|metadata| metadata.system.as_str())
311        .unwrap_or_default();
312    render_human_with_registry_for_schema(envelope, registry, system)
313}
314
315/// Renders an envelope using registry entries for a specific schema id.
316///
317/// Shows every column of the registered view. Use
318/// [`render_human_with_registry_selected`] to narrow the columns to a field
319/// selection.
320#[must_use]
321pub fn render_human_with_registry_for_schema(
322    envelope: &Envelope,
323    registry: &HumanViewRegistry,
324    schema_id: &str,
325) -> String {
326    render_human_with_registry_selected(envelope, registry, schema_id, "")
327}
328
329/// Renders an envelope using a registered view, narrowed to `fields`.
330///
331/// `fields` uses the same comma-separated syntax as `--fields`: an empty
332/// string, `all`, or `*` keeps every column; otherwise only the view columns
333/// whose `field` is listed are shown. A custom view renderer receives the full
334/// data and ignores `fields`.
335#[must_use]
336pub fn render_human_with_registry_selected(
337    envelope: &Envelope,
338    registry: &HumanViewRegistry,
339    schema_id: &str,
340    fields: &str,
341) -> String {
342    if let Some(error) = &envelope.error {
343        return format!("Error: {}\n", error.message);
344    }
345    if let Some(data) = &envelope.data
346        && let Some(custom) = registry.custom(schema_id)
347    {
348        return custom.render(data);
349    }
350    match registry.columns(schema_id) {
351        Some(columns) => {
352            let selected = select_columns(columns, fields);
353            render_human_with_view(envelope, Some(&selected), fields)
354        }
355        None => render_human_with_view(envelope, None, fields),
356    }
357}
358
359/// Narrows and reorders view columns to a `--fields`-style selection. An
360/// empty string, `all`, or `*` keeps every column in its declared order;
361/// otherwise columns are chosen and ordered by the comma-separated list
362/// (deduplicated, first occurrence wins) — a name with no matching column is
363/// silently skipped, so a view still only ever shows its own declared
364/// fields.
365fn select_columns(columns: &[TableColumn], fields: &str) -> Vec<TableColumn> {
366    let fields = fields.trim();
367    if fields.is_empty() || fields == "all" || fields == "*" {
368        return columns.to_vec();
369    }
370    let mut seen = BTreeSet::new();
371    fields
372        .split(',')
373        .map(str::trim)
374        .filter(|part| !part.is_empty() && seen.insert(*part))
375        .filter_map(|name| columns.iter().find(|column| column.field == name).cloned())
376        .collect()
377}
378
379/// Renders an envelope using explicit table columns.
380///
381/// `columns`, when `Some`, is expected to already be `--fields`-selected and
382/// ordered (applied by callers such as
383/// [`render_human_with_registry_selected`] before this function runs) — this
384/// function does not re-apply `fields` to it. `fields` is only read here when
385/// `columns` is `None`, to give the dynamically-derived, no-view column
386/// catalog the same field selection and order a view would have gotten. Pass
387/// `""` when no field-selection value is available.
388#[must_use]
389pub fn render_human_with_view(
390    envelope: &Envelope,
391    columns: Option<&[TableColumn]>,
392    fields: &str,
393) -> String {
394    // Errors render on their own; success output gets the data body plus, when
395    // present, a "Next steps:" footer built from the envelope's next_actions
396    // (these otherwise appear only in JSON/TOON).
397    if let Some(error) = &envelope.error {
398        let mut out = format!("Error: {}\n", error.message);
399        if let Some(fix) = &envelope.fix {
400            out.push_str("Fix: ");
401            out.push_str(fix);
402            out.push('\n');
403        }
404        return out;
405    }
406    let available_width = terminal_width();
407    let (mut body, notes) = match &envelope.data {
408        None => ("(no data)\n".to_owned(), RenderNotes::default()),
409        Some(data) => render_data_body(
410            data,
411            columns,
412            fields,
413            available_width,
414            envelope.pagination.as_ref(),
415        ),
416    };
417    // Footers are appended in place: the common no-footer path leaves `body`
418    // untouched (no realloc/copy), and non-empty content is written directly
419    // into it (no per-footer temporaries).
420    append_render_notes(&mut body, &notes);
421    if !notes.pagination_shown {
422        // `envelope.data` already reflects the fully piped result (filter ->
423        // paginate -> expr -> fields), so its length is what this non-table
424        // path actually rendered — unlike `pagination.count`, which is only
425        // the pre-`--expr` slice size and can go stale once `--expr` reshapes
426        // the array (mirrors the same fix in `render_table`).
427        let shown = envelope
428            .data
429            .as_ref()
430            .and_then(Value::as_array)
431            .and_then(|items| i64::try_from(items.len()).ok());
432        append_pagination_summary(&mut body, envelope.pagination.as_ref(), shown);
433    }
434    append_next_actions(&mut body, &envelope.next_actions);
435    body
436}
437
438/// Render just the data portion of a success envelope (no next-steps footer).
439fn render_data_body(
440    data: &Value,
441    columns: Option<&[TableColumn]>,
442    fields: &str,
443    available_width: usize,
444    pagination: Option<&PaginationMeta>,
445) -> (String, RenderNotes) {
446    if let Some(columns) = columns {
447        return match data {
448            Value::Array(items) => {
449                render_array_with_columns(items, columns, available_width, pagination)
450            }
451            Value::Object(map) => render_object_with_columns(map, columns, available_width),
452            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
453                (format!("{}\n", format_value(data)), RenderNotes::default())
454            }
455        };
456    }
457    match data {
458        Value::Array(items) => render_array(items, fields, available_width, pagination),
459        Value::Object(map) => {
460            let columns = dynamic_columns(fields, || map.keys().cloned().collect());
461            render_object_with_columns(map, &columns, available_width)
462        }
463        other => (
464            format!("{}\n", format_plain_value(other)),
465            RenderNotes::default(),
466        ),
467    }
468}
469
470/// Builds the column catalog for data with no registered view: when `fields`
471/// names specific fields (not empty/`all`/`*`), columns are derived from that
472/// list, in the order given (deduplicated) — the same order source a
473/// registered view's `--fields` selection uses (see [`select_columns`]).
474/// Otherwise falls back to `natural_keys()` sorted alphabetically, since a
475/// bare JSON object has no other order signal to offer.
476fn dynamic_columns(fields: &str, natural_keys: impl FnOnce() -> Vec<String>) -> Vec<TableColumn> {
477    let fields = fields.trim();
478    if fields.is_empty() || fields == "all" || fields == "*" {
479        let mut keys = natural_keys();
480        keys.sort();
481        return keys
482            .into_iter()
483            .map(|key| TableColumn::new(key.clone(), key))
484            .collect();
485    }
486    let mut seen = BTreeSet::new();
487    fields
488        .split(',')
489        .map(str::trim)
490        .filter(|part| !part.is_empty() && seen.insert(*part))
491        .map(|field| TableColumn::new(field, field))
492        .collect()
493}
494
495/// True when at least one item has a JSON number at `field`, and no item
496/// with a present, non-null value at `field` holds anything else.
497fn column_is_all_numeric(items: &[Value], field: &str) -> bool {
498    let mut saw_number = false;
499    for item in items {
500        match item
501            .as_object()
502            .and_then(|map| resolve_field_path(map, field))
503        {
504            Some(Value::Number(_)) => saw_number = true,
505            Some(Value::Null) | None => {}
506            Some(_) => return false,
507        }
508    }
509    saw_number
510}
511
512/// Appends footer hints for truncated cells and/or hidden columns to `out`
513/// (a no-op when neither happened). Mirrors `append_next_actions`: writes
514/// directly into `out` rather than building a separate string.
515fn append_render_notes(out: &mut String, notes: &RenderNotes) {
516    // `--fields` only ever selects among top-level declared columns: it can
517    // drop a `TableColumn::nested` column entirely, but can't narrow what
518    // shows *inside* one. Suggesting it as a fix once any of the reported
519    // narrowing happened inside a nested block would be wrong — there's no
520    // flag that reaches that fine-grained, so `--json` is the only real
521    // remedy in that case.
522    let fields_helps = !notes.nested_narrowing;
523    if notes.truncated {
524        if fields_helps {
525            out.push_str(
526                "\nOutput truncated to fit the display width — use --fields to show fewer columns, or --json for full values.\n",
527            );
528        } else {
529            out.push_str(
530                "\nOutput truncated to fit the display width — use --json for full values.\n",
531            );
532        }
533    }
534    if !notes.hidden_columns.is_empty() {
535        let suggestion = if fields_helps {
536            "use --fields to choose columns, or --json for full output"
537        } else {
538            "use --json for full output"
539        };
540        out.push_str(&format!(
541            "\n{} column{} hidden to fit the display width ({}) — {suggestion}.\n",
542            notes.hidden_columns.len(),
543            if notes.hidden_columns.len() == 1 {
544                ""
545            } else {
546                "s"
547            },
548            notes.hidden_columns.join(", "),
549        ));
550    }
551}
552
553/// Appends a one-line pagination summary to `out` (a no-op when the response
554/// wasn't paginated). Unlike `next_actions`, this always shows the underlying
555/// facts even on the last page, where there's no follow-up command to
556/// suggest.
557///
558/// Only a fallback: when the data rendered as a table, `render_table` already
559/// merged these same facts into its `(N of M rows, ...)` footer
560/// (`RenderNotes::pagination_shown` signals that to
561/// [`render_human_with_view`]), so this only actually prints anything for a
562/// paginated response that *didn't* render as a table (e.g. a bare array of
563/// scalars) — otherwise the two would repeat the same count/offset/limit on
564/// consecutive lines.
565///
566/// `shown` is the caller's actual rendered item count (from `envelope.data`,
567/// post-pipeline), used in place of `pagination.count` — which is only the
568/// pre-`--expr` slice size and can go stale once `--expr` reshapes the array
569/// after pagination ran (mirrors the same fix in `render_table`). `None`
570/// means `--expr` reshaped the data into something that's no longer even an
571/// array (e.g. `length(@)` turning it into a number) — pagination still ran,
572/// but there's no rendered row count left to describe, so this falls back to
573/// a more neutral line instead of a "Showing N of M" claim that would no
574/// longer match what's actually displayed above it.
575fn append_pagination_summary(
576    out: &mut String,
577    pagination: Option<&PaginationMeta>,
578    shown: Option<i64>,
579) {
580    let Some(pagination) = pagination else {
581        return;
582    };
583    match shown {
584        Some(count) => out.push_str(&format!(
585            "\nShowing {count} of {} (offset {}, limit {})\n",
586            pagination.total, pagination.offset, pagination.limit
587        )),
588        None => out.push_str(&format!(
589            "\n(pagination: {} total, offset {}, limit {})\n",
590            pagination.total, pagination.offset, pagination.limit
591        )),
592    }
593}
594
595/// Append a "Next steps:" footer listing suggested follow-up commands to `out`
596/// (a no-op when there are none). Each action shows its command template with
597/// any known param values substituted into their `<placeholder>` (params
598/// without a known value, e.g. required-only hints, are shown as-is), followed
599/// by the description beneath it. Writes directly into `out` to avoid
600/// per-action temporaries.
601fn append_next_actions(out: &mut String, actions: &[NextAction]) {
602    if actions.is_empty() {
603        return;
604    }
605    out.push_str("\nNext steps:\n");
606    for action in actions {
607        out.push_str("  ");
608        out.push_str(&substitute_known_params(&action.command, &action.params));
609        out.push_str("\n      ");
610        out.push_str(&action.description);
611        out.push('\n');
612    }
613}
614
615/// Fills a `NextAction` command template with any params that carry a known
616/// concrete `value` — e.g. `"domain quote <domain>"` with
617/// `params["domain"].value == Some("example.com")` becomes
618/// `"domain quote example.com"`. A param's placeholder is its key wrapped in
619/// angle brackets (`<domain>`); params without a known value (required-only
620/// hints) are left as literal placeholder text for the user to fill in.
621/// Borrows `command` as-is (no allocation) when nothing has a known value.
622fn substitute_known_params<'cmd>(
623    command: &'cmd str,
624    params: &HashMap<String, NextActionParam>,
625) -> Cow<'cmd, str> {
626    let mut command = Cow::Borrowed(command);
627    for (key, param) in params {
628        if let Some(value) = &param.value {
629            let placeholder = format!("<{key}>");
630            if command.contains(&placeholder) {
631                command = Cow::Owned(command.replace(&placeholder, value));
632            }
633        }
634    }
635    command
636}
637
638/// Upper bound on a `no_truncate` column's width, even though it otherwise
639/// skips the normal 40-char cap. Prevents a pathologically long field value
640/// (not expected in practice, but not guaranteed by any schema) from padding
641/// every row and the separator line out to an unusable or memory-heavy width.
642///
643/// This bounds runtime *values*, not the column *header*: width is always
644/// widened back up to `column.header.len()` after the cap is applied, so a
645/// header can never be truncated or misaligned even in the (unrealistic)
646/// case where it exceeds `NO_TRUNCATE_MAX_WIDTH` itself. Headers are static,
647/// developer-authored labels, not the pathological runtime data this cap
648/// guards against.
649const NO_TRUNCATE_MAX_WIDTH: usize = 4096;
650
651/// Space between adjacent rendered columns. Must match the gutter
652/// `render_table` actually writes, since width-fitting math (how much room
653/// is left for column content) has to agree with what gets printed.
654const COLUMN_GUTTER: usize = 2;
655
656/// Indent applied to a nested table/property-bag block under a parent
657/// object's field. Matches the two-space depth-step the TOON encoder already
658/// uses (`crate::output::toon`'s `push_line`), for a consistent look across
659/// human and TOON nested rendering.
660const NESTED_INDENT: &str = "  ";
661
662/// Detects how wide to render human-output tables and guides.
663///
664/// An interactive terminal gets its live width (via `termimad`); anything
665/// else (pipes, files, CI) gets a fixed `80` so non-interactive `--human`
666/// output stays deterministic. Floored at `20` in case a terminal reports an
667/// unusably small or zero width.
668#[must_use]
669pub(crate) fn terminal_width() -> usize {
670    if std::io::stdout().is_terminal() {
671        usize::from(termimad::terminal_size().0).max(20)
672    } else {
673        80
674    }
675}
676
677/// Signals produced while rendering a table body, used to build human-output
678/// footer hints. `Default` means nothing was hidden or shortened.
679#[derive(Default)]
680struct RenderNotes {
681    /// Whether any cell was shortened to fit the terminal.
682    truncated: bool,
683    /// Headers of columns dropped entirely because there wasn't room for
684    /// them, in their original declared/requested order (the order they
685    /// would have appeared in the table, had they fit) — not reverse
686    /// priority order.
687    hidden_columns: Vec<String>,
688    /// Whether any of the truncation/hiding captured above happened inside a
689    /// nested child block (a `TableColumn::nested` column's own table or
690    /// property bag) rather than at this level's own top-level columns.
691    /// `--fields` only ever selects among top-level declared columns — it
692    /// can drop a nested column entirely, but can't narrow what's shown
693    /// *inside* one — so [`append_render_notes`] must not suggest `--fields`
694    /// as a fix when this is set, even though `hidden_columns`/`truncated`
695    /// are otherwise reported identically either way.
696    nested_narrowing: bool,
697    /// Whether the table footer already merged in the pagination summary
698    /// (`render_table`'s `(N of M rows, offset O, limit L)` line) — so
699    /// [`render_human_with_view`] doesn't also append the standalone
700    /// `append_pagination_summary` line and duplicate the same facts.
701    pagination_shown: bool,
702}
703
704/// Chooses how many leading columns (priority order, most important first),
705/// each contributing at least `min_widths[i]`, fit in `available_width` — so
706/// lower-priority trailing columns can be dropped when the terminal is too
707/// narrow for all of them. `min_widths[i]` should be the column's header
708/// length for a column that can still shrink, or its full natural width for
709/// one that can't (e.g. `no_truncate`) — using a shrinkable column's header
710/// length here lets it still be counted as fitting even though its eventual
711/// rendered width may be larger. Always keeps at least one column, even if
712/// it alone exceeds `available_width`.
713fn columns_fitting_width(min_widths: &[usize], available_width: usize) -> usize {
714    let mut used = 0_usize;
715    let mut kept = 0_usize;
716    for (index, &min_width) in min_widths.iter().enumerate() {
717        let gutter = if index == 0 { 0 } else { COLUMN_GUTTER };
718        let next_used = used + gutter + min_width;
719        if next_used > available_width && kept > 0 {
720            break;
721        }
722        used = next_used;
723        kept += 1;
724    }
725    kept
726}
727
728/// Fits `natural` (fully-untruncated) column widths into `available_width`.
729///
730/// `no_truncate` columns are never shrunk (they keep their natural width
731/// unconditionally — that's the whole point of the flag) and their width is
732/// reserved out of the budget up front. The remaining columns are never
733/// shrunk below their header length, and share whatever budget is left
734/// beyond that, smallest-need-first, so a column that wants only a little
735/// gets exactly that instead of an equal-but-wasteful split.
736///
737/// Returns the fitted widths and whether any truncatable column ended up
738/// narrower than its natural width (i.e. some cell will actually be cut).
739fn fit_column_widths(
740    headers: &[usize],
741    natural: &[usize],
742    no_truncate: &[bool],
743    available_width: usize,
744) -> (Vec<usize>, bool) {
745    let mut widths = natural.to_vec();
746    let truncatable: Vec<usize> = (0..no_truncate.len())
747        .filter(|&index| !no_truncate[index])
748        .collect();
749    if truncatable.is_empty() {
750        return (widths, false);
751    }
752    let gutters = COLUMN_GUTTER * headers.len().saturating_sub(1);
753    let reserved: usize = (0..no_truncate.len())
754        .filter(|&index| no_truncate[index])
755        .map(|index| natural[index])
756        .sum();
757    let budget = available_width
758        .saturating_sub(gutters)
759        .saturating_sub(reserved);
760    let header_floor: usize = truncatable.iter().map(|&index| headers[index]).sum();
761    for &index in &truncatable {
762        widths[index] = headers[index];
763    }
764    let mut leftover = budget.saturating_sub(header_floor);
765    let mut needy: Vec<usize> = truncatable
766        .iter()
767        .copied()
768        .filter(|&index| natural[index] > headers[index])
769        .collect();
770    needy.sort_by_key(|&index| natural[index] - headers[index]);
771    // Smallest-need-first, take exactly what's wanted or whatever's left,
772    // whichever is less. Deliberately not an even split of `leftover` across
773    // the remaining columns: dividing first and taking `min(wants, share)`
774    // can floor a small want to zero when `leftover < remaining columns`,
775    // denying it entirely while a later, greedier column absorbs the
776    // remainder — worse than just letting small wants claim what they need
777    // outright before anyone larger gets a turn.
778    for &index in &needy {
779        let wants = natural[index] - headers[index];
780        let take = wants.min(leftover);
781        widths[index] += take;
782        leftover -= take;
783    }
784    let truncated = truncatable
785        .iter()
786        .any(|&index| widths[index] < natural[index]);
787    (widths, truncated)
788}
789
790fn render_array_with_columns(
791    items: &[Value],
792    columns: &[TableColumn],
793    available_width: usize,
794    pagination: Option<&PaginationMeta>,
795) -> (String, RenderNotes) {
796    if items.is_empty() || columns.is_empty() {
797        // Empty columns happens when every item is `{}` (the no-view
798        // dynamic catalog has no keys to show) or a view's `--fields`
799        // filtered out every declared column — either way there's nothing
800        // to build a table from, so fall back to the same message used for
801        // no items at all rather than rendering a blank header/rows table.
802        return ("(no results)\n".to_owned(), RenderNotes::default());
803    }
804    if !items.iter().all(Value::is_object) {
805        return (render_array_lines(items), RenderNotes::default());
806    }
807    // Natural widths (and rows) are computed for every original column
808    // before deciding what to hide: a `no_truncate` column never shrinks
809    // below its natural width, so the hiding decision has to know that real
810    // requirement — using just its header length here could keep a
811    // low-priority trailing column that would never have fit anyway,
812    // producing an overflow that hiding it would have avoided.
813    let header_lens: Vec<usize> = columns.iter().map(|column| column.header.len()).collect();
814    let no_truncate_all: Vec<bool> = columns.iter().map(|column| column.no_truncate).collect();
815    let mut natural = header_lens.clone();
816    let rows: Vec<Vec<String>> = items
817        .iter()
818        .map(|item| {
819            columns
820                .iter()
821                .enumerate()
822                .map(|(index, column)| {
823                    let value = item
824                        .as_object()
825                        .and_then(|map| resolve_field_path(map, &column.field))
826                        .map_or_else(String::new, format_value);
827                    let cap = if column.no_truncate {
828                        NO_TRUNCATE_MAX_WIDTH
829                    } else {
830                        usize::MAX
831                    };
832                    natural[index] = natural[index].max(value.len().min(cap));
833                    value
834                })
835                .collect::<Vec<_>>()
836        })
837        .collect();
838
839    let min_widths: Vec<usize> = (0..columns.len())
840        .map(|index| {
841            if no_truncate_all[index] {
842                natural[index]
843            } else {
844                header_lens[index]
845            }
846        })
847        .collect();
848    let mut kept = columns_fitting_width(&min_widths, available_width);
849
850    // Hiding a column is preferred over truncating a cell: if the survivors
851    // still don't fit their natural width, keep dropping the lowest-priority
852    // one and re-fitting, until either everyone remaining fits in full or
853    // only one column is left (which always stays, however it fits).
854    let (fitted, truncated) = loop {
855        let (fitted, truncated) = fit_column_widths(
856            &header_lens[..kept],
857            &natural[..kept],
858            &no_truncate_all[..kept],
859            available_width,
860        );
861        if !truncated || kept <= 1 {
862            break (fitted, truncated);
863        }
864        kept -= 1;
865    };
866
867    let hidden_columns = columns[kept..]
868        .iter()
869        .map(|column| column.header.clone())
870        .collect::<Vec<_>>();
871    let columns = &columns[..kept];
872    let rows: Vec<Vec<String>> = rows
873        .into_iter()
874        .map(|row| row.into_iter().take(kept).collect())
875        .collect();
876
877    let table = render_table(
878        &columns
879            .iter()
880            .map(|column| column.header.clone())
881            .collect::<Vec<_>>(),
882        &fitted,
883        &columns
884            .iter()
885            .map(|column| column.align)
886            .collect::<Vec<_>>(),
887        &rows,
888        pagination,
889    );
890    (
891        table,
892        RenderNotes {
893            truncated,
894            hidden_columns,
895            nested_narrowing: false,
896            pagination_shown: pagination.is_some(),
897        },
898    )
899}
900
901fn render_object_with_columns(
902    map: &serde_json::Map<String, Value>,
903    columns: &[TableColumn],
904    available_width: usize,
905) -> (String, RenderNotes) {
906    if map.is_empty() || columns.is_empty() {
907        // Empty columns happens the same way it does in
908        // `render_array_with_columns`: a view's `--fields` filtered out
909        // every declared column. Nothing to render either way, so this
910        // reports the same "(no data)" a genuinely empty object gets,
911        // rather than an unlabeled blank line.
912        return ("(no data)\n".to_owned(), RenderNotes::default());
913    }
914    let mut out = String::new();
915    let mut notes = RenderNotes::default();
916    for column in columns {
917        let value = resolve_field_path(map, &column.field);
918        match (&column.nested, value) {
919            (Some(nested_columns), Some(value)) if is_nestable(value) => {
920                out.push_str(&format!("{}:\n", column.header));
921                let child_width = available_width.saturating_sub(NESTED_INDENT.len());
922                let nested_pagination = match value {
923                    Value::Array(_) => {
924                        resolve_field_parent(map, &column.field).and_then(resolve_nested_pagination)
925                    }
926                    _ => None,
927                };
928                let (block, child_notes) = render_nested_value(
929                    value,
930                    nested_columns,
931                    child_width,
932                    nested_pagination.as_ref(),
933                );
934                out.push_str(&indent_block(&block, NESTED_INDENT));
935                if child_notes.truncated
936                    || !child_notes.hidden_columns.is_empty()
937                    || child_notes.nested_narrowing
938                {
939                    notes.nested_narrowing = true;
940                }
941                notes.truncated |= child_notes.truncated;
942                notes.hidden_columns.extend(
943                    child_notes
944                        .hidden_columns
945                        .into_iter()
946                        .map(|hidden| format!("{} > {hidden}", column.header)),
947                );
948            }
949            (_, value) => {
950                let value_str = value.map_or_else(String::new, format_value);
951                out.push_str(&format!("{}: {value_str}\n", column.header));
952            }
953        }
954    }
955    (out, notes)
956}
957
958fn render_array(
959    items: &[Value],
960    fields: &str,
961    available_width: usize,
962    pagination: Option<&PaginationMeta>,
963) -> (String, RenderNotes) {
964    if items.is_empty() {
965        return ("(no results)\n".to_owned(), RenderNotes::default());
966    }
967    let Some(Value::Object(first_map)) = items.first() else {
968        return (render_array_lines(items), RenderNotes::default());
969    };
970    if !items.iter().all(Value::is_object) {
971        return (render_array_lines(items), RenderNotes::default());
972    }
973    let columns: Vec<TableColumn> = dynamic_columns(fields, || first_map.keys().cloned().collect())
974        .into_iter()
975        .map(|column| {
976            if column_is_all_numeric(items, &column.field) {
977                column.align(Alignment::Right)
978            } else {
979                column
980            }
981        })
982        .collect();
983    render_array_with_columns(items, &columns, available_width, pagination)
984}
985
986fn render_array_lines(items: &[Value]) -> String {
987    let mut out = String::new();
988    for item in items {
989        out.push_str(&format!("{}\n", format_plain_value(item)));
990    }
991    out
992}
993
994/// Pads `text` to `width`, on the left for `Alignment::Right` and on the
995/// right otherwise — matching how the header row is padded so a column's
996/// header and cells share the same alignment.
997fn pad_column(text: &str, width: usize, alignment: Alignment) -> String {
998    match alignment {
999        Alignment::Left => format!("{text:<width$}"),
1000        Alignment::Right => format!("{text:>width$}"),
1001    }
1002}
1003
1004fn render_table(
1005    headers: &[String],
1006    widths: &[usize],
1007    alignments: &[Alignment],
1008    rows: &[Vec<String>],
1009    pagination: Option<&PaginationMeta>,
1010) -> String {
1011    let mut out = String::new();
1012    for (index, header) in headers.iter().enumerate() {
1013        if index > 0 {
1014            out.push_str("  ");
1015        }
1016        out.push_str(&pad_column(
1017            &header.to_uppercase(),
1018            widths[index],
1019            alignments[index],
1020        ));
1021    }
1022    out.push('\n');
1023    for (index, width) in widths.iter().enumerate() {
1024        if index > 0 {
1025            out.push_str("  ");
1026        }
1027        out.push_str(&"-".repeat(*width));
1028    }
1029    out.push('\n');
1030    for row in rows {
1031        for (index, value) in row.iter().enumerate() {
1032            if index > 0 {
1033                out.push_str("  ");
1034            }
1035            out.push_str(&pad_column(
1036                &truncate(value, widths[index]),
1037                widths[index],
1038                alignments[index],
1039            ));
1040        }
1041        out.push('\n');
1042    }
1043    // Merge the pagination facts into this footer rather than letting
1044    // `append_pagination_summary` print a second, redundant line right below
1045    // it — both would otherwise state the same shown/total count. The shown
1046    // count comes from `rows.len()`, not `pagination.count`: a later
1047    // pipeline step (`--expr`) can still reshape `envelope.data` after
1048    // pagination ran, so `rows.len()` is what's actually rendered above,
1049    // while `total`/`offset`/`limit` stay pagination's own facts.
1050    match pagination {
1051        Some(pagination) => out.push_str(&format!(
1052            "\n({} of {} rows, offset {}, limit {})\n",
1053            rows.len(),
1054            pagination.total,
1055            pagination.offset,
1056            pagination.limit
1057        )),
1058        None => out.push_str(&format!("\n({} rows)\n", rows.len())),
1059    }
1060    out
1061}
1062
1063/// Resolves a column's (possibly dotted) field path against an object,
1064/// walking down through nested objects one segment at a time — e.g.
1065/// `"parameters.items"` reaches `map["parameters"]["items"]`.
1066///
1067/// Returns `None` when: `field` is empty; any segment (including a
1068/// leading/trailing/doubled `.`) is empty; an intermediate or leaf segment is
1069/// missing; or an intermediate segment's value is not an object. The leaf
1070/// segment's value is returned as-is whatever its `Value` variant is —
1071/// callers decide what to do with that.
1072fn resolve_field_path<'value>(
1073    map: &'value serde_json::Map<String, Value>,
1074    field: &str,
1075) -> Option<&'value Value> {
1076    let mut segments = field.split('.');
1077    let first = segments.next()?;
1078    if first.is_empty() {
1079        return None;
1080    }
1081    let mut current = map.get(first)?;
1082    for segment in segments {
1083        if segment.is_empty() {
1084            return None;
1085        }
1086        current = current.as_object()?.get(segment)?;
1087    }
1088    Some(current)
1089}
1090
1091/// Resolves the object that directly contains `field`'s leaf segment — e.g.
1092/// for `"parameters.items"`, the object at `"parameters"` (the one whose keys
1093/// include `"items"` as a direct child). A field with no `.` has `map` itself
1094/// as its parent, since the leaf is already one of `map`'s direct keys.
1095///
1096/// Used to reach a nested array's `pagination` sibling (see
1097/// [`resolve_nested_pagination`]) that `resolve_field_path` alone can't see,
1098/// since that function only ever returns the leaf.
1099fn resolve_field_parent<'value>(
1100    map: &'value serde_json::Map<String, Value>,
1101    field: &str,
1102) -> Option<&'value serde_json::Map<String, Value>> {
1103    match field.rsplit_once('.') {
1104        None => Some(map),
1105        Some((parent_path, _leaf)) => resolve_field_path(map, parent_path)?.as_object(),
1106    }
1107}
1108
1109/// Resolves a `pagination` field on `parent` — the same object that directly
1110/// contains a [`TableColumn::nested`] column's array — as a [`PaginationMeta`],
1111/// so nested tables get the exact same `"(N of M rows, offset O, limit L)"`
1112/// footer a top-level paginated array gets.
1113fn resolve_nested_pagination(parent: &serde_json::Map<String, Value>) -> Option<PaginationMeta> {
1114    serde_json::from_value(parent.get("pagination")?.clone()).ok()
1115}
1116
1117/// Prefixes every non-empty line of `block` with `indent`, leaving blank
1118/// lines (e.g. the blank line before a table's `(N rows)` footer) bare so no
1119/// line ever carries trailing-whitespace-only indent. Round-trips a block's
1120/// existing single-trailing-newline convention.
1121fn indent_block(block: &str, indent: &str) -> String {
1122    block
1123        .lines()
1124        .map(|line| {
1125            if line.is_empty() {
1126                line.to_owned()
1127            } else {
1128                format!("{indent}{line}")
1129            }
1130        })
1131        .collect::<Vec<_>>()
1132        .join("\n")
1133        + "\n"
1134}
1135
1136/// Whether `value` is a shape [`TableColumn::nested`] can render as a child
1137/// block: a single object, or an array whose items are all objects (an empty
1138/// array trivially qualifies, rendering as an indented "no results"). Gates
1139/// entry into nested rendering in [`render_object_with_columns`] so a column
1140/// with `.nested(...)` set is a true no-op — the exact same single-line
1141/// `format_value` rendering an un-opted-in column would have produced —
1142/// whenever the runtime value doesn't actually have this shape (a scalar, or
1143/// an array mixing objects with non-objects).
1144fn is_nestable(value: &Value) -> bool {
1145    matches!(value, Value::Object(_))
1146        || matches!(value, Value::Array(items) if items.iter().all(Value::is_object))
1147}
1148
1149/// Renders a nested column's resolved value as a child block, reusing the
1150/// same renderers a top-level array/object would use, just at a narrowed
1151/// width. Only called once [`is_nestable`] has confirmed `value`'s shape, so
1152/// the array/object arms below are the only ones a real caller reaches; the
1153/// scalar fallback keeps this function total on its own.
1154fn render_nested_value(
1155    value: &Value,
1156    nested_columns: &[TableColumn],
1157    available_width: usize,
1158    pagination: Option<&PaginationMeta>,
1159) -> (String, RenderNotes) {
1160    match value {
1161        Value::Array(items) => {
1162            render_array_with_columns(items, nested_columns, available_width, pagination)
1163        }
1164        Value::Object(map) => render_object_with_columns(map, nested_columns, available_width),
1165        other => (format!("{}\n", format_value(other)), RenderNotes::default()),
1166    }
1167}
1168
1169fn format_value(value: &Value) -> String {
1170    match value {
1171        Value::Null => String::new(),
1172        Value::Bool(true) => "yes".to_owned(),
1173        Value::Bool(false) => "no".to_owned(),
1174        Value::Number(number) => format_number(number),
1175        Value::String(value) => value.clone(),
1176        Value::Array(items) => items
1177            .iter()
1178            .map(format_value)
1179            .collect::<Vec<_>>()
1180            .join(", "),
1181        Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned()),
1182    }
1183}
1184
1185fn format_plain_value(value: &Value) -> String {
1186    match value {
1187        Value::Null => "<nil>".to_owned(),
1188        Value::Bool(value) => value.to_string(),
1189        Value::Number(number) => format_number(number),
1190        Value::String(value) => value.clone(),
1191        Value::Array(items) => {
1192            let values = items
1193                .iter()
1194                .map(format_plain_value)
1195                .collect::<Vec<_>>()
1196                .join(" ");
1197            format!("[{values}]")
1198        }
1199        Value::Object(object) => {
1200            let mut pairs = object
1201                .iter()
1202                .map(|(key, value)| (key.clone(), value.clone()))
1203                .collect::<Vec<_>>();
1204            pairs.sort_by(|left, right| left.0.cmp(&right.0));
1205            let object = pairs
1206                .into_iter()
1207                .collect::<serde_json::Map<String, Value>>();
1208            serde_json::to_string(&Value::Object(object)).unwrap_or_else(|_| "{}".to_owned())
1209        }
1210    }
1211}
1212
1213fn truncate(value: &str, width: usize) -> String {
1214    if value.len() <= width {
1215        return value.to_owned();
1216    }
1217    if width <= 3 {
1218        return value.chars().take(width).collect();
1219    }
1220    let mut out = value.chars().take(width - 3).collect::<String>();
1221    out.push_str("...");
1222    out
1223}
1224
1225fn format_number(number: &serde_json::Number) -> String {
1226    number.to_string()
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232    use serde_json::json;
1233
1234    #[test]
1235    fn format_plain_value_round_trips_a_bare_string_verbatim() {
1236        // No quoting/escaping — the exact convention `raw_output` bypass
1237        // relies on to render a `CommandResult` string byte-for-byte.
1238        assert_eq!(
1239            format_plain_value(&Value::String("some\nverbatim\ntext".to_owned())),
1240            "some\nverbatim\ntext"
1241        );
1242    }
1243
1244    #[test]
1245    fn human_output_appends_next_steps_footer() {
1246        let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain")
1247            .with_next_actions(vec![NextAction::new(
1248                "domain purchase --quote-token <token> --agree --confirm",
1249                "Register at the quoted price",
1250            )]);
1251        let out = render_human(&envelope);
1252        // Data still renders as before…
1253        assert!(out.contains("domain: example.com"), "{out}");
1254        // …followed by a Next steps footer with the command and its description.
1255        assert!(out.contains("\nNext steps:\n"), "{out}");
1256        assert!(
1257            out.contains("domain purchase --quote-token <token> --agree --confirm"),
1258            "{out}"
1259        );
1260        assert!(out.contains("Register at the quoted price"), "{out}");
1261    }
1262
1263    #[test]
1264    fn human_output_substitutes_known_next_action_params() {
1265        let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain")
1266            .with_next_actions(vec![
1267                NextAction::new(
1268                    "domain purchase --quote-token <quote-token> --agree --confirm",
1269                    "Register at the quoted price",
1270                )
1271                .with_param("quote-token", NextActionParam::value("abc-123")),
1272            ]);
1273        let out = render_human(&envelope);
1274        assert!(
1275            out.contains("domain purchase --quote-token abc-123 --agree --confirm"),
1276            "{out}"
1277        );
1278        assert!(!out.contains("<quote-token>"), "{out}");
1279    }
1280
1281    #[test]
1282    fn human_output_leaves_placeholder_without_a_known_value() {
1283        let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain")
1284            .with_next_actions(vec![
1285                NextAction::new("domain quote <domain>", "Price a registration")
1286                    .with_param("domain", NextActionParam::required()),
1287            ]);
1288        let out = render_human(&envelope);
1289        assert!(out.contains("domain quote <domain>"), "{out}");
1290    }
1291
1292    #[test]
1293    fn human_output_has_no_footer_without_next_actions() {
1294        let envelope = Envelope::success(json!({ "domain": "example.com" }), "domain");
1295        let out = render_human(&envelope);
1296        assert!(out.contains("domain: example.com"), "{out}");
1297        assert!(
1298            !out.contains("Next steps"),
1299            "no footer when there are no actions: {out}"
1300        );
1301    }
1302
1303    #[test]
1304    fn error_output_has_no_next_steps_footer() {
1305        // An error envelope carries no next_actions and must render only the error.
1306        let envelope = Envelope::error("ERROR", "boom", "domain");
1307        let out = render_human(&envelope);
1308        assert!(out.starts_with("Error:"), "{out}");
1309        assert!(!out.contains("Next steps"), "{out}");
1310        assert!(!out.contains("Fix:"), "{out}");
1311    }
1312
1313    #[test]
1314    fn error_output_appends_fix_line() {
1315        let envelope =
1316            Envelope::error("AUTH_REQUIRED", "not logged in", "auth").with_fix("Run auth login");
1317        let out = render_human(&envelope);
1318        assert_eq!(out, "Error: not logged in\nFix: Run auth login\n");
1319    }
1320
1321    #[test]
1322    fn no_truncate_column_keeps_long_values_intact() {
1323        let long_url = "https://example.com/legal/agreements/registration-agreement-v2";
1324        assert!(long_url.len() > 40, "fixture must exceed the default cap");
1325        let items = vec![json!({ "title": long_url, "url": long_url })];
1326        let columns = vec![
1327            // Declared first (higher priority) so it survives hide-before-
1328            // truncate rather than the lower-priority title column
1329            // absorbing truncation instead — with only two columns, any
1330            // truncation now cascades to hiding the lower-priority one.
1331            TableColumn::new("url", "URL").no_truncate(true),
1332            TableColumn::new("title", "Title"),
1333        ];
1334
1335        let (out, notes) = render_array_with_columns(&items, &columns, 80, None);
1336
1337        assert!(
1338            out.contains(long_url),
1339            "no_truncate column must keep the full value: {out}"
1340        );
1341        assert!(
1342            !out.contains("..."),
1343            "hiding the lower-priority column avoided any truncation: {out}"
1344        );
1345        assert_eq!(
1346            notes.hidden_columns,
1347            vec!["Title".to_owned()],
1348            "the lower-priority truncatable column is hidden rather than shown truncated: {out}"
1349        );
1350    }
1351
1352    #[test]
1353    fn no_truncate_column_still_caps_pathologically_long_values() {
1354        let huge_value = "x".repeat(NO_TRUNCATE_MAX_WIDTH * 2);
1355        let items = vec![json!({ "url": huge_value })];
1356        let columns = vec![TableColumn::new("url", "URL").no_truncate(true)];
1357
1358        let (out, _notes) = render_array_with_columns(&items, &columns, 80, None);
1359
1360        assert!(
1361            out.contains("..."),
1362            "values far beyond the no_truncate cap should still be truncated: {out}"
1363        );
1364        assert!(
1365            !out.contains(&huge_value),
1366            "the full pathological value should not be rendered verbatim: {out}"
1367        );
1368    }
1369
1370    #[test]
1371    fn right_aligned_column_pads_header_and_cells_on_the_left() {
1372        let items = vec![
1373            json!({ "period": "1 year", "price": "71.99" }),
1374            json!({ "period": "2 years", "price": "143.99" }),
1375        ];
1376        let columns = vec![
1377            TableColumn::new("period", "Period"),
1378            TableColumn::new("price", "Price").align(Alignment::Right),
1379        ];
1380
1381        let (out, _notes) = render_array_with_columns(&items, &columns, 80, None);
1382        let mut lines = out.lines();
1383        let header_line = lines.next().expect("header line");
1384        let row_lines: Vec<&str> = lines.skip(1).take(2).collect();
1385
1386        // "PRICE" (5 chars) right-aligned in a 6-wide column ("143.99")
1387        // leaves one leading space and no trailing space.
1388        assert!(header_line.ends_with(" PRICE"), "{header_line}");
1389        assert!(row_lines[0].ends_with(" 71.99"), "{}", row_lines[0]);
1390        assert!(row_lines[1].ends_with("143.99"), "{}", row_lines[1]);
1391        // The unaligned leading column is untouched (still left-aligned).
1392        assert!(header_line.starts_with("PERIOD "), "{header_line}");
1393    }
1394
1395    #[test]
1396    fn column_alignment_defaults_to_left() {
1397        let items = vec![json!({ "name": "a" }), json!({ "name": "bb" })];
1398        let columns = vec![TableColumn::new("name", "Name")];
1399
1400        let (out, _notes) = render_array_with_columns(&items, &columns, 80, None);
1401        let mut lines = out.lines();
1402        let header_line = lines.next().expect("header line");
1403
1404        assert!(
1405            header_line.starts_with("NAME"),
1406            "Alignment::Left is the default: {header_line}"
1407        );
1408    }
1409
1410    #[test]
1411    fn column_width_never_shrinks_below_a_long_header() {
1412        let long_header = "A Very Long Header That Exceeds The Default Width Cap";
1413        let items = vec![json!({ "field": "short" })];
1414        let columns = vec![TableColumn::new("field", long_header)];
1415
1416        // Deliberately far narrower than the header: the header must still
1417        // render in full even though the row ends up wider than the terminal.
1418        let (out, _notes) = render_array_with_columns(&items, &columns, 10, None);
1419        let header_line = out.lines().next().expect("header line");
1420        let separator_line = out.lines().nth(1).expect("separator line");
1421
1422        assert_eq!(
1423            header_line.len(),
1424            separator_line.len(),
1425            "header and separator must stay aligned even when the header alone exceeds the terminal: {out}"
1426        );
1427        assert!(
1428            header_line.len() >= long_header.len(),
1429            "header must not be cut short: {out}"
1430        );
1431    }
1432
1433    #[test]
1434    fn wide_terminal_shows_full_values_without_truncation() {
1435        let description = "a description that is well past the old forty-character cap";
1436        assert!(description.len() > 40, "fixture must exceed the old cap");
1437        let items = vec![json!({ "id": "1", "description": description })];
1438        let columns = vec![
1439            TableColumn::new("id", "ID"),
1440            TableColumn::new("description", "Description"),
1441        ];
1442
1443        let (out, notes) = render_array_with_columns(&items, &columns, 200, None);
1444
1445        assert!(
1446            !notes.truncated,
1447            "plenty of room, nothing to shorten: {out}"
1448        );
1449        assert!(notes.hidden_columns.is_empty(), "{out}");
1450        assert!(out.contains(description), "{out}");
1451        assert!(!out.contains("..."), "{out}");
1452    }
1453
1454    #[test]
1455    fn narrow_terminal_truncates_and_reports_it() {
1456        // A single column whose value is far longer than the terminal
1457        // allows: there's nothing else to hide (hide-before-truncate has no
1458        // lower-priority column to drop), so truncation is the only option
1459        // and it must still be reported.
1460        let description = "a description that is well past the old forty-character cap";
1461        let items = vec![json!({ "description": description })];
1462        let columns = vec![TableColumn::new("description", "Description")];
1463
1464        let (out, notes) = render_array_with_columns(&items, &columns, 20, None);
1465
1466        assert!(
1467            notes.truncated,
1468            "narrow terminal must shorten a cell: {out}"
1469        );
1470        assert!(
1471            notes.hidden_columns.is_empty(),
1472            "only one column exists to begin with: {out}"
1473        );
1474        assert!(out.contains("..."), "{out}");
1475    }
1476
1477    #[test]
1478    fn narrow_terminal_hides_columns_before_truncating_any_of_the_survivors() {
1479        // Three equally-competing columns: at this width, showing all three
1480        // (or even two) would require truncating every survivor a little.
1481        // Hide-before-truncate should instead cascade down to the single
1482        // highest-priority column and show it in full.
1483        let items = vec![json!({ "a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5) })];
1484        let columns = vec![
1485            TableColumn::new("a", "A"),
1486            TableColumn::new("b", "B"),
1487            TableColumn::new("c", "C"),
1488        ];
1489
1490        let (out, notes) = render_array_with_columns(&items, &columns, 10, None);
1491
1492        assert!(
1493            !notes.truncated,
1494            "hiding B and C should leave A fully shown, untruncated: {out}"
1495        );
1496        assert_eq!(
1497            notes.hidden_columns,
1498            vec!["B".to_owned(), "C".to_owned()],
1499            "should cascade down to the single highest-priority column: {out}"
1500        );
1501        assert!(!out.contains("..."), "{out}");
1502    }
1503
1504    #[test]
1505    fn overflow_hides_lowest_priority_columns_first() {
1506        let items = vec![json!({
1507            "id": "1",
1508            "name": "acme",
1509            "status": "active",
1510            "created_at": "2026-01-01",
1511        })];
1512        let columns = vec![
1513            TableColumn::new("id", "ID"),
1514            TableColumn::new("name", "Name"),
1515            TableColumn::new("status", "Status"),
1516            TableColumn::new("created_at", "Created At"),
1517        ];
1518
1519        let (out, notes) = render_array_with_columns(&items, &columns, 10, None);
1520
1521        assert_eq!(
1522            notes.hidden_columns,
1523            vec!["Status".to_owned(), "Created At".to_owned()],
1524            "lowest-priority (trailing) columns are dropped first: {out}"
1525        );
1526        let header_line = out.lines().next().expect("header line");
1527        assert!(header_line.contains("ID"), "{out}");
1528        assert!(header_line.contains("NAME"), "{out}");
1529        assert!(!header_line.contains("STATUS"), "{out}");
1530        assert!(!header_line.contains("CREATED"), "{out}");
1531    }
1532
1533    #[test]
1534    fn render_human_with_view_reports_hidden_columns_in_footer() {
1535        let envelope = Envelope::success(
1536            json!([{
1537                "id": "1",
1538                "name": "acme",
1539                "status": "active",
1540                "region": "us-west",
1541                "created_at": "2026-01-01",
1542                "updated_at": "2026-01-02",
1543                "notes": "irrelevant, lowest priority",
1544            }]),
1545            "resource",
1546        );
1547        let columns = vec![
1548            TableColumn::new("id", "ID"),
1549            TableColumn::new("name", "Name"),
1550            TableColumn::new("status", "Status"),
1551            TableColumn::new("region", "Region"),
1552            TableColumn::new("created_at", "Created At"),
1553            TableColumn::new("updated_at", "Updated At"),
1554            // Deliberately long enough that, combined with the columns above,
1555            // it can't fit alongside them at the fallback 80-column width.
1556            TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"),
1557        ];
1558
1559        // In test runs stdout is not a TTY, so `terminal_width()` deterministically
1560        // falls back to 80 — these headers don't all fit at that width.
1561        let out = render_human_with_view(&envelope, Some(&columns), "");
1562
1563        assert!(out.contains("hidden to fit the display width"), "{out}");
1564        assert!(
1565            out.contains("This Is An Extremely Long Trailing Column Header"),
1566            "{out}"
1567        );
1568        assert!(out.contains("--fields"), "{out}");
1569        assert!(out.contains("--json"), "{out}");
1570    }
1571
1572    #[test]
1573    fn select_columns_orders_by_requested_fields_not_declared_order() {
1574        let columns = vec![
1575            TableColumn::new("id", "ID"),
1576            TableColumn::new("name", "Name"),
1577            TableColumn::new("status", "Status"),
1578        ];
1579
1580        let selected = select_columns(&columns, "status,id");
1581
1582        assert_eq!(
1583            selected
1584                .iter()
1585                .map(|c| c.field.as_str())
1586                .collect::<Vec<_>>(),
1587            vec!["status", "id"],
1588            "order should follow the requested fields, not declaration order"
1589        );
1590    }
1591
1592    #[test]
1593    fn select_columns_dedupes_and_skips_unknown_fields() {
1594        let columns = vec![
1595            TableColumn::new("id", "ID"),
1596            TableColumn::new("name", "Name"),
1597            TableColumn::new("status", "Status"),
1598        ];
1599
1600        let selected = select_columns(&columns, "status,bogus,status,id");
1601
1602        assert_eq!(
1603            selected
1604                .iter()
1605                .map(|c| c.field.as_str())
1606                .collect::<Vec<_>>(),
1607            vec!["status", "id"],
1608            "duplicates collapse to first occurrence; unknown fields are dropped"
1609        );
1610    }
1611
1612    #[test]
1613    fn dynamic_columns_orders_by_requested_fields() {
1614        let columns = dynamic_columns("price1Year,domain", || {
1615            vec![
1616                "domain".to_owned(),
1617                "currency".to_owned(),
1618                "price1Year".to_owned(),
1619            ]
1620        });
1621
1622        assert_eq!(
1623            columns.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
1624            vec!["price1Year", "domain"]
1625        );
1626    }
1627
1628    #[test]
1629    fn dynamic_columns_falls_back_to_alphabetical_without_fields() {
1630        let columns = dynamic_columns("", || vec!["currency".to_owned(), "domain".to_owned()]);
1631
1632        assert_eq!(
1633            columns.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
1634            vec!["currency", "domain"],
1635            "no fields signal at all: alphabetical is the only order available"
1636        );
1637    }
1638
1639    #[test]
1640    fn no_view_array_rendering_right_aligns_a_column_that_is_numeric_on_every_row() {
1641        let items = vec![
1642            json!({ "name": "small", "count": 3 }),
1643            json!({ "name": "bigger", "count": 42 }),
1644        ];
1645
1646        let (out, _notes) = render_array(&items, "name,count", 80, None);
1647        let mut lines = out.lines();
1648        let header_line = lines.next().expect("header line");
1649        let row_lines: Vec<&str> = lines.skip(1).take(2).collect();
1650
1651        assert!(header_line.ends_with(" COUNT"), "{header_line}");
1652        assert!(row_lines[0].ends_with("   3"), "{}", row_lines[0]);
1653        assert!(row_lines[1].ends_with("  42"), "{}", row_lines[1]);
1654        assert!(header_line.starts_with("NAME "), "{header_line}");
1655    }
1656
1657    #[test]
1658    fn no_view_array_rendering_keeps_a_mixed_type_column_left_aligned() {
1659        // Same field is a number on one row and a string on another — a
1660        // single non-number value anywhere disqualifies the whole column,
1661        // matching how right-aligning it would look ragged next to text.
1662        let items = vec![json!({ "code": 1 }), json!({ "code": "default" })];
1663
1664        let (out, _notes) = render_array(&items, "", 80, None);
1665        let header_line = out.lines().next().expect("header line");
1666
1667        assert!(header_line.starts_with("CODE"), "{header_line}");
1668    }
1669
1670    #[test]
1671    fn no_view_array_rendering_keeps_an_all_null_column_left_aligned() {
1672        // No row ever has a number at this field, so there's no positive
1673        // signal to right-align on.
1674        let items = vec![json!({ "note": null }), json!({ "note": null })];
1675
1676        let (out, _notes) = render_array(&items, "", 80, None);
1677        let header_line = out.lines().next().expect("header line");
1678
1679        assert!(header_line.starts_with("NOTE"), "{header_line}");
1680    }
1681
1682    #[test]
1683    fn no_view_array_rendering_follows_requested_field_order() {
1684        // Reproduces the real-world `domain suggest` symptom: a command with
1685        // no registered view whose default_fields lists `domain` first must
1686        // not silently reorder it after `currency` just because "c" < "d".
1687        let envelope = Envelope::success(
1688            json!([{ "domain": "example.com", "currency": "USD", "price1Year": "12.99" }]),
1689            "domain:suggest",
1690        );
1691        let registry = HumanViewRegistry::new();
1692
1693        let rendered = render_human_with_registry_selected(
1694            &envelope,
1695            &registry,
1696            "domain:suggest",
1697            "domain,price1Year,currency",
1698        );
1699
1700        let header_line = rendered.lines().next().expect("header line");
1701        assert!(header_line.contains("DOMAIN"), "{rendered}");
1702        let domain_pos = header_line.find("DOMAIN").expect("domain header");
1703        let price_pos = header_line.find("PRICE1YEAR").expect("price1Year header");
1704        let currency_pos = header_line.find("CURRENCY").expect("currency header");
1705        assert!(
1706            domain_pos < price_pos && price_pos < currency_pos,
1707            "expected DOMAIN, PRICE1YEAR, CURRENCY in that order: {header_line}"
1708        );
1709    }
1710
1711    #[test]
1712    fn registered_view_rendering_follows_requested_field_order() {
1713        let mut registry = HumanViewRegistry::new();
1714        registry.register(HumanViewDef::new(
1715            "things",
1716            vec![
1717                TableColumn::new("id", "ID"),
1718                TableColumn::new("name", "Name"),
1719                TableColumn::new("status", "Status"),
1720            ],
1721        ));
1722        let envelope = Envelope::success(
1723            json!([{ "id": "1", "name": "acme", "status": "active" }]),
1724            "things",
1725        );
1726
1727        let rendered =
1728            render_human_with_registry_selected(&envelope, &registry, "things", "status,id");
1729
1730        let header_line = rendered.lines().next().expect("header line");
1731        assert!(!header_line.contains("NAME"), "{rendered}");
1732        let status_pos = header_line.find("STATUS").expect("status header");
1733        let id_pos = header_line.find("ID").expect("id header");
1734        assert!(
1735            status_pos < id_pos,
1736            "expected STATUS before ID per the requested field order: {header_line}"
1737        );
1738    }
1739
1740    #[test]
1741    fn fit_column_widths_gives_small_wants_priority_over_larger_ones() {
1742        // Regression: a naive `leftover / remaining` split can floor a small
1743        // want to zero (denying a column that needed only 1 more char)
1744        // while a much larger want absorbs that same unit and stays
1745        // truncated anyway — net truncation is identical, but a column that
1746        // could have been fully satisfied wasn't.
1747        let headers = [1, 1, 1];
1748        let natural = [2, 2, 6]; // wants: 1, 1, 5
1749        let no_truncate = [false, false, false];
1750
1751        let (widths, truncated) = fit_column_widths(&headers, &natural, &no_truncate, 8);
1752
1753        assert_eq!(
1754            widths[0], natural[0],
1755            "a column that only wanted 1 more char should get it in full: {widths:?}"
1756        );
1757        assert!(truncated, "budget is still too small overall: {widths:?}");
1758    }
1759
1760    #[test]
1761    fn overflow_hiding_accounts_for_no_truncate_columns_true_width() {
1762        // Regression: deciding what to hide from header length alone
1763        // under-counts a `no_truncate` column (it never shrinks below its
1764        // natural width), which could keep a short-header trailing column
1765        // that would never have fit anyway — overflowing when hiding it
1766        // would have let the row fit.
1767        let url = "x".repeat(40);
1768        let items = vec![json!({ "url": url, "notes": "irrelevant, lowest priority" })];
1769        let columns = vec![
1770            TableColumn::new("url", "URL").no_truncate(true),
1771            TableColumn::new("notes", "X"),
1772        ];
1773
1774        // Exactly enough room for the URL alone (40 chars), not enough for
1775        // the URL plus even a 1-char trailing column and its gutter (43).
1776        let (out, notes) = render_array_with_columns(&items, &columns, 42, None);
1777
1778        assert_eq!(
1779            notes.hidden_columns,
1780            vec!["X".to_owned()],
1781            "the trailing column must be hidden so the no_truncate URL column fits: {out}"
1782        );
1783        let header_line = out.lines().next().expect("header line");
1784        assert!(
1785            header_line.len() <= 42,
1786            "must not overflow once the trailing column is hidden: {out}"
1787        );
1788    }
1789
1790    #[test]
1791    fn render_array_with_columns_handles_no_columns_gracefully() {
1792        // A view's `--fields` filtered out every declared column: nothing to
1793        // build a table from, so this must report "no results" rather than
1794        // a blank header/rows table.
1795        let items = vec![json!({ "a": "1" })];
1796        let (out, notes) = render_array_with_columns(&items, &[], 80, None);
1797
1798        assert_eq!(out, "(no results)\n");
1799        assert!(!notes.truncated, "{out}");
1800        assert!(notes.hidden_columns.is_empty(), "{out}");
1801    }
1802
1803    #[test]
1804    fn render_object_with_columns_handles_no_columns_gracefully() {
1805        // Sibling of the array-path test above (Copilot/human review caught
1806        // this asymmetry): a view's `--fields` filtered out every declared
1807        // column on an object-shaped response must report "(no data)"
1808        // rather than silently rendering an empty string.
1809        let map = json!({ "a": "1" });
1810        let (out, notes) =
1811            render_object_with_columns(map.as_object().expect("object fixture"), &[], 80);
1812
1813        assert_eq!(out, "(no data)\n");
1814        assert!(!notes.truncated, "{out}");
1815        assert!(notes.hidden_columns.is_empty(), "{out}");
1816    }
1817
1818    #[test]
1819    fn no_view_array_of_empty_objects_reports_no_results() {
1820        // Every item is `{}`, so the dynamic (no-view) column catalog has no
1821        // keys to derive columns from — same "no columns" case as above,
1822        // reached through the no-view path instead.
1823        let items = vec![json!({}), json!({})];
1824        let (out, notes) = render_array(&items, "", 80, None);
1825
1826        assert_eq!(out, "(no results)\n");
1827        assert!(notes.hidden_columns.is_empty(), "{out}");
1828    }
1829
1830    #[test]
1831    fn resolve_field_path_walks_dotted_wrapper_and_reports_missing_or_wrong_shape() {
1832        let map = json!({
1833            "parameters": { "items": [{"name": "limit"}], "total": 1 },
1834            "owner": "not-an-object",
1835        });
1836        let map = map.as_object().expect("object fixture");
1837
1838        assert_eq!(
1839            resolve_field_path(map, "parameters.items"),
1840            map.get("parameters").and_then(|value| value.get("items"))
1841        );
1842        assert_eq!(resolve_field_path(map, "parameters.missing"), None);
1843        assert_eq!(
1844            resolve_field_path(map, "owner.name"),
1845            None,
1846            "intermediate value is a string, not an object"
1847        );
1848        assert_eq!(resolve_field_path(map, "missing"), None);
1849        assert_eq!(resolve_field_path(map, ""), None, "empty field");
1850        assert_eq!(resolve_field_path(map, ".parameters"), None, "leading dot");
1851        assert_eq!(resolve_field_path(map, "parameters."), None, "trailing dot");
1852        assert_eq!(
1853            resolve_field_path(map, "parameters..items"),
1854            None,
1855            "doubled dot"
1856        );
1857    }
1858
1859    #[test]
1860    fn resolve_field_parent_returns_parent_object_for_dotted_and_bare_fields() {
1861        let map = json!({
1862            "parameters": { "items": [], "total": 2 },
1863            "owner": "not-an-object",
1864        });
1865        let map = map.as_object().expect("object fixture");
1866
1867        assert_eq!(
1868            resolve_field_parent(map, "parameters.items"),
1869            map.get("parameters").and_then(Value::as_object)
1870        );
1871        assert_eq!(
1872            resolve_field_parent(map, "items"),
1873            Some(map),
1874            "a field with no dot has the object being rendered as its own parent"
1875        );
1876        assert_eq!(
1877            resolve_field_parent(map, "owner.name"),
1878            None,
1879            "intermediate value is a string, not an object"
1880        );
1881        assert_eq!(resolve_field_parent(map, "missing.items"), None);
1882    }
1883
1884    #[test]
1885    fn resolve_nested_pagination_deserializes_a_pagination_meta_shaped_sibling() {
1886        let parent = json!({
1887            "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true },
1888        });
1889        let parent = parent.as_object().expect("object fixture");
1890
1891        let meta = resolve_nested_pagination(parent).expect("pagination sibling present");
1892        assert_eq!(
1893            meta,
1894            PaginationMeta {
1895                total: 26,
1896                offset: 0,
1897                limit: 2,
1898                count: 2,
1899                has_more: true,
1900            }
1901        );
1902    }
1903
1904    #[test]
1905    fn resolve_nested_pagination_is_none_when_the_sibling_is_absent_or_malformed() {
1906        let no_sibling = json!({ "items": [] });
1907        assert_eq!(
1908            resolve_nested_pagination(no_sibling.as_object().expect("object fixture")),
1909            None,
1910            "no pagination field at all"
1911        );
1912
1913        let wrong_shape = json!({ "pagination": { "total": 26 } });
1914        assert_eq!(
1915            resolve_nested_pagination(wrong_shape.as_object().expect("object fixture")),
1916            None,
1917            "missing required PaginationMeta fields fails to deserialize"
1918        );
1919
1920        let not_an_object = json!({ "pagination": "26 total" });
1921        assert_eq!(
1922            resolve_nested_pagination(not_an_object.as_object().expect("object fixture")),
1923            None,
1924            "pagination field present but not object-shaped"
1925        );
1926    }
1927
1928    #[test]
1929    fn nested_array_of_objects_renders_as_indented_child_table() {
1930        let map = json!({
1931            "name": "getPets",
1932            "parameters": {
1933                "items": [
1934                    {"name": "limit", "in": "query"},
1935                    {"name": "id", "in": "path"},
1936                ],
1937            },
1938        });
1939        let columns = vec![
1940            TableColumn::new("name", "Name"),
1941            TableColumn::new("parameters.items", "Parameters").nested(vec![
1942                TableColumn::new("name", "Name"),
1943                TableColumn::new("in", "In"),
1944            ]),
1945        ];
1946
1947        let (out, notes) =
1948            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
1949
1950        assert!(out.starts_with("Name: getPets\nParameters:\n"), "{out}");
1951        assert!(
1952            out.contains("  NAME"),
1953            "child header must be indented: {out}"
1954        );
1955        assert!(out.contains("  limit"), "child row must be indented: {out}");
1956        assert!(
1957            !out.contains('{'),
1958            "no raw JSON should leak into output: {out}"
1959        );
1960        assert!(!notes.truncated, "{out}");
1961        assert!(
1962            out.contains("(2 rows)"),
1963            "no pagination sibling means the plain row-count footer, unchanged: {out}"
1964        );
1965    }
1966
1967    #[test]
1968    fn nested_array_with_pagination_sibling_renders_pagination_style_footer() {
1969        let map = json!({
1970            "name": "getPets",
1971            "parameters": {
1972                "items": [
1973                    {"name": "limit", "in": "query"},
1974                    {"name": "id", "in": "path"},
1975                ],
1976                "pagination": { "total": 26, "offset": 0, "limit": 2, "count": 2, "has_more": true },
1977            },
1978        });
1979        let columns = vec![
1980            TableColumn::new("name", "Name"),
1981            TableColumn::new("parameters.items", "Parameters").nested(vec![
1982                TableColumn::new("name", "Name"),
1983                TableColumn::new("in", "In"),
1984            ]),
1985        ];
1986
1987        let (out, _notes) =
1988            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
1989
1990        assert!(
1991            out.contains("(2 of 26 rows, offset 0, limit 2)"),
1992            "nested table should reuse the pagination sibling's PaginationMeta facts: {out}"
1993        );
1994    }
1995
1996    #[test]
1997    fn nested_array_without_pagination_sibling_keeps_the_plain_row_count_footer() {
1998        let map = json!({ "items": [{"name": "limit"}] });
1999        let columns =
2000            vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])];
2001
2002        let (out, _notes) =
2003            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
2004
2005        assert!(
2006            out.contains("(1 rows)"),
2007            "no pagination sibling means no opt-in — behavior is unchanged: {out}"
2008        );
2009    }
2010
2011    #[test]
2012    fn nested_array_with_malformed_pagination_sibling_keeps_the_plain_row_count_footer() {
2013        let map =
2014            json!({ "items": [{"name": "limit"}], "pagination": { "total": "not-a-number" } });
2015        let columns =
2016            vec![TableColumn::new("items", "Items").nested(vec![TableColumn::new("name", "Name")])];
2017
2018        let (out, _notes) =
2019            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
2020
2021        assert!(
2022            out.contains("(1 rows)"),
2023            "a pagination sibling that fails to deserialize degrades to the plain footer: {out}"
2024        );
2025    }
2026
2027    #[test]
2028    fn nested_child_table_narrows_and_reports_via_merged_render_notes() {
2029        let map = json!({
2030            "items": [
2031                {"a": "x".repeat(5), "b": "x".repeat(5), "c": "x".repeat(5)},
2032            ],
2033        });
2034        let columns = vec![TableColumn::new("items", "Items").nested(vec![
2035            TableColumn::new("a", "A"),
2036            TableColumn::new("b", "B"),
2037            TableColumn::new("c", "C"),
2038        ])];
2039
2040        // Narrow enough to force the child table's own hide-before-truncate
2041        // cascade (mirrors `narrow_terminal_hides_columns_before_truncating_any_of_the_survivors`).
2042        let (out, notes) =
2043            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 12);
2044
2045        assert_eq!(
2046            notes.hidden_columns,
2047            vec!["Items > B".to_owned(), "Items > C".to_owned()],
2048            "hidden columns bubble up prefixed with the parent header: {out}"
2049        );
2050        assert!(
2051            notes.nested_narrowing,
2052            "narrowing happened inside the nested child, not at this level's own columns: {out}"
2053        );
2054    }
2055
2056    #[test]
2057    fn footer_does_not_suggest_fields_for_narrowing_inside_a_nested_column() {
2058        // `--fields` only selects among top-level declared columns — it
2059        // cannot narrow what shows *inside* a `TableColumn::nested` column.
2060        // When a nested child's own columns get hidden, the footer must not
2061        // claim `--fields` fixes it (regression: it used to say so
2062        // unconditionally, misleading users into trying a flag that does
2063        // nothing for this case — see PR review discussion). Same fixture
2064        // shape as `render_human_with_view_reports_hidden_columns_in_footer`
2065        // (proven to overflow the fallback 80-column width), just nested
2066        // one level under an "items" field instead of being the top-level
2067        // view directly.
2068        let envelope = Envelope::success(
2069            json!({
2070                "items": [{
2071                    "id": "1",
2072                    "name": "acme",
2073                    "status": "active",
2074                    "region": "us-west",
2075                    "created_at": "2026-01-01",
2076                    "updated_at": "2026-01-02",
2077                    "notes": "irrelevant, lowest priority",
2078                }],
2079            }),
2080            "thing",
2081        );
2082        let columns = vec![TableColumn::new("items", "Items").nested(vec![
2083            TableColumn::new("id", "ID"),
2084            TableColumn::new("name", "Name"),
2085            TableColumn::new("status", "Status"),
2086            TableColumn::new("region", "Region"),
2087            TableColumn::new("created_at", "Created At"),
2088            TableColumn::new("updated_at", "Updated At"),
2089            TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"),
2090        ])];
2091
2092        let out = render_human_with_view(&envelope, Some(&columns), "");
2093
2094        assert!(out.contains("hidden to fit the display width"), "{out}");
2095        assert!(
2096            out.contains("Items > This Is An Extremely Long Trailing Column Header"),
2097            "{out}"
2098        );
2099        assert!(
2100            !out.contains("use --fields"),
2101            "must not suggest --fields as a fix when the narrowing is inside a nested column \
2102             (mentioning it to explain why it won't help is fine): {out}"
2103        );
2104        assert!(
2105            out.contains("--json"),
2106            "must still point at --json as the real remedy: {out}"
2107        );
2108    }
2109
2110    #[test]
2111    fn empty_nested_array_renders_no_results_indented() {
2112        let map = json!({ "items": [] });
2113        let columns = vec![
2114            TableColumn::new("items", "Parameters").nested(vec![TableColumn::new("name", "Name")]),
2115        ];
2116
2117        let (out, _notes) =
2118            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
2119
2120        assert_eq!(out, "Parameters:\n  (no results)\n");
2121    }
2122
2123    #[test]
2124    fn nested_object_field_renders_as_indented_property_bag() {
2125        let map = json!({ "owner": {"name": "Ada", "email": "ada@example.test"} });
2126        let columns = vec![TableColumn::new("owner", "Owner").nested(vec![
2127            TableColumn::new("name", "Name"),
2128            TableColumn::new("email", "Email"),
2129        ])];
2130
2131        let (out, _notes) =
2132            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
2133
2134        assert_eq!(out, "Owner:\n  Name: Ada\n  Email: ada@example.test\n");
2135    }
2136
2137    #[test]
2138    fn unopted_in_nested_value_still_renders_as_raw_json_line() {
2139        // A column with no `.nested(...)` is a strict no-op even when the
2140        // runtime value happens to be list/object shaped — locks in the
2141        // "opt-in, never automatic" guarantee.
2142        let map = json!({
2143            "parameters": {"items": [{"name": "limit"}], "total": 1},
2144        });
2145        let columns = vec![TableColumn::new("parameters", "Parameters")];
2146
2147        let (out, _notes) =
2148            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
2149
2150        assert_eq!(
2151            out,
2152            format!(
2153                "Parameters: {}\n",
2154                format_value(map.get("parameters").expect("parameters"))
2155            )
2156        );
2157        assert!(out.contains('{'), "unchanged raw-JSON fallback: {out}");
2158    }
2159
2160    #[test]
2161    fn nested_column_is_a_no_op_when_the_value_is_not_actually_nestable() {
2162        // A column can opt into `.nested(...)` while still receiving a
2163        // scalar or a mixed (non-uniform) array at runtime — e.g. a field
2164        // that's usually a list of objects but is empty/absent for this row,
2165        // or simply the wrong shape. Rendering must stay the same flat
2166        // `header: value` line a column with `nested: None` would have
2167        // produced, not a `header:\n  value` block — regression guard for a
2168        // shape-drift bug where the header line alone changed to multi-line
2169        // even though the value itself fell back to `format_value`.
2170        let map = json!({
2171            "scalar": "just a string",
2172            "mixed": ["a", {"b": 1}],
2173        });
2174        let nested_columns = vec![TableColumn::new("x", "X")];
2175        let columns = vec![
2176            TableColumn::new("scalar", "Scalar").nested(nested_columns.clone()),
2177            TableColumn::new("mixed", "Mixed").nested(nested_columns),
2178        ];
2179        let unnested_columns = vec![
2180            TableColumn::new("scalar", "Scalar"),
2181            TableColumn::new("mixed", "Mixed"),
2182        ];
2183
2184        let (nested_out, _) =
2185            render_object_with_columns(map.as_object().expect("object fixture"), &columns, 80);
2186        let (unnested_out, _) = render_object_with_columns(
2187            map.as_object().expect("object fixture"),
2188            &unnested_columns,
2189            80,
2190        );
2191
2192        assert_eq!(
2193            nested_out, unnested_out,
2194            "an opted-in column must render identically to an unopted-in one \
2195             when the runtime value isn't list-of-objects or object shaped"
2196        );
2197        assert_eq!(nested_out, "Scalar: just a string\nMixed: a, {\"b\":1}\n");
2198    }
2199}