1use crate::core::output::OutputFormat;
2use crate::core::output_model::{ColumnAlignment, Group, OutputItems, OutputResult};
3use crate::core::row::Row;
4
5use crate::ui::document::{Block, Document, JsonBlock, TableStyle};
6use crate::ui::{RenderBackend, RenderSettings, ResolvedRenderSettings};
7
8mod common;
9pub mod help;
10pub mod message;
11mod mreg;
12mod table;
13mod value;
14
15pub use help::build_help_document;
16pub use message::{MessageContent, MessageFormatter, MessageKind, MessageOptions, MessageRules};
17
18#[cfg(test)]
19pub fn build_document(rows: &[Row], settings: &RenderSettings) -> Document {
20 build_document_from_output(
21 &OutputResult {
22 items: OutputItems::Rows(rows.to_vec()),
23 meta: Default::default(),
24 },
25 settings,
26 )
27}
28
29pub fn build_document_from_output(output: &OutputResult, settings: &RenderSettings) -> Document {
30 let resolved = settings.resolve_render_settings();
31 build_document_from_output_resolved(output, settings, &resolved)
32}
33
34pub fn build_document_from_output_resolved(
35 output: &OutputResult,
36 settings: &RenderSettings,
37 resolved: &ResolvedRenderSettings,
38) -> Document {
39 let format = resolve_output_format(output, settings.format);
40 let mut next_block_id = 1u64;
41 match format {
42 OutputFormat::Json => Document {
43 blocks: vec![Block::Json(build_json_block_from_output(output))],
44 },
45 OutputFormat::Table => build_table_document(output, TableStyle::Grid, &mut next_block_id),
46 OutputFormat::Markdown => {
47 build_table_document(output, TableStyle::Markdown, &mut next_block_id)
48 }
49 OutputFormat::Mreg => {
50 let rows = materialize_rows(output);
51 let width_hint = resolved.width.unwrap_or(100).max(24);
52 let prefer_stacked_object_lists = resolved.backend == RenderBackend::Rich;
53 Document {
54 blocks: mreg::build_mreg_blocks(
55 &rows,
56 mreg::MregBuildOptions {
57 key_order: Some(&output.meta.key_index),
58 short_list_max: settings.short_list_max,
59 medium_list_max: settings.medium_list_max,
60 width_hint,
61 indent_size: settings.indent_size.max(1),
62 prefer_stacked_object_lists,
63 stack_min_col_width: settings.mreg_stack_min_col_width.max(1),
64 stack_overflow_ratio: settings.mreg_stack_overflow_ratio.max(100),
65 },
66 &mut next_block_id,
67 ),
68 }
69 }
70 OutputFormat::Value => {
71 let rows = materialize_rows(output);
72 Document {
73 blocks: vec![Block::Value(value::build_value_block(&rows))],
74 }
75 }
76 OutputFormat::Auto => unreachable!("auto format is resolved above"),
77 }
78}
79
80#[cfg(test)]
81pub fn resolve_format(rows: &[Row], format: OutputFormat) -> OutputFormat {
82 resolve_output_format(
83 &OutputResult {
84 items: OutputItems::Rows(rows.to_vec()),
85 meta: Default::default(),
86 },
87 format,
88 )
89}
90
91pub fn resolve_output_format(output: &OutputResult, format: OutputFormat) -> OutputFormat {
92 if !matches!(format, OutputFormat::Auto) {
93 return format;
94 }
95
96 if matches!(output.items, OutputItems::Groups(_)) {
97 return OutputFormat::Table;
98 }
99
100 let rows = materialize_rows(output);
101 if rows
102 .iter()
103 .all(|row| row.len() == 1 && row.contains_key("value"))
104 {
105 OutputFormat::Value
106 } else if rows.len() <= 1 {
107 OutputFormat::Mreg
108 } else {
109 OutputFormat::Table
110 }
111}
112
113fn build_table_document(
114 output: &OutputResult,
115 style: TableStyle,
116 next_block_id: &mut u64,
117) -> Document {
118 match &output.items {
119 OutputItems::Rows(rows) => Document {
120 blocks: vec![Block::Table({
121 let mut block = table::build_table_block(
122 rows,
123 style,
124 Some(&output.meta.key_index),
125 allocate_block_id(next_block_id),
126 );
127 block.align = table_alignments_for_headers(
128 &block.headers,
129 &output.meta.key_index,
130 &output.meta.column_align,
131 );
132 block
133 })],
134 },
135 OutputItems::Groups(groups) => Document {
136 blocks: groups
137 .iter()
138 .map(|group| {
139 let mut rows = group.rows.clone();
140 if rows.is_empty() {
141 rows.push(merge_group_header(group));
142 }
143 let mut block = table::build_table_block(
144 &rows,
145 style,
146 Some(&output.meta.key_index),
147 allocate_block_id(next_block_id),
148 );
149 block.align = table_alignments_for_headers(
150 &block.headers,
151 &output.meta.key_index,
152 &output.meta.column_align,
153 );
154 block.header_pairs = group_header_pairs(group, Some(&output.meta.key_index));
155 Block::Table(block)
156 })
157 .collect(),
158 },
159 }
160}
161
162fn table_alignments_for_headers(
163 headers: &[String],
164 key_index: &[String],
165 column_align: &[ColumnAlignment],
166) -> Option<Vec<crate::ui::document::TableAlign>> {
167 if key_index.is_empty() || column_align.is_empty() {
168 return None;
169 }
170
171 let align_by_key = key_index
172 .iter()
173 .cloned()
174 .zip(column_align.iter().copied())
175 .collect::<std::collections::BTreeMap<String, ColumnAlignment>>();
176
177 let out = headers
178 .iter()
179 .map(|header| {
180 align_by_key
181 .get(header)
182 .copied()
183 .map(table_align_from_output)
184 .unwrap_or(crate::ui::document::TableAlign::Default)
185 })
186 .collect::<Vec<_>>();
187
188 if out
189 .iter()
190 .all(|align| matches!(align, crate::ui::document::TableAlign::Default))
191 {
192 None
193 } else {
194 Some(out)
195 }
196}
197
198fn table_align_from_output(value: ColumnAlignment) -> crate::ui::document::TableAlign {
199 match value {
200 ColumnAlignment::Default => crate::ui::document::TableAlign::Default,
201 ColumnAlignment::Left => crate::ui::document::TableAlign::Left,
202 ColumnAlignment::Center => crate::ui::document::TableAlign::Center,
203 ColumnAlignment::Right => crate::ui::document::TableAlign::Right,
204 }
205}
206
207fn allocate_block_id(next_block_id: &mut u64) -> u64 {
208 let id = *next_block_id;
209 *next_block_id = next_block_id.saturating_add(1);
210 id
211}
212
213fn build_json_block_from_output(output: &OutputResult) -> JsonBlock {
214 let payload = match &output.items {
215 OutputItems::Rows(rows) => serde_json::Value::Array(
216 rows.iter()
217 .cloned()
218 .map(serde_json::Value::Object)
219 .collect(),
220 ),
221 OutputItems::Groups(groups) => serde_json::Value::Array(
222 groups
223 .iter()
224 .map(|group| {
225 let mut item = serde_json::Map::new();
226 item.insert(
227 "groups".to_string(),
228 serde_json::Value::Object(group.groups.clone()),
229 );
230 item.insert(
231 "aggregates".to_string(),
232 serde_json::Value::Object(group.aggregates.clone()),
233 );
234 item.insert(
235 "rows".to_string(),
236 serde_json::Value::Array(
237 group
238 .rows
239 .iter()
240 .cloned()
241 .map(serde_json::Value::Object)
242 .collect(),
243 ),
244 );
245 serde_json::Value::Object(item)
246 })
247 .collect(),
248 ),
249 };
250
251 JsonBlock { payload }
252}
253
254fn materialize_rows(output: &OutputResult) -> Vec<Row> {
255 match &output.items {
256 OutputItems::Rows(rows) => rows.clone(),
257 OutputItems::Groups(groups) => {
258 let mut out = Vec::new();
259 for group in groups {
260 if group.rows.is_empty() {
261 out.push(merge_group_header(group));
262 continue;
263 }
264 for row in &group.rows {
265 out.push(merge_group_row(group, row));
266 }
267 }
268 out
269 }
270 }
271}
272
273fn merge_group_header(group: &Group) -> Row {
274 let mut row = group.groups.clone();
275 for (key, value) in &group.aggregates {
276 row.insert(key.clone(), value.clone());
277 }
278 row
279}
280
281fn merge_group_row(group: &Group, row: &Row) -> Row {
282 let mut merged = group.groups.clone();
283 for (key, value) in &group.aggregates {
284 merged.insert(key.clone(), value.clone());
285 }
286 for (key, value) in row {
287 merged.insert(key.clone(), value.clone());
288 }
289 merged
290}
291
292fn group_header_pairs(
293 group: &Group,
294 preferred_key_order: Option<&[String]>,
295) -> Vec<(String, serde_json::Value)> {
296 let mut out = Vec::new();
297 let mut seen = std::collections::BTreeSet::new();
298 let mut ordered = Vec::new();
299
300 if let Some(order) = preferred_key_order {
301 for key in order {
302 if group.groups.contains_key(key) || group.aggregates.contains_key(key) {
303 ordered.push(key.clone());
304 }
305 }
306 }
307
308 for key in group.groups.keys() {
309 ordered.push(key.clone());
310 }
311 for key in group.aggregates.keys() {
312 ordered.push(key.clone());
313 }
314
315 for key in ordered {
316 if !seen.insert(key.clone()) {
317 continue;
318 }
319 if let Some(value) = group.groups.get(&key) {
320 out.push((key.clone(), value.clone()));
321 continue;
322 }
323 if let Some(value) = group.aggregates.get(&key) {
324 out.push((key.clone(), value.clone()));
325 }
326 }
327
328 out
329}
330
331#[cfg(test)]
332mod tests {
333 use super::{
334 build_document, build_document_from_output, build_document_from_output_resolved,
335 group_header_pairs, materialize_rows, resolve_format, resolve_output_format,
336 table_alignments_for_headers,
337 };
338 use crate::core::output::{OutputFormat, RenderMode};
339 use crate::core::output_model::{
340 ColumnAlignment, Group, OutputItems, OutputMeta, OutputResult,
341 };
342 use crate::core::row::Row;
343 use crate::ui::RenderSettings;
344 use crate::ui::document::{Block, TableAlign, TableStyle};
345 use serde_json::json;
346
347 fn settings(format: OutputFormat) -> RenderSettings {
348 RenderSettings {
349 mode: RenderMode::Plain,
350 width: Some(100),
351 grid_padding: 2,
352 theme_name: "plain".to_string(),
353 ..RenderSettings::test_plain(format)
354 }
355 }
356
357 #[test]
358 fn auto_format_uses_table_for_grouped_output() {
359 let mut group_fields = Row::new();
360 group_fields.insert("group".to_string(), json!("a"));
361 let mut row = Row::new();
362 row.insert("uid".to_string(), json!("oistes"));
363 let output = OutputResult {
364 items: OutputItems::Groups(vec![Group {
365 groups: group_fields,
366 aggregates: Row::new(),
367 rows: vec![row],
368 }]),
369 meta: OutputMeta::default(),
370 };
371
372 assert_eq!(
373 resolve_output_format(&output, OutputFormat::Auto),
374 OutputFormat::Table
375 );
376 }
377
378 #[test]
379 fn grouped_table_document_populates_header_pairs() {
380 let mut group_fields = Row::new();
381 group_fields.insert("group".to_string(), json!("ops"));
382 let mut aggregates = Row::new();
383 aggregates.insert("count".to_string(), json!(2));
384 let mut row = Row::new();
385 row.insert("uid".to_string(), json!("alice"));
386 let output = OutputResult {
387 items: OutputItems::Groups(vec![Group {
388 groups: group_fields,
389 aggregates,
390 rows: vec![row],
391 }]),
392 meta: OutputMeta {
393 key_index: vec!["group".to_string(), "count".to_string(), "uid".to_string()],
394 column_align: Vec::new(),
395 wants_copy: false,
396 grouped: true,
397 },
398 };
399
400 let document = build_document_from_output(&output, &settings(OutputFormat::Table));
401 let Block::Table(table) = &document.blocks[0] else {
402 panic!("expected table block");
403 };
404 assert_eq!(table.style, TableStyle::Grid);
405 assert_eq!(
406 table.header_pairs,
407 vec![
408 ("group".to_string(), json!("ops")),
409 ("count".to_string(), json!(2))
410 ]
411 );
412 assert_eq!(table.headers, vec!["uid".to_string()]);
413 }
414
415 #[test]
416 fn grouped_json_document_keeps_group_structure() {
417 let mut group_fields = Row::new();
418 group_fields.insert("group".to_string(), json!("ops"));
419 let mut row = Row::new();
420 row.insert("uid".to_string(), json!("alice"));
421 let output = OutputResult {
422 items: OutputItems::Groups(vec![Group {
423 groups: group_fields,
424 aggregates: Row::new(),
425 rows: vec![row],
426 }]),
427 meta: OutputMeta::default(),
428 };
429 let document = build_document_from_output(&output, &settings(OutputFormat::Json));
430 let Block::Json(json_block) = &document.blocks[0] else {
431 panic!("expected json block");
432 };
433 let payload = json_block.payload.as_array().expect("array payload");
434 let first = payload.first().expect("first group");
435 assert!(first.get("groups").is_some());
436 assert!(first.get("rows").is_some());
437 }
438
439 #[test]
440 fn row_table_document_preserves_alignment_metadata() {
441 let mut row = Row::new();
442 row.insert("name".to_string(), json!("alice"));
443 row.insert("count".to_string(), json!(2));
444 let output = OutputResult {
445 items: OutputItems::Rows(vec![row]),
446 meta: OutputMeta {
447 key_index: vec!["name".to_string(), "count".to_string()],
448 column_align: vec![ColumnAlignment::Left, ColumnAlignment::Right],
449 wants_copy: false,
450 grouped: false,
451 },
452 };
453
454 let document = build_document_from_output(&output, &settings(OutputFormat::Table));
455 let Block::Table(table) = &document.blocks[0] else {
456 panic!("expected table block");
457 };
458 assert_eq!(
459 table.align,
460 Some(vec![
461 crate::ui::document::TableAlign::Left,
462 crate::ui::document::TableAlign::Right
463 ])
464 );
465 }
466
467 #[test]
468 fn grouped_table_document_preserves_alignment_metadata() {
469 let mut group_fields = Row::new();
470 group_fields.insert("group".to_string(), json!("ops"));
471 let mut row = Row::new();
472 row.insert("uid".to_string(), json!("alice"));
473 row.insert("count".to_string(), json!(2));
474 let output = OutputResult {
475 items: OutputItems::Groups(vec![Group {
476 groups: group_fields,
477 aggregates: Row::new(),
478 rows: vec![row],
479 }]),
480 meta: OutputMeta {
481 key_index: vec!["group".to_string(), "uid".to_string(), "count".to_string()],
482 column_align: vec![
483 ColumnAlignment::Default,
484 ColumnAlignment::Left,
485 ColumnAlignment::Right,
486 ],
487 wants_copy: false,
488 grouped: true,
489 },
490 };
491
492 let document = build_document_from_output(&output, &settings(OutputFormat::Table));
493 let Block::Table(table) = &document.blocks[0] else {
494 panic!("expected table block");
495 };
496 assert_eq!(
497 table.align,
498 Some(vec![
499 crate::ui::document::TableAlign::Left,
500 crate::ui::document::TableAlign::Right
501 ])
502 );
503 }
504
505 #[test]
506 fn grouped_markdown_document_preserves_header_pairs_and_alignment() {
507 let mut group_fields = Row::new();
508 group_fields.insert("group".to_string(), json!("ops"));
509 let mut aggregates = Row::new();
510 aggregates.insert("count".to_string(), json!(2));
511 let mut row = Row::new();
512 row.insert("uid".to_string(), json!("alice"));
513 row.insert("score".to_string(), json!(42));
514 let output = OutputResult {
515 items: OutputItems::Groups(vec![Group {
516 groups: group_fields,
517 aggregates,
518 rows: vec![row],
519 }]),
520 meta: OutputMeta {
521 key_index: vec![
522 "group".to_string(),
523 "count".to_string(),
524 "uid".to_string(),
525 "score".to_string(),
526 ],
527 column_align: vec![
528 ColumnAlignment::Default,
529 ColumnAlignment::Default,
530 ColumnAlignment::Left,
531 ColumnAlignment::Right,
532 ],
533 wants_copy: false,
534 grouped: true,
535 },
536 };
537
538 let document = build_document_from_output(&output, &settings(OutputFormat::Markdown));
539 let Block::Table(table) = &document.blocks[0] else {
540 panic!("expected table block");
541 };
542 assert_eq!(table.style, TableStyle::Markdown);
543 assert_eq!(
544 table.header_pairs,
545 vec![
546 ("group".to_string(), json!("ops")),
547 ("count".to_string(), json!(2))
548 ]
549 );
550 assert_eq!(
551 table.align,
552 Some(vec![
553 crate::ui::document::TableAlign::Left,
554 crate::ui::document::TableAlign::Right
555 ])
556 );
557 }
558
559 #[test]
560 fn build_document_wrapper_and_resolve_format_cover_value_and_explicit_modes() {
561 let rows = vec![json!({"value": 7}).as_object().cloned().expect("object")];
562
563 assert_eq!(
564 resolve_format(&rows, OutputFormat::Auto),
565 OutputFormat::Value
566 );
567 assert_eq!(
568 resolve_format(&rows, OutputFormat::Json),
569 OutputFormat::Json
570 );
571
572 let document = build_document(&rows, &settings(OutputFormat::Value));
573 assert!(matches!(document.blocks[0], Block::Value(_)));
574 }
575
576 #[test]
577 fn mreg_and_value_documents_materialize_group_rows_consistently() {
578 let group = Group {
579 groups: json!({"group": "ops"})
580 .as_object()
581 .cloned()
582 .expect("object"),
583 aggregates: json!({"count": 2}).as_object().cloned().expect("object"),
584 rows: vec![],
585 };
586 let output = OutputResult {
587 items: OutputItems::Groups(vec![group.clone()]),
588 meta: OutputMeta {
589 key_index: vec!["group".to_string(), "count".to_string()],
590 ..OutputMeta::default()
591 },
592 };
593
594 let resolved = settings(OutputFormat::Mreg).resolve_render_settings();
595 let document =
596 build_document_from_output_resolved(&output, &settings(OutputFormat::Mreg), &resolved);
597 assert!(!document.blocks.is_empty());
598
599 let value_output = OutputResult {
600 items: OutputItems::Groups(vec![group]),
601 meta: OutputMeta::default(),
602 };
603 let value_document =
604 build_document_from_output(&value_output, &settings(OutputFormat::Value));
605 assert!(matches!(value_document.blocks[0], Block::Value(_)));
606
607 let rows = materialize_rows(&value_output);
608 assert_eq!(rows.len(), 1);
609 assert_eq!(rows[0].get("group"), Some(&json!("ops")));
610 assert_eq!(rows[0].get("count"), Some(&json!(2)));
611 }
612
613 #[test]
614 fn header_pairs_and_alignments_skip_defaults_and_deduplicate_keys() {
615 let group = Group {
616 groups: json!({"group": "ops"})
617 .as_object()
618 .cloned()
619 .expect("object"),
620 aggregates: json!({"count": 2}).as_object().cloned().expect("object"),
621 rows: vec![],
622 };
623
624 let pairs = group_header_pairs(
625 &group,
626 Some(&[
627 "count".to_string(),
628 "group".to_string(),
629 "group".to_string(),
630 ]),
631 );
632 assert_eq!(
633 pairs,
634 vec![
635 ("count".to_string(), json!(2)),
636 ("group".to_string(), json!("ops"))
637 ]
638 );
639
640 let align = table_alignments_for_headers(
641 &["group".to_string(), "count".to_string()],
642 &["group".to_string(), "count".to_string()],
643 &[ColumnAlignment::Default, ColumnAlignment::Default],
644 );
645 assert!(align.is_none());
646
647 let align = table_alignments_for_headers(
648 &["group".to_string(), "count".to_string()],
649 &["group".to_string(), "count".to_string()],
650 &[ColumnAlignment::Center, ColumnAlignment::Right],
651 );
652 assert_eq!(align, Some(vec![TableAlign::Center, TableAlign::Right]));
653 }
654}