Skip to main content

osp_cli/ui/format/
mod.rs

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}