Skip to main content

ion_fmt/
display.rs

1//! Formatting display primitives for Ion documents.
2//!
3//! The formatter is intentionally render-only: it operates on parsed [`Ion`]
4//! values and delegates parsing and validation to the `ion` crate.
5
6use crate::columns_width::{Column, ColumnsWidth};
7use ion::{Dictionary, Ion, Row, Section, Value};
8use std::fmt::{self, Write};
9use std::str::FromStr;
10
11/// Formatting options used when rendering Ion documents.
12#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
13pub struct FormatOptions {
14    /// Dictionary rendering options.
15    pub dictionary: DictionaryOptions,
16    /// Section rendering options.
17    pub section: SectionOptions,
18    /// Document-level rendering options.
19    pub document: DocumentOptions,
20    /// Table rendering options.
21    pub table: Option<TableOptions>,
22}
23
24/// Options that control dictionary formatting behavior.
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub struct DictionaryOptions {
27    /// Style used for dictionary fields.
28    pub field: FieldStyle,
29}
30
31/// Options that control section-level formatting behavior.
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub struct SectionOptions {
34    /// Spacing between dictionary fields and table rows in the same section.
35    pub spacing: SectionSpacing,
36}
37
38/// Options that control document-level formatting behavior.
39#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
40pub struct DocumentOptions {
41    /// Spacing at end-of-document.
42    pub spacing: DocumentSpacing,
43}
44
45/// Options that control table formatting behavior.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum TableOptions {
48    /// Treat the first row as a header and synthesize a separator row when one is missing.
49    AddHeaderSeparatorWhenMissing,
50}
51
52/// Formatting style for dictionary string fields.
53#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
54pub enum FieldStyle {
55    /// Render dictionary strings as escaped single-line values.
56    Singleline,
57    /// Preserve embedded newline characters as multiline quoted values.
58    #[default]
59    Multiline,
60}
61
62/// Spacing behavior between dictionary fields and table rows in one section.
63#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
64pub enum SectionSpacing {
65    /// Keep the single line break between dictionary fields and table rows.
66    NewLine,
67    /// Add an extra empty line between dictionary fields and table rows.
68    #[default]
69    AdditionalNewLine,
70}
71
72/// Spacing behavior at end-of-document.
73#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
74pub enum DocumentSpacing {
75    /// Keep a single newline at end of document.
76    #[default]
77    EndNewLine,
78    /// Add one extra empty line at end of document.
79    AdditionalEndNewLine,
80}
81
82impl FromStr for FieldStyle {
83    type Err = String;
84
85    fn from_str(value: &str) -> Result<Self, Self::Err> {
86        match value {
87            "singleline" => Ok(Self::Singleline),
88            "multiline" => Ok(Self::Multiline),
89            _ => Err(format!(
90                "Unsupported `dictionary-field` style `{value}`. Expected `singleline` or `multiline`."
91            )),
92        }
93    }
94}
95
96impl FromStr for SectionSpacing {
97    type Err = String;
98
99    fn from_str(value: &str) -> Result<Self, Self::Err> {
100        match value {
101            "newline" => Ok(Self::NewLine),
102            "additional-newline" => Ok(Self::AdditionalNewLine),
103            _ => Err(format!(
104                "Unsupported `section-spacing` style `{value}`. Expected `newline` or `additional-newline`."
105            )),
106        }
107    }
108}
109
110impl FromStr for DocumentSpacing {
111    type Err = String;
112
113    fn from_str(value: &str) -> Result<Self, Self::Err> {
114        match value {
115            "end-newline" => Ok(Self::EndNewLine),
116            "additional-end-newline" => Ok(Self::AdditionalEndNewLine),
117            _ => Err(format!(
118                "Unsupported `document-spacing` style `{value}`. Expected `end-newline` or `additional-end-newline`."
119            )),
120        }
121    }
122}
123
124impl FromStr for TableOptions {
125    type Err = String;
126
127    fn from_str(value: &str) -> Result<Self, Self::Err> {
128        match value {
129            "add-header-separator-when-missing" => Ok(Self::AddHeaderSeparatorWhenMissing),
130            _ => Err(format!(
131                "Unsupported `table` style `{value}`. Expected `add-header-separator-when-missing`."
132            )),
133        }
134    }
135}
136
137/// Display adapter that renders an [`Ion`] document with canonical formatting.
138///
139/// This is useful when you already have a parsed [`Ion`] value and want a
140/// `Display` implementation instead of an owned `String`.
141#[derive(Clone, Debug)]
142pub struct IonDisplay<'a> {
143    ion: &'a Ion,
144    options: FormatOptions,
145}
146
147impl<'a> IonDisplay<'a> {
148    /// Creates a display adapter with explicit formatting options.
149    #[must_use]
150    pub fn new(ion: &'a Ion, options: FormatOptions) -> Self {
151        Self { ion, options }
152    }
153}
154
155/// Display adapter that renders dictionary fields from a section.
156///
157/// This is useful when callers want to format only dictionary values instead
158/// of a full section or full document.
159#[derive(Clone, Debug)]
160pub struct DictionaryDisplay<'a> {
161    dictionary: &'a Dictionary,
162    field: FieldStyle,
163}
164
165impl<'a> DictionaryDisplay<'a> {
166    /// Creates a dictionary display adapter with explicit dictionary field style.
167    #[must_use]
168    pub fn new(dictionary: &'a Dictionary, field: FieldStyle) -> Self {
169        Self { dictionary, field }
170    }
171}
172
173/// Display adapter that renders a single dictionary key-value pair.
174#[derive(Clone, Debug)]
175pub struct DictionaryFieldDisplay<'a> {
176    key: &'a str,
177    value: &'a Value,
178    options: FieldStyle,
179}
180
181impl<'a> DictionaryFieldDisplay<'a> {
182    /// Creates a dictionary field display adapter with explicit dictionary field style.
183    #[must_use]
184    pub fn new(key: &'a str, value: &'a Value, field: FieldStyle) -> Self {
185        Self {
186            key,
187            value,
188            options: field,
189        }
190    }
191}
192
193#[derive(Clone, Debug)]
194struct SectionDisplay<'a> {
195    columns_width: ColumnsWidth,
196    name: &'a str,
197    section: &'a Section,
198    options: FormatOptions,
199}
200
201#[derive(Clone, Debug)]
202struct RowsDisplay<'a> {
203    columns_width: &'a ColumnsWidth,
204    rows: &'a Vec<Row>,
205    table: Option<TableOptions>,
206}
207
208#[derive(Clone, Debug)]
209struct RowDisplay<'a> {
210    columns_width: &'a ColumnsWidth,
211    row: &'a Row,
212    row_type: RowTypeDisplay,
213}
214
215#[derive(Clone, Copy, Debug, Eq, PartialEq)]
216pub(crate) enum RowTypeDisplay {
217    Header,
218    Separator,
219    Data,
220}
221
222impl<'a> SectionDisplay<'a> {
223    fn new(name: &'a str, section: &'a Section, options: FormatOptions) -> Self {
224        let columns_width = section
225            .rows
226            .iter()
227            .map(|row| row.iter())
228            .collect::<ColumnsWidth>();
229
230        Self {
231            columns_width,
232            name,
233            section,
234            options,
235        }
236    }
237}
238
239/// Returns a display adapter rendered with explicit formatting options.
240#[must_use]
241pub fn display_with_options(ion: &Ion, options: FormatOptions) -> IonDisplay<'_> {
242    IonDisplay::new(ion, options)
243}
244
245/// Formats an Ion document using explicit formatting options.
246#[must_use]
247pub fn format_ion_with_options(ion: &Ion, options: FormatOptions) -> String {
248    display_with_options(ion, options).to_string()
249}
250
251impl fmt::Display for IonDisplay<'_> {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        self.ion
254            .iter()
255            .map(|(name, section)| SectionDisplay::new(name, section, self.options))
256            .try_for_each(|section| writeln!(f, "{section}"))?;
257
258        if self.ion.iter().next().is_some()
259            && self.options.document.spacing == DocumentSpacing::AdditionalEndNewLine
260        {
261            writeln!(f)?;
262        }
263
264        Ok(())
265    }
266}
267
268impl fmt::Display for SectionDisplay<'_> {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        writeln!(f, "[{}]", self.name)?;
271        DictionaryDisplay::new(&self.section.dictionary, self.options.dictionary.field).fmt(f)?;
272
273        if self.section.rows.is_empty() {
274            return Ok(());
275        }
276
277        if !self.section.dictionary.is_empty()
278            && self.options.section.spacing == SectionSpacing::AdditionalNewLine
279        {
280            writeln!(f)?;
281        }
282
283        RowsDisplay {
284            columns_width: &self.columns_width,
285            rows: &self.section.rows,
286            table: self.options.table,
287        }
288        .fmt(f)
289    }
290}
291
292impl fmt::Display for DictionaryDisplay<'_> {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        self.dictionary
295            .iter()
296            .try_for_each(|(key, value)| DictionaryFieldDisplay::new(key, value, self.field).fmt(f))
297    }
298}
299
300impl fmt::Display for DictionaryFieldDisplay<'_> {
301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302        display_dictionary(f, self.value, self.key, self.options, 0)
303    }
304}
305
306fn display_dictionary(
307    f: &mut fmt::Formatter<'_>,
308    value: &Value,
309    key: &str,
310    options: FieldStyle,
311    depth: usize,
312) -> Result<(), fmt::Error> {
313    let current_inden = depth * 4;
314
315    match value {
316        Value::Dictionary(dictionary) if options == FieldStyle::Multiline => {
317            write!(f, "{0:1$}{2} = ", "", current_inden, key)?;
318            write_dictionary_multiline_value_at_depth(f, dictionary, depth, options)?;
319        }
320        Value::String(text) if options == FieldStyle::Multiline && text.contains('\n') => {
321            write_dictionary_multiline_string(f, key, text, depth)?;
322        }
323        Value::String(_) => {
324            write!(f, "{0:1$}{2} = \"{3}\"", "", current_inden, key, value)?;
325        }
326        _ => {
327            write!(f, "{0:1$}{2} = {3}", "", current_inden, key, value)?;
328        }
329    }
330
331    writeln!(f)
332}
333
334impl fmt::Display for RowsDisplay<'_> {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        if self.rows.is_empty() {
337            return Ok(());
338        }
339
340        // When the table option is enabled and the parsed rows still start with plain
341        // data, treat the first source row as a synthesized header/separator pair.
342        // `skip_items = 1` then prevents rendering that same source row again below.
343        let skip_items = if matches!(
344            self.table,
345            Some(TableOptions::AddHeaderSeparatorWhenMissing)
346        ) && matches!(self.columns_width.row_type(0), RowTypeDisplay::Data)
347        {
348            let header_row = &self.rows[0];
349            RowDisplay {
350                columns_width: self.columns_width,
351                row: header_row,
352                row_type: RowTypeDisplay::Header,
353            }
354            .fmt(f)?;
355            RowDisplay {
356                columns_width: self.columns_width,
357                row: header_row,
358                row_type: RowTypeDisplay::Separator,
359            }
360            .fmt(f)?;
361
362            1
363        } else {
364            0
365        };
366
367        self.rows
368            .iter()
369            .enumerate()
370            .skip(skip_items)
371            .try_for_each(|(row_idx, row)| {
372                RowDisplay {
373                    columns_width: self.columns_width,
374                    row,
375                    row_type: self.columns_width.row_type(row_idx),
376                }
377                .fmt(f)
378            })?;
379
380        Ok(())
381    }
382}
383
384impl fmt::Display for RowDisplay<'_> {
385    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386        match self.row_type {
387            RowTypeDisplay::Header => header(f, self),
388            RowTypeDisplay::Separator => header_separator(f, self),
389            RowTypeDisplay::Data => write_data(f, self),
390        }
391    }
392}
393
394fn write_dictionary_multiline_string(
395    f: &mut fmt::Formatter<'_>,
396    key: &str,
397    text: &str,
398    depth: usize,
399) -> fmt::Result {
400    write!(f, "{0:1$}{2} = \"", "", depth * 4, key)?;
401
402    for ch in text.chars() {
403        match ch {
404            '\\' => f.write_str("\\\\")?,
405            '"' => f.write_str("\\\"")?,
406            _ => f.write_char(ch)?,
407        }
408    }
409    write!(f, "\"")
410}
411
412fn write_dictionary_multiline_value_at_depth(
413    f: &mut fmt::Formatter<'_>,
414    dictionary: &Dictionary,
415    depth: usize,
416    options: FieldStyle,
417) -> fmt::Result {
418    writeln!(f, "{{")?;
419
420    for (key, value) in dictionary {
421        display_dictionary(f, value, key, options, depth + 1)?;
422    }
423
424    write!(f, "{0:1$}}}", "", depth * 4)
425}
426
427fn header(f: &mut fmt::Formatter<'_>, columns: &RowDisplay<'_>) -> fmt::Result {
428    let mut columns_iter = columns.row.iter().enumerate();
429
430    if let Some((idx, ion_value)) = columns_iter.next() {
431        let column = columns.columns_width.column(idx);
432        let text = format!("{ion_value}");
433        let (left_alignment, right_alignment) = center_header_column_alignment(column, &text);
434
435        write_header_first_column(f, &text, left_alignment, right_alignment)?;
436
437        for (idx, ion_value) in columns_iter {
438            let column = columns.columns_width.column(idx);
439            let text = format!("{ion_value}");
440            let (left_alignment, right_alignment) = center_header_column_alignment(column, &text);
441
442            write_header_next_columns(f, &text, left_alignment, right_alignment)?;
443        }
444
445        writeln!(f)?;
446    }
447
448    Ok(())
449}
450
451fn center_header_column_alignment(column: Column, text: &str) -> (usize, usize) {
452    let spaces_width = column.width.checked_sub(text.len()).unwrap_or(text.len());
453    let even_width = spaces_width / 2;
454    let (modulo_left, modulo_right) = if text.len() & 0x1 == 0x1 {
455        (0, spaces_width % 2)
456    } else {
457        (spaces_width % 2, 0)
458    };
459
460    (even_width + modulo_left, even_width + modulo_right)
461}
462
463fn header_separator(f: &mut fmt::Formatter<'_>, columns: &RowDisplay<'_>) -> fmt::Result {
464    let mut header_iter = columns.row.iter().enumerate().map(|(index, _)| index);
465
466    if let Some(idx) = header_iter.next() {
467        write_separator_first_column(f, columns.columns_width.width(idx) + 2)?;
468
469        for idx in header_iter {
470            write_separator_next_columns(f, columns.columns_width.width(idx) + 2)?;
471        }
472
473        writeln!(f)?;
474    }
475
476    Ok(())
477}
478
479fn write_data(f: &mut fmt::Formatter<'_>, columns: &RowDisplay<'_>) -> fmt::Result {
480    let mut values_iter = columns.row.iter().enumerate();
481
482    if let Some((idx, ion_value)) = values_iter.next() {
483        let column = columns.columns_width.column(idx);
484        write_data_first_column(f, column, ion_value)?;
485
486        for (idx, ion_value) in values_iter {
487            let column = columns.columns_width.column(idx);
488            write_data_next_columns(f, column, ion_value)?;
489        }
490
491        writeln!(f)?;
492    }
493
494    Ok(())
495}
496
497fn write_data_first_column(
498    f: &mut fmt::Formatter<'_>,
499    column: Column,
500    ion_value: &Value,
501) -> fmt::Result {
502    let text = format!("{ion_value}");
503    write_data_cell(f, column, &text, "| ")
504}
505
506fn write_data_next_columns(
507    f: &mut fmt::Formatter<'_>,
508    column: Column,
509    ion_value: &Value,
510) -> fmt::Result {
511    let text = format!("{ion_value}");
512    write_data_cell(f, column, &text, " ")
513}
514
515fn write_header_first_column(
516    f: &mut fmt::Formatter<'_>,
517    text: &str,
518    left_alignment: usize,
519    right_alignment: usize,
520) -> fmt::Result {
521    write_header_cell(f, text, left_alignment, right_alignment, "| ")
522}
523
524fn write_header_next_columns(
525    f: &mut fmt::Formatter<'_>,
526    text: &str,
527    left_alignment: usize,
528    right_alignment: usize,
529) -> fmt::Result {
530    write_header_cell(f, text, left_alignment, right_alignment, " ")
531}
532
533fn write_header_cell(
534    f: &mut fmt::Formatter<'_>,
535    text: &str,
536    left_alignment: usize,
537    right_alignment: usize,
538    prefix: &str,
539) -> fmt::Result {
540    f.write_str(prefix)?;
541
542    write_repeated(f, ' ', left_alignment)?;
543    f.write_str(text)?;
544    write_repeated(f, ' ', right_alignment)?;
545    f.write_str(" |")
546}
547
548fn write_separator_first_column(f: &mut fmt::Formatter<'_>, width: usize) -> fmt::Result {
549    f.write_char('|')?;
550    write_separator_cell(f, width)
551}
552
553fn write_separator_next_columns(f: &mut fmt::Formatter<'_>, width: usize) -> fmt::Result {
554    write_separator_cell(f, width)
555}
556
557fn write_separator_cell(f: &mut fmt::Formatter<'_>, width: usize) -> fmt::Result {
558    write_repeated(f, '-', width)?;
559    f.write_char('|')
560}
561
562fn write_data_cell(
563    f: &mut fmt::Formatter<'_>,
564    column: Column,
565    text: &str,
566    prefix: &str,
567) -> fmt::Result {
568    f.write_str(prefix)?;
569
570    let padding = column.width.saturating_sub(text.len());
571    if column.is_number() {
572        write_repeated(f, ' ', padding)?;
573        f.write_str(text)?;
574    } else {
575        f.write_str(text)?;
576        write_repeated(f, ' ', padding)?;
577    }
578
579    f.write_str(" |")
580}
581
582fn write_repeated(f: &mut fmt::Formatter<'_>, ch: char, count: usize) -> fmt::Result {
583    for _ in 0..count {
584        f.write_char(ch)?;
585    }
586
587    Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::columns_width::{Column, ColumnType};
594    use indoc::indoc;
595    use ion::Dictionary;
596    use pretty_assertions::assert_eq;
597    use std::sync::LazyLock;
598    use test_case::test_case;
599
600    #[derive(Debug)]
601    struct IonFormatTestCase {
602        description: &'static str,
603        raw: &'static str,
604        options: FormatOptions,
605        expected: &'static str,
606    }
607
608    #[derive(Debug)]
609    struct DictionaryDisplayTestCase {
610        description: &'static str,
611        dictionary: Dictionary,
612        field: FieldStyle,
613        expected: &'static str,
614    }
615
616    #[derive(Debug)]
617    struct DictionaryFieldDisplayTestCase {
618        description: &'static str,
619        key: &'static str,
620        value: Value,
621        field: FieldStyle,
622        expected: &'static str,
623    }
624
625    #[derive(Debug)]
626    struct WriteDictionaryMultilineStringTestCase {
627        description: &'static str,
628        key: &'static str,
629        text: &'static str,
630        expected: &'static str,
631    }
632
633    #[derive(Debug)]
634    struct RowDisplayTestCase {
635        description: &'static str,
636        row: Row,
637        row_type: RowTypeDisplay,
638        columns_width: ColumnsWidth,
639        expected: &'static str,
640    }
641
642    fn string(value: &str) -> Value {
643        Value::String(value.into())
644    }
645
646    fn dictionary(entries: impl IntoIterator<Item = (&'static str, Value)>) -> Dictionary {
647        let mut dictionary = Dictionary::new();
648        for (key, value) in entries {
649            dictionary.insert(key.into(), value);
650        }
651        dictionary
652    }
653
654    #[test]
655    fn section_spacing_default_is_additional_newline() {
656        assert_eq!(SectionSpacing::AdditionalNewLine, SectionSpacing::default());
657        assert_eq!(
658            SectionSpacing::AdditionalNewLine,
659            FormatOptions::default().section.spacing
660        );
661    }
662
663    #[test]
664    fn document_spacing_default_is_newline() {
665        assert_eq!(DocumentSpacing::EndNewLine, DocumentSpacing::default());
666        assert_eq!(
667            DocumentSpacing::EndNewLine,
668            FormatOptions::default().document.spacing
669        );
670    }
671
672    static ION_FORMAT_CASE: LazyLock<IonFormatTestCase> = LazyLock::new(|| IonFormatTestCase {
673        description: "formats ion document",
674        raw: indoc! {r#"
675            [ALPHA]
676            name = "foo"
677
678            [BETA]
679            | num | text |
680            |-----|------|
681            | 1   | A    |
682            | 22  | B    |
683        "#},
684        options: FormatOptions {
685            dictionary: DictionaryOptions {
686                field: FieldStyle::Singleline,
687            },
688            section: SectionOptions {
689                spacing: SectionSpacing::AdditionalNewLine,
690            },
691            document: DocumentOptions {
692                spacing: DocumentSpacing::EndNewLine,
693            },
694            table: None,
695        },
696        expected: indoc! {r#"
697            [ALPHA]
698            name = "foo"
699
700            [BETA]
701            | num | text |
702            |-----|------|
703            |   1 | A    |
704            |  22 | B    |
705
706        "#},
707    });
708    static ION_FORMAT_NON_STRING_DICTIONARY_CASE: LazyLock<IonFormatTestCase> =
709        LazyLock::new(|| IonFormatTestCase {
710            description: "formats non string dictionary value",
711            raw: indoc! {r"
712                [ALPHA]
713                value = 7
714            "},
715            options: FormatOptions {
716                dictionary: DictionaryOptions {
717                    field: FieldStyle::Singleline,
718                },
719                section: SectionOptions {
720                    spacing: SectionSpacing::AdditionalNewLine,
721                },
722                document: DocumentOptions {
723                    spacing: DocumentSpacing::EndNewLine,
724                },
725                table: None,
726            },
727            expected: indoc! {r"
728                [ALPHA]
729                value = 7
730
731            "},
732        });
733    const TEST_1A_BASE_CASE: IonFormatTestCase = IonFormatTestCase {
734        description: "TEST_1A base input with singleline dictionary, single newline section, single newline document",
735        raw: indoc! {r#"
736            [Data]
737            select = "
738                SELECT column
739                FROM table t1
740                INNER JOIN t2
741                    ON t1.id = t2.
742                WHERE t1.userid = {{ user_id }}
743                ORDER BY name ASC
744            "
745            |   col1   |
746            |--------|
747            | name1 |
748            | name2 |
749        "#},
750        options: FormatOptions {
751            dictionary: DictionaryOptions {
752                field: FieldStyle::Singleline,
753            },
754            section: SectionOptions {
755                spacing: SectionSpacing::NewLine,
756            },
757            document: DocumentOptions {
758                spacing: DocumentSpacing::EndNewLine,
759            },
760            table: None,
761        },
762        expected: indoc! {r#"
763            [Data]
764            select = "\n    SELECT column\n    FROM table t1\n    INNER JOIN t2\n        ON t1.id = t2.\n    WHERE t1.userid = {{ user_id }}\n    ORDER BY name ASC\n"
765            |  col1 |
766            |-------|
767            | name1 |
768            | name2 |
769
770        "#},
771    };
772    const TEST_1B_DICTIONARY_MULTILINE_CASE: IonFormatTestCase = IonFormatTestCase {
773        description: "TEST_1B same input as TEST_1A with multiline dictionary field style",
774        options: FormatOptions {
775            dictionary: DictionaryOptions {
776                field: FieldStyle::Multiline,
777            },
778            ..TEST_1A_BASE_CASE.options
779        },
780        expected: indoc! {r#"
781            [Data]
782            select = "
783                SELECT column
784                FROM table t1
785                INNER JOIN t2
786                    ON t1.id = t2.
787                WHERE t1.userid = {{ user_id }}
788                ORDER BY name ASC
789            "
790            |  col1 |
791            |-------|
792            | name1 |
793            | name2 |
794
795        "#},
796        ..TEST_1A_BASE_CASE
797    };
798    const TEST_1C_SECTION_ADDITIONAL_NEWLINE_CASE: IonFormatTestCase = IonFormatTestCase {
799        description: "TEST_1C same input as TEST_1A with additional newline between dictionary and table",
800        options: FormatOptions {
801            section: SectionOptions {
802                spacing: SectionSpacing::AdditionalNewLine,
803            },
804            ..TEST_1A_BASE_CASE.options
805        },
806        expected: indoc! {r#"
807                [Data]
808                select = "\n    SELECT column\n    FROM table t1\n    INNER JOIN t2\n        ON t1.id = t2.\n    WHERE t1.userid = {{ user_id }}\n    ORDER BY name ASC\n"
809
810                |  col1 |
811                |-------|
812                | name1 |
813                | name2 |
814
815            "#},
816        ..TEST_1A_BASE_CASE
817    };
818    const TEST_1D_DOCUMENT_ADDITIONAL_NEWLINE_CASE: IonFormatTestCase = IonFormatTestCase {
819        description: "TEST_1D same input as TEST_1A with additional newline at document end",
820        options: FormatOptions {
821            document: DocumentOptions {
822                spacing: DocumentSpacing::AdditionalEndNewLine,
823            },
824            table: None,
825            ..TEST_1A_BASE_CASE.options
826        },
827        expected: indoc! {r#"
828                [Data]
829                select = "\n    SELECT column\n    FROM table t1\n    INNER JOIN t2\n        ON t1.id = t2.\n    WHERE t1.userid = {{ user_id }}\n    ORDER BY name ASC\n"
830                |  col1 |
831                |-------|
832                | name1 |
833                | name2 |
834
835
836            "#},
837        ..TEST_1A_BASE_CASE
838    };
839    const TEST_1E_DEFAULT_BEHAVIOR_CASE: IonFormatTestCase = IonFormatTestCase {
840        description: "TEST_1E same input as TEST_1A with current default option combination",
841        options: FormatOptions {
842            dictionary: DictionaryOptions {
843                field: FieldStyle::Multiline,
844            },
845            section: SectionOptions {
846                spacing: SectionSpacing::AdditionalNewLine,
847            },
848            document: DocumentOptions {
849                spacing: DocumentSpacing::EndNewLine,
850            },
851            table: None,
852        },
853        expected: indoc! {r#"
854            [Data]
855            select = "
856                SELECT column
857                FROM table t1
858                INNER JOIN t2
859                    ON t1.id = t2.
860                WHERE t1.userid = {{ user_id }}
861                ORDER BY name ASC
862            "
863
864            |  col1 |
865            |-------|
866            | name1 |
867            | name2 |
868
869        "#},
870        ..TEST_1A_BASE_CASE
871    };
872    const TEST_2_WITHOUT_HEADER_TABLE: IonFormatTestCase = IonFormatTestCase {
873        description: "TEST_2 table rows without explicit header separator are formatted consistently",
874        raw: indoc! {r"
875            [WITHOUT_HEADER]
876            | 1 | alpha | PL | 11.2 | ok |
877            | 2 | beta | DE | 9 | hold |
878            | 3 | gamma | UK | 13.75 | ok |
879        "},
880        options: FormatOptions {
881            dictionary: DictionaryOptions {
882                field: FieldStyle::Singleline,
883            },
884            section: SectionOptions {
885                spacing: SectionSpacing::AdditionalNewLine,
886            },
887            document: DocumentOptions {
888                spacing: DocumentSpacing::EndNewLine,
889            },
890            table: None,
891        },
892        expected: indoc! {r"
893            [WITHOUT_HEADER]
894            | 1 | alpha | PL |  11.2 | ok   |
895            | 2 | beta  | DE |     9 | hold |
896            | 3 | gamma | UK | 13.75 | ok   |
897
898        "},
899    };
900    const TEST_2A_ADD_HEADER_SEPARATOR_WHEN_MISSING: IonFormatTestCase = IonFormatTestCase {
901        description: "TEST_2A synthesizes a header separator when configured",
902        raw: indoc! {r"
903            [WITHOUT_HEADER]
904            | id | name | country | amount | status |
905            | 1 | alpha | PL | 11.2 | ok |
906            | 2 | beta | DE | 9 | hold |
907            | 3 | gamma | UK | 13.75 | ok |
908        "},
909        options: FormatOptions {
910            dictionary: DictionaryOptions {
911                field: FieldStyle::Singleline,
912            },
913            section: SectionOptions {
914                spacing: SectionSpacing::AdditionalNewLine,
915            },
916            document: DocumentOptions {
917                spacing: DocumentSpacing::EndNewLine,
918            },
919            table: Some(TableOptions::AddHeaderSeparatorWhenMissing),
920        },
921        expected: indoc! {r"
922            [WITHOUT_HEADER]
923            | id |  name | country | amount | status |
924            |----|-------|---------|--------|--------|
925            |  1 | alpha | PL      |   11.2 | ok     |
926            |  2 | beta  | DE      |      9 | hold   |
927            |  3 | gamma | UK      |  13.75 | ok     |
928
929        "},
930    };
931    const TEST_3A_NESTED_DICTIONARY: IonFormatTestCase = IonFormatTestCase {
932        description: "TEST_3A formats dictionary with string, array, and nested dictionary values",
933        raw: indoc! {r#"
934            [CONTRACT]
935            country = "Poland"
936            markets = ["PL", "DE", "UK"]
937            75042 = {
938                view = "SV"
939                loc  = ["M", "B"]
940                dist = { beach_km = 4.1 }
941            }
942        "#},
943        options: FormatOptions {
944            dictionary: DictionaryOptions {
945                field: FieldStyle::Singleline,
946            },
947            section: SectionOptions {
948                spacing: SectionSpacing::AdditionalNewLine,
949            },
950            document: DocumentOptions {
951                spacing: DocumentSpacing::EndNewLine,
952            },
953            table: None,
954        },
955        #[cfg(not(feature = "dictionary-indexmap"))]
956        expected: indoc! {r#"
957            [CONTRACT]
958            75042 = { dist = { beach_km = 4.1 }, loc = [ "M", "B" ], view = "SV" }
959            country = "Poland"
960            markets = [ "PL", "DE", "UK" ]
961
962        "#},
963        #[cfg(feature = "dictionary-indexmap")]
964        expected: indoc! {r#"
965            [CONTRACT]
966            country = "Poland"
967            markets = [ "PL", "DE", "UK" ]
968            75042 = { view = "SV", loc = [ "M", "B" ], dist = { beach_km = 4.1 } }
969
970        "#},
971    };
972    const TEST_3B_NESTED_DICTIONARY: IonFormatTestCase = IonFormatTestCase {
973        description: "TEST_3B same input as TEST_3A with multiline dictionary field style",
974        options: FormatOptions {
975            dictionary: DictionaryOptions {
976                field: FieldStyle::Multiline,
977            },
978            ..TEST_3A_NESTED_DICTIONARY.options
979        },
980        #[cfg(not(feature = "dictionary-indexmap"))]
981        expected: indoc! {r#"
982            [CONTRACT]
983            75042 = {
984                dist = {
985                    beach_km = 4.1
986                }
987                loc = [ "M", "B" ]
988                view = "SV"
989            }
990            country = "Poland"
991            markets = [ "PL", "DE", "UK" ]
992
993        "#},
994        #[cfg(feature = "dictionary-indexmap")]
995        expected: indoc! {r#"
996            [CONTRACT]
997            country = "Poland"
998            markets = [ "PL", "DE", "UK" ]
999            75042 = {
1000                view = "SV"
1001                loc = [ "M", "B" ]
1002                dist = {
1003                    beach_km = 4.1
1004                }
1005            }
1006
1007        "#},
1008        ..TEST_3A_NESTED_DICTIONARY
1009    };
1010    const TEST_3C_NESTED_DICTIONARY: IonFormatTestCase = IonFormatTestCase {
1011        description: "TEST_3C same input as TEST_3A with default dictionary field style",
1012        options: FormatOptions {
1013            dictionary: DictionaryOptions {
1014                field: FieldStyle::Multiline,
1015            },
1016            document: DocumentOptions {
1017                spacing: DocumentSpacing::AdditionalEndNewLine,
1018            },
1019            table: None,
1020            ..TEST_3A_NESTED_DICTIONARY.options
1021        },
1022        #[cfg(not(feature = "dictionary-indexmap"))]
1023        expected: indoc! {r#"
1024            [CONTRACT]
1025            75042 = {
1026                dist = {
1027                    beach_km = 4.1
1028                }
1029                loc = [ "M", "B" ]
1030                view = "SV"
1031            }
1032            country = "Poland"
1033            markets = [ "PL", "DE", "UK" ]
1034
1035
1036        "#},
1037        #[cfg(feature = "dictionary-indexmap")]
1038        expected: indoc! {r#"
1039            [CONTRACT]
1040            country = "Poland"
1041            markets = [ "PL", "DE", "UK" ]
1042            75042 = {
1043                view = "SV"
1044                loc = [ "M", "B" ]
1045                dist = {
1046                    beach_km = 4.1
1047                }
1048            }
1049
1050
1051        "#},
1052        ..TEST_3A_NESTED_DICTIONARY
1053    };
1054    const TEST_4A_DEEP_NESTED_DICTIONARY: IonFormatTestCase = IonFormatTestCase {
1055        description: "TEST_4A formats deep nested dictionary field in singleline mode",
1056        raw: indoc! {r#"
1057            [CONFIG]
1058            config = {
1059                zeta = {
1060                    release = {
1061                        minor = 2
1062                        major = 1
1063                    }
1064                    flags = [true, false]
1065                    select = "
1066                        SELECT column
1067                        FROM table t4
1068                        INNER JOIN t6
1069                            ON t4.id = t6.id
1070                        WHERE t4.userid = {{ user_id }}
1071                        ORDER BY name ASC
1072                    "
1073                }
1074                alpha = "pkg"
1075                select = "
1076                    SELECT column
1077                    FROM table t1
1078                    INNER JOIN t2
1079                        ON t1.id = t2.something
1080                    WHERE t1.userid = {{ user_id }}
1081                    ORDER BY name ASC
1082                "
1083            }
1084        "#},
1085        options: FormatOptions {
1086            dictionary: DictionaryOptions {
1087                field: FieldStyle::Singleline,
1088            },
1089            section: SectionOptions {
1090                spacing: SectionSpacing::AdditionalNewLine,
1091            },
1092            document: DocumentOptions {
1093                spacing: DocumentSpacing::EndNewLine,
1094            },
1095            table: None,
1096        },
1097        #[cfg(not(feature = "dictionary-indexmap"))]
1098        expected: indoc! {r#"
1099            [CONFIG]
1100            config = { alpha = "pkg", select = "\n        SELECT column\n        FROM table t1\n        INNER JOIN t2\n            ON t1.id = t2.something\n        WHERE t1.userid = {{ user_id }}\n        ORDER BY name ASC\n    ", zeta = { flags = [ true, false ], release = { major = 1, minor = 2 }, select = "\n            SELECT column\n            FROM table t4\n            INNER JOIN t6\n                ON t4.id = t6.id\n            WHERE t4.userid = {{ user_id }}\n            ORDER BY name ASC\n        " } }
1101
1102        "#},
1103        #[cfg(feature = "dictionary-indexmap")]
1104        expected: indoc! {r#"
1105            [CONFIG]
1106            config = { zeta = { release = { minor = 2, major = 1 }, flags = [ true, false ], select = "\n            SELECT column\n            FROM table t4\n            INNER JOIN t6\n                ON t4.id = t6.id\n            WHERE t4.userid = {{ user_id }}\n            ORDER BY name ASC\n        " }, alpha = "pkg", select = "\n        SELECT column\n        FROM table t1\n        INNER JOIN t2\n            ON t1.id = t2.something\n        WHERE t1.userid = {{ user_id }}\n        ORDER BY name ASC\n    " }
1107
1108        "#},
1109    };
1110    const TEST_4B_DEEP_NESTED_DICTIONARY: IonFormatTestCase = IonFormatTestCase {
1111        description: "TEST_4B same input as TEST_4A in multiline mode",
1112        options: FormatOptions {
1113            dictionary: DictionaryOptions {
1114                field: FieldStyle::Multiline,
1115            },
1116            section: SectionOptions {
1117                spacing: SectionSpacing::AdditionalNewLine,
1118            },
1119            document: DocumentOptions {
1120                spacing: DocumentSpacing::EndNewLine,
1121            },
1122            table: None,
1123        },
1124        #[cfg(not(feature = "dictionary-indexmap"))]
1125        expected: indoc! {r#"
1126            [CONFIG]
1127            config = {
1128                alpha = "pkg"
1129                select = "
1130                    SELECT column
1131                    FROM table t1
1132                    INNER JOIN t2
1133                        ON t1.id = t2.something
1134                    WHERE t1.userid = {{ user_id }}
1135                    ORDER BY name ASC
1136                "
1137                zeta = {
1138                    flags = [ true, false ]
1139                    release = {
1140                        major = 1
1141                        minor = 2
1142                    }
1143                    select = "
1144                        SELECT column
1145                        FROM table t4
1146                        INNER JOIN t6
1147                            ON t4.id = t6.id
1148                        WHERE t4.userid = {{ user_id }}
1149                        ORDER BY name ASC
1150                    "
1151                }
1152            }
1153
1154        "#},
1155        #[cfg(feature = "dictionary-indexmap")]
1156        expected: indoc! {r#"
1157            [CONFIG]
1158            config = {
1159                zeta = {
1160                    release = {
1161                        minor = 2
1162                        major = 1
1163                    }
1164                    flags = [ true, false ]
1165                    select = "
1166                        SELECT column
1167                        FROM table t4
1168                        INNER JOIN t6
1169                            ON t4.id = t6.id
1170                        WHERE t4.userid = {{ user_id }}
1171                        ORDER BY name ASC
1172                    "
1173                }
1174                alpha = "pkg"
1175                select = "
1176                    SELECT column
1177                    FROM table t1
1178                    INNER JOIN t2
1179                        ON t1.id = t2.something
1180                    WHERE t1.userid = {{ user_id }}
1181                    ORDER BY name ASC
1182                "
1183            }
1184
1185        "#},
1186        ..TEST_4A_DEEP_NESTED_DICTIONARY
1187    };
1188    const TEST_5A_TABLE_ONLY_WITH_DEFAULT_SECTION_SPACING_CASE: IonFormatTestCase =
1189        IonFormatTestCase {
1190            description: "TEST_5A table-only input does not add section spacing when dictionary is empty",
1191            raw: indoc! {r"
1192            [TABLE]
1193            | c |
1194            |---|
1195            | 1 |
1196        "},
1197            options: FormatOptions {
1198                dictionary: DictionaryOptions {
1199                    field: FieldStyle::Singleline,
1200                },
1201                section: SectionOptions {
1202                    spacing: SectionSpacing::AdditionalNewLine,
1203                },
1204                document: DocumentOptions {
1205                    spacing: DocumentSpacing::EndNewLine,
1206                },
1207                table: None,
1208            },
1209            expected: indoc! {r"
1210            [TABLE]
1211            | c |
1212            |---|
1213            | 1 |
1214
1215        "},
1216        };
1217    #[test_case(&*ION_FORMAT_CASE)]
1218    #[test_case(&*ION_FORMAT_NON_STRING_DICTIONARY_CASE)]
1219    #[test_case(&TEST_1A_BASE_CASE)]
1220    #[test_case(&TEST_1B_DICTIONARY_MULTILINE_CASE)]
1221    #[test_case(&TEST_1C_SECTION_ADDITIONAL_NEWLINE_CASE)]
1222    #[test_case(&TEST_1D_DOCUMENT_ADDITIONAL_NEWLINE_CASE)]
1223    #[test_case(&TEST_1E_DEFAULT_BEHAVIOR_CASE)]
1224    #[test_case(&TEST_2_WITHOUT_HEADER_TABLE)]
1225    #[test_case(&TEST_2A_ADD_HEADER_SEPARATOR_WHEN_MISSING)]
1226    #[test_case(&TEST_3A_NESTED_DICTIONARY)]
1227    #[test_case(&TEST_3B_NESTED_DICTIONARY)]
1228    #[test_case(&TEST_3C_NESTED_DICTIONARY)]
1229    #[test_case(&TEST_4A_DEEP_NESTED_DICTIONARY)]
1230    #[test_case(&TEST_4B_DEEP_NESTED_DICTIONARY)]
1231    #[test_case(&TEST_5A_TABLE_ONLY_WITH_DEFAULT_SECTION_SPACING_CASE)]
1232    fn format_ion_document(case: &IonFormatTestCase) {
1233        let ion = case.raw.parse::<Ion>().unwrap();
1234        assert_eq!(
1235            case.expected,
1236            format_ion_with_options(&ion, case.options),
1237            "{}",
1238            case.description
1239        );
1240        assert_eq!(
1241            case.expected,
1242            display_with_options(&ion, case.options).to_string(),
1243            "{}",
1244            case.description
1245        );
1246    }
1247
1248    static DICTIONARY_DISPLAY_SINGLELINE_CASE: LazyLock<DictionaryDisplayTestCase> =
1249        LazyLock::new(|| DictionaryDisplayTestCase {
1250            description: "dictionary display with singleline style",
1251            dictionary: dictionary([("a", string("foo")), ("query", string("\nSELECT 1\n"))]),
1252            field: FieldStyle::Singleline,
1253            expected: "a = \"foo\"\nquery = \"\\nSELECT 1\\n\"\n",
1254        });
1255    static DICTIONARY_DISPLAY_MULTILINE_CASE: LazyLock<DictionaryDisplayTestCase> =
1256        LazyLock::new(|| DictionaryDisplayTestCase {
1257            description: "dictionary display with multiline style",
1258            dictionary: dictionary([("a", string("foo")), ("query", string("\nSELECT 1\n"))]),
1259            field: FieldStyle::Multiline,
1260            expected: "a = \"foo\"\nquery = \"\nSELECT 1\n\"\n",
1261        });
1262
1263    #[test_case(&*DICTIONARY_DISPLAY_SINGLELINE_CASE)]
1264    #[test_case(&*DICTIONARY_DISPLAY_MULTILINE_CASE)]
1265    fn display_dictionary(case: &DictionaryDisplayTestCase) {
1266        assert_eq!(
1267            case.expected,
1268            DictionaryDisplay::new(&case.dictionary, case.field).to_string(),
1269            "{}",
1270            case.description
1271        );
1272    }
1273
1274    static DICTIONARY_FIELD_NON_STRING_CASE: LazyLock<DictionaryFieldDisplayTestCase> =
1275        LazyLock::new(|| DictionaryFieldDisplayTestCase {
1276            description: "dictionary field non string value",
1277            key: "count",
1278            value: Value::Integer(7),
1279            field: FieldStyle::Singleline,
1280            expected: "count = 7\n",
1281        });
1282    static DICTIONARY_FIELD_MULTILINE_SINGLELINE_CASE: LazyLock<DictionaryFieldDisplayTestCase> =
1283        LazyLock::new(|| DictionaryFieldDisplayTestCase {
1284            description: "dictionary field multiline value with singleline style",
1285            key: "query",
1286            value: string("\nSELECT 1\n"),
1287            field: FieldStyle::Singleline,
1288            expected: "query = \"\\nSELECT 1\\n\"\n",
1289        });
1290    static DICTIONARY_FIELD_MULTILINE_MULTILINE_CASE: LazyLock<DictionaryFieldDisplayTestCase> =
1291        LazyLock::new(|| DictionaryFieldDisplayTestCase {
1292            description: "dictionary field multiline value with multiline style",
1293            key: "query",
1294            value: string("\nSELECT 1\n"),
1295            field: FieldStyle::Multiline,
1296            expected: "query = \"\nSELECT 1\n\"\n",
1297        });
1298    static DICTIONARY_FIELD_DICTIONARY_MULTILINE_CASE: LazyLock<DictionaryFieldDisplayTestCase> =
1299        LazyLock::new(|| {
1300            let value = Value::Dictionary(dictionary([
1301                ("view", string("SV")),
1302                ("loc", Value::Array(vec![string("M"), string("B")])),
1303                (
1304                    "dist",
1305                    Value::Dictionary(dictionary([("beach_km", Value::Float(4.1))])),
1306                ),
1307            ]));
1308            DictionaryFieldDisplayTestCase {
1309                description: "dictionary field dictionary value with multiline style",
1310                key: "75042",
1311                value,
1312                field: FieldStyle::Multiline,
1313                expected: if cfg!(feature = "dictionary-indexmap") {
1314                    indoc! {r#"
1315                        75042 = {
1316                            view = "SV"
1317                            loc = [ "M", "B" ]
1318                            dist = {
1319                                beach_km = 4.1
1320                            }
1321                        }
1322                    "#}
1323                } else {
1324                    indoc! {r#"
1325                        75042 = {
1326                            dist = {
1327                                beach_km = 4.1
1328                            }
1329                            loc = [ "M", "B" ]
1330                            view = "SV"
1331                        }
1332                    "#}
1333                },
1334            }
1335        });
1336    #[test_case(&*DICTIONARY_FIELD_NON_STRING_CASE)]
1337    #[test_case(&*DICTIONARY_FIELD_MULTILINE_SINGLELINE_CASE)]
1338    #[test_case(&*DICTIONARY_FIELD_MULTILINE_MULTILINE_CASE)]
1339    #[test_case(&*DICTIONARY_FIELD_DICTIONARY_MULTILINE_CASE)]
1340    fn display_dictionary_field(case: &DictionaryFieldDisplayTestCase) {
1341        assert_eq!(
1342            case.expected,
1343            DictionaryFieldDisplay::new(case.key, &case.value, case.field).to_string(),
1344            "{}",
1345            case.description
1346        );
1347    }
1348
1349    fn render_dictionary_multiline_string(key: &str, text: &str) -> String {
1350        struct DictionaryMultilineStringDisplay<'a> {
1351            key: &'a str,
1352            text: &'a str,
1353        }
1354
1355        impl std::fmt::Display for DictionaryMultilineStringDisplay<'_> {
1356            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1357                write_dictionary_multiline_string(f, self.key, self.text, 0)
1358            }
1359        }
1360
1361        DictionaryMultilineStringDisplay { key, text }.to_string()
1362    }
1363
1364    const WRITE_MULTILINE_DICTIONARY_STRING_EMPTY_CASE: WriteDictionaryMultilineStringTestCase =
1365        WriteDictionaryMultilineStringTestCase {
1366            description: "writes empty multiline dictionary string",
1367            key: "query",
1368            text: "",
1369            expected: "query = \"\"",
1370        };
1371    const WRITE_MULTILINE_DICTIONARY_STRING_MULTILINE_CASE: WriteDictionaryMultilineStringTestCase =
1372        WriteDictionaryMultilineStringTestCase {
1373            description: "preserves multiline text and trailing newline",
1374            key: "query",
1375            text: "\nSELECT 1\nFROM dual\n",
1376            expected: "query = \"\nSELECT 1\nFROM dual\n\"",
1377        };
1378    const WRITE_MULTILINE_DICTIONARY_STRING_ESCAPES_QUOTE_CASE:
1379        WriteDictionaryMultilineStringTestCase = WriteDictionaryMultilineStringTestCase {
1380        description: "escapes quotes in multiline dictionary string",
1381        key: "query",
1382        text: "value \"quoted\"",
1383        expected: "query = \"value \\\"quoted\\\"\"",
1384    };
1385    const WRITE_MULTILINE_DICTIONARY_STRING_ESCAPES_BACKSLASH_CASE:
1386        WriteDictionaryMultilineStringTestCase = WriteDictionaryMultilineStringTestCase {
1387        description: "escapes backslashes in multiline dictionary string",
1388        key: "query",
1389        text: r"C:\work\ion",
1390        expected: "query = \"C:\\\\work\\\\ion\"",
1391    };
1392    const WRITE_MULTILINE_DICTIONARY_STRING_ESCAPES_QUOTE_AND_BACKSLASH_CASE:
1393        WriteDictionaryMultilineStringTestCase = WriteDictionaryMultilineStringTestCase {
1394        description: "escapes quotes and backslashes while preserving newlines",
1395        key: "query",
1396        text: "path=\"C:\\work\"\n-- done",
1397        expected: "query = \"path=\\\"C:\\\\work\\\"\n-- done\"",
1398    };
1399
1400    #[test_case(&WRITE_MULTILINE_DICTIONARY_STRING_EMPTY_CASE)]
1401    #[test_case(&WRITE_MULTILINE_DICTIONARY_STRING_MULTILINE_CASE)]
1402    #[test_case(&WRITE_MULTILINE_DICTIONARY_STRING_ESCAPES_QUOTE_CASE)]
1403    #[test_case(&WRITE_MULTILINE_DICTIONARY_STRING_ESCAPES_BACKSLASH_CASE)]
1404    #[test_case(&WRITE_MULTILINE_DICTIONARY_STRING_ESCAPES_QUOTE_AND_BACKSLASH_CASE)]
1405    fn write_dictionary_multiline_string_cases(case: &WriteDictionaryMultilineStringTestCase) {
1406        assert_eq!(
1407            case.expected,
1408            render_dictionary_multiline_string(case.key, case.text),
1409            "{}",
1410            case.description
1411        );
1412    }
1413
1414    static HEADER_ROW_DISPLAY_CASE: LazyLock<RowDisplayTestCase> =
1415        LazyLock::new(|| RowDisplayTestCase {
1416            description: "header row",
1417            row: vec![string("num"), string("total")],
1418            row_type: RowTypeDisplay::Header,
1419            columns_width: ColumnsWidth::new(vec![
1420                Column {
1421                    width: 3,
1422                    typ: ColumnType::Text,
1423                },
1424                Column {
1425                    width: 5,
1426                    typ: ColumnType::Text,
1427                },
1428            ]),
1429            expected: "| num | total |\n",
1430        });
1431    static SEPARATOR_ROW_DISPLAY_CASE: LazyLock<RowDisplayTestCase> =
1432        LazyLock::new(|| RowDisplayTestCase {
1433            description: "separator row",
1434            row: vec![string("num"), string("total")],
1435            row_type: RowTypeDisplay::Separator,
1436            columns_width: ColumnsWidth::new(vec![
1437                Column {
1438                    width: 3,
1439                    typ: ColumnType::Text,
1440                },
1441                Column {
1442                    width: 5,
1443                    typ: ColumnType::Text,
1444                },
1445            ]),
1446            expected: "|-----|-------|\n",
1447        });
1448    static DATA_ROW_DISPLAY_CASE: LazyLock<RowDisplayTestCase> =
1449        LazyLock::new(|| RowDisplayTestCase {
1450            description: "data row",
1451            row: vec![Value::Integer(12), string("A")],
1452            row_type: RowTypeDisplay::Data,
1453            columns_width: ColumnsWidth::new(vec![
1454                Column {
1455                    width: 3,
1456                    typ: ColumnType::Number,
1457                },
1458                Column {
1459                    width: 5,
1460                    typ: ColumnType::Text,
1461                },
1462            ]),
1463            expected: "|  12 | A     |\n",
1464        });
1465    static HEADER_ROW_DISPLAY_EVEN_TEXT_CASE: LazyLock<RowDisplayTestCase> =
1466        LazyLock::new(|| RowDisplayTestCase {
1467            description: "header row with even text",
1468            row: vec![string("ABCD"), string("EF")],
1469            row_type: RowTypeDisplay::Header,
1470            columns_width: ColumnsWidth::new(vec![
1471                Column {
1472                    width: 5,
1473                    typ: ColumnType::Text,
1474                },
1475                Column {
1476                    width: 4,
1477                    typ: ColumnType::Text,
1478                },
1479            ]),
1480            expected: "|  ABCD |  EF  |\n",
1481        });
1482    static DATA_ROW_DISPLAY_TEXT_THEN_NUMBER_CASE: LazyLock<RowDisplayTestCase> =
1483        LazyLock::new(|| RowDisplayTestCase {
1484            description: "data row with text first and number next",
1485            row: vec![string("A"), Value::Integer(7)],
1486            row_type: RowTypeDisplay::Data,
1487            columns_width: ColumnsWidth::new(vec![
1488                Column {
1489                    width: 3,
1490                    typ: ColumnType::Text,
1491                },
1492                Column {
1493                    width: 4,
1494                    typ: ColumnType::Number,
1495                },
1496            ]),
1497            expected: "| A   |    7 |\n",
1498        });
1499    static EMPTY_HEADER_ROW_DISPLAY_CASE: LazyLock<RowDisplayTestCase> =
1500        LazyLock::new(|| RowDisplayTestCase {
1501            description: "empty header row",
1502            row: vec![],
1503            row_type: RowTypeDisplay::Header,
1504            columns_width: ColumnsWidth::new(vec![]),
1505            expected: "",
1506        });
1507    static EMPTY_SEPARATOR_ROW_DISPLAY_CASE: LazyLock<RowDisplayTestCase> =
1508        LazyLock::new(|| RowDisplayTestCase {
1509            description: "empty separator row",
1510            row: vec![],
1511            row_type: RowTypeDisplay::Separator,
1512            columns_width: ColumnsWidth::new(vec![]),
1513            expected: "",
1514        });
1515    static EMPTY_DATA_ROW_DISPLAY_CASE: LazyLock<RowDisplayTestCase> =
1516        LazyLock::new(|| RowDisplayTestCase {
1517            description: "empty data row",
1518            row: vec![],
1519            row_type: RowTypeDisplay::Data,
1520            columns_width: ColumnsWidth::new(vec![]),
1521            expected: "",
1522        });
1523
1524    #[test_case(&*HEADER_ROW_DISPLAY_CASE)]
1525    #[test_case(&*SEPARATOR_ROW_DISPLAY_CASE)]
1526    #[test_case(&*DATA_ROW_DISPLAY_CASE)]
1527    #[test_case(&*HEADER_ROW_DISPLAY_EVEN_TEXT_CASE)]
1528    #[test_case(&*DATA_ROW_DISPLAY_TEXT_THEN_NUMBER_CASE)]
1529    #[test_case(&*EMPTY_HEADER_ROW_DISPLAY_CASE)]
1530    #[test_case(&*EMPTY_SEPARATOR_ROW_DISPLAY_CASE)]
1531    #[test_case(&*EMPTY_DATA_ROW_DISPLAY_CASE)]
1532    fn display_row(case: &RowDisplayTestCase) {
1533        let row_display = RowDisplay {
1534            columns_width: &case.columns_width,
1535            row: &case.row,
1536            row_type: case.row_type,
1537        };
1538        assert_eq!(
1539            case.expected,
1540            row_display.to_string(),
1541            "{}",
1542            case.description
1543        );
1544    }
1545}