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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
20pub enum Alignment {
21 #[default]
23 Left,
24 Right,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
49#[non_exhaustive]
50pub struct TableColumn {
51 pub field: String,
58 pub header: String,
60 pub no_truncate: bool,
65 pub nested: Option<Vec<TableColumn>>,
71 pub align: Alignment,
73}
74
75impl TableColumn {
76 #[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 #[must_use]
91 pub fn no_truncate(mut self, value: bool) -> Self {
92 self.no_truncate = value;
93 self
94 }
95
96 #[must_use]
101 pub fn align(mut self, alignment: Alignment) -> Self {
102 self.align = alignment;
103 self
104 }
105
106 #[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#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct HumanViewDef {
130 pub schema_id: String,
132 pub columns: Vec<TableColumn>,
135}
136
137impl HumanViewDef {
138 #[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
148pub type HumanViewFn = Arc<dyn Fn(&Value) -> String + Send + Sync>;
150
151#[derive(Clone)]
153pub struct HumanViewRenderer {
154 render: HumanViewFn,
155}
156
157impl HumanViewRenderer {
158 #[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 #[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#[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 #[must_use]
191 pub fn new() -> Self {
192 Self::default()
193 }
194
195 pub fn register(&mut self, view: HumanViewDef) {
197 self.by_schema_id.insert(view.schema_id, view.columns);
198 }
199
200 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 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 #[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 #[must_use]
225 pub fn custom(&self, schema_id: &str) -> Option<&HumanViewRenderer> {
226 self.custom_by_schema_id.get(schema_id)
227 }
228
229 #[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
245pub 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
253pub 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#[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#[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#[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#[must_use]
300pub fn render_human(envelope: &Envelope) -> String {
301 render_human_with_view(envelope, None, "")
302}
303
304#[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#[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#[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
359fn 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#[must_use]
389pub fn render_human_with_view(
390 envelope: &Envelope,
391 columns: Option<&[TableColumn]>,
392 fields: &str,
393) -> String {
394 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 append_render_notes(&mut body, ¬es);
421 if !notes.pagination_shown {
422 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
438fn 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
470fn 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
495fn 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
512fn append_render_notes(out: &mut String, notes: &RenderNotes) {
516 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
553fn 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
595fn 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
615fn 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) = ¶m.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
638const NO_TRUNCATE_MAX_WIDTH: usize = 4096;
650
651const COLUMN_GUTTER: usize = 2;
655
656const NESTED_INDENT: &str = " ";
661
662#[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#[derive(Default)]
680struct RenderNotes {
681 truncated: bool,
683 hidden_columns: Vec<String>,
688 nested_narrowing: bool,
697 pagination_shown: bool,
702}
703
704fn 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
728fn 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 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 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 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 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 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
994fn 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 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
1063fn 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
1091fn 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
1109fn resolve_nested_pagination(parent: &serde_json::Map<String, Value>) -> Option<PaginationMeta> {
1114 serde_json::from_value(parent.get("pagination")?.clone()).ok()
1115}
1116
1117fn 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
1136fn 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
1149fn 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 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 assert!(out.contains("domain: example.com"), "{out}");
1254 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 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 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 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 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 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 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 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 TableColumn::new("notes", "This Is An Extremely Long Trailing Column Header"),
1557 ];
1558
1559 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 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 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 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 ®istry,
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, ®istry, "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 let headers = [1, 1, 1];
1748 let natural = [2, 2, 6]; 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 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 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 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 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 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 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 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 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 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}