Skip to main content

inillucent_cli/
render.rs

1//! The output modes: how a row becomes the text a person or a script reads.
2//!
3//! Invariant: a mode decides layout and nothing else. Every mode is handed the
4//! same values, and none of them converts one - a blob prints as its bytes in
5//! `list` and as an `x'...'` literal in `quote` because those are two ways of
6//! writing the same value, not two values. The conversions themselves belong to
7//! the engine, which is why nothing in this file parses or casts anything.
8//!
9//! The default is SQLite's: `list` mode, `|` between columns, headers off, and
10//! NULL as the empty string. That last one is a genuinely bad default and it is
11//! kept anyway, because a script written against `sqlite3` and pointed at this
12//! shell has to see the same bytes.
13
14use inillucent_value::Value;
15
16/// How rows are laid out.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub enum Mode {
19    /// Columns separated by the separator, one row per line.
20    #[default]
21    List,
22    /// Fixed-width columns, padded to the widest value.
23    Column,
24    /// One `name = value` line per column, a blank line between rows.
25    Line,
26    /// Comma-separated, quoted the way a spreadsheet expects.
27    Csv,
28    /// Columns separated by tabs.
29    Tabs,
30    /// Every value as an SQL literal.
31    Quote,
32    /// One `INSERT INTO` statement per row.
33    Insert,
34    /// A JSON array of objects.
35    Json,
36    /// A Markdown table.
37    Markdown,
38    /// A table drawn with `+` and `-`.
39    Table,
40    /// A table drawn with box-drawing characters.
41    Box,
42    /// An HTML table body.
43    Html,
44}
45
46impl Mode {
47    /// Returns the mode a name selects.
48    pub fn from_name(name: &str) -> Option<Mode> {
49        match name.to_ascii_lowercase().as_str() {
50            "list" => Some(Mode::List),
51            "column" | "columns" => Some(Mode::Column),
52            "line" | "lines" => Some(Mode::Line),
53            "csv" => Some(Mode::Csv),
54            "tabs" => Some(Mode::Tabs),
55            "quote" => Some(Mode::Quote),
56            "insert" => Some(Mode::Insert),
57            "json" => Some(Mode::Json),
58            "markdown" => Some(Mode::Markdown),
59            "table" => Some(Mode::Table),
60            "box" => Some(Mode::Box),
61            "html" => Some(Mode::Html),
62            _ => None,
63        }
64    }
65
66    /// Returns the column separator this mode starts with.
67    ///
68    /// Choosing a mode resets the separator, which is why `.mode csv` produces
69    /// commas without being told to. A `.separator` afterwards still wins.
70    pub fn separator(self) -> &'static str {
71        match self {
72            Mode::Csv | Mode::Quote => ",",
73            Mode::Tabs => "\t",
74            _ => "|",
75        }
76    }
77
78    /// Returns the name `.show` prints for this mode.
79    pub fn name(self) -> &'static str {
80        match self {
81            Mode::List => "list",
82            Mode::Column => "column",
83            Mode::Line => "line",
84            Mode::Csv => "csv",
85            Mode::Tabs => "tabs",
86            Mode::Quote => "quote",
87            Mode::Insert => "insert",
88            Mode::Json => "json",
89            Mode::Markdown => "markdown",
90            Mode::Table => "table",
91            Mode::Box => "box",
92            Mode::Html => "html",
93        }
94    }
95}
96
97/// Everything a mode needs that is not the rows themselves.
98#[derive(Clone, Debug)]
99pub struct Layout {
100    /// Which mode.
101    pub mode: Mode,
102    /// What goes between columns, in the modes that use one.
103    pub separator: String,
104    /// What goes between rows.
105    pub row_separator: String,
106    /// What a NULL prints as.
107    pub null: String,
108    /// Whether to print a header line.
109    pub headers: bool,
110    /// The table name `insert` mode writes.
111    pub table: String,
112    /// The column widths `.width` fixed, if any.
113    pub widths: Vec<usize>,
114    /// Whether these lines are going to standard output.
115    ///
116    /// **Only `csv` reads it, and only on Windows**, where standard output is
117    /// the one destination that translates a line feed on the way out. The
118    /// bytes are in the comment at the end of [`csv`]. A file and a collecting
119    /// caller both receive exactly what is written to them, so the second
120    /// carriage return that makes standard output match the reference is wrong
121    /// for both. The shell sets this from where its own output is currently
122    /// going; the default is standard output, because that is where a `Layout`
123    /// built by hand is printed.
124    pub to_stdout: bool,
125}
126
127impl Default for Layout {
128    /// Returns SQLite's defaults.
129    fn default() -> Layout {
130        Layout {
131            mode: Mode::List,
132            separator: "|".to_string(),
133            row_separator: "\n".to_string(),
134            null: String::new(),
135            headers: false,
136            // "tab", not "table": the reference chose a name that is not
137            // a keyword, so the statements it writes can be pasted back.
138            table: "tab".to_string(),
139            widths: Vec::new(),
140            to_stdout: true,
141        }
142    }
143}
144
145/// Renders a result set into the lines that should be printed.
146///
147/// It returns lines rather than writing, so the caller decides where they go -
148/// which is what `.output` and `.once` need, and what makes this testable
149/// without a file.
150pub fn render(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
151    match layout.mode {
152        Mode::List | Mode::Tabs => separated(layout, columns, rows),
153        Mode::Csv => csv(layout, columns, rows),
154        Mode::Quote => quoted(layout, columns, rows),
155        Mode::Line => lines(layout, columns, rows),
156        Mode::Insert => inserts(layout, columns, rows),
157        Mode::Json => json(layout, columns, rows),
158        Mode::Column => aligned(layout, columns, rows),
159        Mode::Markdown | Mode::Table | Mode::Box => drawn(layout, columns, rows),
160        Mode::Html => html(layout, columns, rows),
161    }
162}
163
164/// Returns the text a value prints as in the plain modes.
165fn plain(layout: &Layout, value: &Value<'static>) -> String {
166    match value {
167        Value::Null => layout.null.clone(),
168        Value::Integer(number) => number.to_string(),
169        Value::Real(_) => number_text(value),
170        Value::Text(text) => printable(text.raw()),
171        Value::Blob(blob) => printable(blob.raw()),
172    }
173}
174
175/// Returns bytes as the shell prints them.
176///
177/// Two rules, both the reference's. A value is printed as a C string, so it
178/// stops at the first NUL; and a control character is printed in caret
179/// notation, because a shell that emitted raw control bytes could be made to
180/// drive a terminal by the contents of a database. A newline is left alone,
181/// since a multi-line value is meant to look like one.
182fn printable(bytes: &[u8]) -> String {
183    let end = bytes
184        .iter()
185        .position(|byte| *byte == 0)
186        .unwrap_or(bytes.len());
187    let visible = bytes.get(..end).unwrap_or(bytes);
188    let mut out = String::with_capacity(visible.len());
189    for chunk in String::from_utf8_lossy(visible).chars() {
190        let code = chunk as u32;
191        if chunk == '\n' {
192            out.push(chunk);
193        } else if code < 0x20 {
194            out.push('^');
195            out.push(char::from_u32(code + 0x40).unwrap_or('?'));
196        } else if code == 0x7f {
197            out.push_str("^?");
198        } else {
199            out.push(chunk);
200        }
201    }
202    out
203}
204
205/// Returns the text the engine writes a number as.
206fn number_text(value: &Value<'static>) -> String {
207    let cast = inillucent_value::cast::cast_value(
208        value.clone(),
209        inillucent_value::Affinity::Text,
210        inillucent_value::TextEncoding::Utf8,
211    );
212    match cast {
213        Ok(Value::Text(text)) => String::from_utf8_lossy(text.raw()).into_owned(),
214        _ => String::new(),
215    }
216}
217
218/// Returns the SQL literal a value would be written as.
219pub fn literal(value: &Value<'static>) -> String {
220    match value {
221        Value::Null => "NULL".to_string(),
222        Value::Integer(number) => number.to_string(),
223        Value::Real(_) => number_text(value),
224        Value::Text(text) => {
225            let body = String::from_utf8_lossy(text.raw()).replace('\'', "''");
226            format!("'{body}'")
227        }
228        Value::Blob(blob) => {
229            // Lower case, both the `x` and the digits: it is what the reference
230            // writes, and a dump is compared against one.
231            let mut out = String::from("x'");
232            for byte in blob.raw() {
233                out.push_str(&format!("{byte:02x}"));
234            }
235            out.push('\'');
236            out
237        }
238    }
239}
240
241/// `list` and `tabs`: values with a separator between them.
242fn separated(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
243    let separator = if layout.mode == Mode::Tabs {
244        "\t"
245    } else {
246        layout.separator.as_str()
247    };
248    let mut out = Vec::with_capacity(rows.len() + 1);
249    if layout.headers {
250        out.push(columns.join(separator));
251    }
252    for row in rows {
253        let cells: Vec<String> = row.iter().map(|value| plain(layout, value)).collect();
254        out.push(cells.join(separator));
255    }
256    out
257}
258
259/// `csv`: a field is quoted when it holds a comma, a quote or a newline.
260fn csv(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
261    let mut out = Vec::with_capacity(rows.len() + 1);
262    if layout.headers {
263        out.push(
264            columns
265                .iter()
266                .map(|name| csv_field(name))
267                .collect::<Vec<String>>()
268                .join(","),
269        );
270    }
271    for row in rows {
272        let cells: Vec<String> = row.iter().map(|value| csv_cell(layout, value)).collect();
273        out.push(cells.join(","));
274    }
275    // The caller writes a newline after each line, so a row separator of
276    // CR LF is a carriage return on the end of the line itself.
277    //
278    // **And a second one, but only on standard output, and only on Windows.**
279    // The reference writes its CR LF through a text-mode C stream, which
280    // translates the LF into CR LF again on the way out, and Rust's `write!`
281    // does no such translation - so emitting two bytes where the reference
282    // emits three would not be byte-compatible with the thing this replaces.
283    //
284    // The destination decides, because the reference's destinations differ.
285    // Measured against the pinned 3.53.4 shell on Windows, one row of one
286    // table, `.mode csv` with headers on:
287    //
288    // | what the reference was asked for | bytes at the end of a record |
289    // |---|---|
290    // | `sqlite3 -csv -header db "SELECT..."`, standard output | CR CR LF |
291    // | the same rows through `.once out.csv`, in the file | CR LF |
292    //
293    // Its output file is not a text-mode stream, so the file gets RFC 4180's
294    // CR LF and nothing more. This shell wrote CR CR LF into the file and into
295    // the text a collecting caller reads, which matched neither. That is what
296    // `to_stdout` is for.
297    if layout.row_separator.ends_with(CRLF) {
298        for line in &mut out {
299            line.push(CR);
300            if cfg!(windows) && layout.to_stdout {
301                line.push(CR);
302            }
303        }
304    }
305    out
306}
307
308/// The row separator RFC 4180 gives a CSV record, and the reference writes.
309const CRLF: &str = "\r\n";
310
311/// The carriage return half of it.
312const CR: char = '\r';
313
314/// Renders one value as a CSV field.
315///
316/// @param layout - the mode's settings, for `nullvalue`
317/// @param value - the cell
318fn csv_cell(layout: &Layout, value: &Value<'static>) -> String {
319    let text = plain(layout, value);
320    // **An empty field that is not NULL is quoted, which is how the two are
321    // told apart** (task-2066 section 4.2, item 27). A BLOB is printed as a C
322    // string and stops at its first NUL, so a blob beginning with one rendered
323    // as nothing at all - and so does a NULL under the default `nullvalue`,
324    // which is the empty string. The reference writes two quotes for the
325    // first and nothing for the second, so an exported blob could be read back
326    // as a NULL.
327    if text.is_empty() && !value.is_null() {
328        return "\"\"".to_string();
329    }
330    csv_field(&text)
331}
332
333/// Quotes one CSV field, if it needs it.
334///
335/// The separator, a quote and a line break all force quoting, and so does a
336/// control character - it has already been turned into caret notation by the
337/// time this sees it, and quoting is how the reference marks that the field was
338/// not plain text to begin with.
339///
340/// @param text - the rendered field
341fn csv_field(text: &str) -> String {
342    let needs = text.contains(',')
343        || text.contains('"')
344        || text.contains('\n')
345        || text.contains('\r')
346        || text.contains('^');
347    if !needs {
348        return text.to_string();
349    }
350    format!("\"{}\"", text.replace('"', "\"\""))
351}
352
353/// `quote`: every value as the literal it would be written as.
354fn quoted(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
355    let mut out = Vec::with_capacity(rows.len() + 1);
356    if layout.headers {
357        out.push(
358            columns
359                .iter()
360                .map(|name| format!("'{}'", name.replace('\'', "''")))
361                .collect::<Vec<String>>()
362                .join(&layout.separator),
363        );
364    }
365    for row in rows {
366        let cells: Vec<String> = row.iter().map(literal).collect();
367        out.push(cells.join(&layout.separator));
368    }
369    out
370}
371
372/// `line`: one `name: value` per column, rows separated by a blank line.
373fn lines(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
374    let width = columns
375        .iter()
376        .map(|name| name.chars().count())
377        .max()
378        .unwrap_or(0);
379    let mut out = Vec::new();
380    for (index, row) in rows.iter().enumerate() {
381        if index > 0 {
382            out.push(String::new());
383        }
384        for (position, value) in row.iter().enumerate() {
385            let name = columns.get(position).cloned().unwrap_or_default();
386            out.push(format!("{name:>width$}: {}", plain(layout, value)));
387        }
388    }
389    out
390}
391
392/// `insert`: one statement per row, which is what a dump is made of.
393fn inserts(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
394    let _ = columns;
395    rows.iter()
396        .map(|row| {
397            let cells: Vec<String> = row.iter().map(literal).collect();
398            format!("INSERT INTO {} VALUES({});", layout.table, cells.join(","))
399        })
400        .collect()
401}
402
403/// `json`: an array of objects, one per row.
404fn json(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
405    let _ = layout;
406    if rows.is_empty() {
407        // An empty result prints nothing, not an empty array: the reference
408        // writes the brackets around rows and there are none.
409        return Vec::new();
410    }
411    let mut out = Vec::with_capacity(rows.len());
412    for (index, row) in rows.iter().enumerate() {
413        let members: Vec<String> = row
414            .iter()
415            .enumerate()
416            .map(|(position, value)| {
417                let name = columns.get(position).cloned().unwrap_or_default();
418                format!(
419                    "\"{}\":{}",
420                    inillucent_base::json::escape(&name),
421                    json_value(value)
422                )
423            })
424            .collect();
425        let open = if index == 0 { "[" } else { "" };
426        let close = if index + 1 == rows.len() { "]" } else { "," };
427        out.push(format!("{open}{{{}}}{close}", members.join(",")));
428    }
429    out
430}
431
432/// Returns a value as JSON.
433fn json_value(value: &Value<'static>) -> String {
434    match value {
435        Value::Null => "null".to_string(),
436        Value::Integer(number) => number.to_string(),
437        Value::Real(_) => number_text(value),
438        Value::Text(text) => format!(
439            "\"{}\"",
440            inillucent_base::json::escape(&String::from_utf8_lossy(text.raw()))
441        ),
442        // A blob's bytes, each as its own escape: the reference writes
443        // `"\u00ab"` rather than the hex a reader might expect, and a consumer
444        // of the JSON is reading whichever one it was given.
445        Value::Blob(blob) => {
446            let escaped: String = blob
447                .raw()
448                .iter()
449                .map(|byte| format!("\\u{byte:04x}"))
450                .collect();
451            format!("\"{escaped}\"")
452        }
453    }
454}
455
456/// Returns each column's width: the widest of its values and its name.
457fn widths(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<usize> {
458    let mut widths: Vec<usize> = columns.iter().map(|name| name.chars().count()).collect();
459    for row in rows {
460        for (index, value) in row.iter().enumerate() {
461            let width = plain(layout, value).chars().count();
462            match widths.get_mut(index) {
463                Some(existing) => *existing = (*existing).max(width),
464                None => widths.push(width),
465            }
466        }
467    }
468    for (index, fixed) in layout.widths.iter().enumerate() {
469        if *fixed == 0 {
470            continue;
471        }
472        if let Some(existing) = widths.get_mut(index) {
473            *existing = *fixed;
474        }
475    }
476    widths
477}
478
479/// `column`: fixed-width columns with two spaces between them.
480fn aligned(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
481    let widths = widths(layout, columns, rows);
482    let mut out = Vec::with_capacity(rows.len() + 2);
483    if layout.headers {
484        let centred: Vec<String> = columns
485            .iter()
486            .enumerate()
487            .map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
488            .collect();
489        out.push(centred.join("  ").trim_end().to_string());
490        out.push(
491            widths
492                .iter()
493                .map(|width| "-".repeat(*width))
494                .collect::<Vec<String>>()
495                .join("  "),
496        );
497    }
498    for row in rows {
499        let cells: Vec<String> = row
500            .iter()
501            .enumerate()
502            .map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
503            .collect();
504        out.push(pad_row(&cells, &widths));
505    }
506    out
507}
508
509/// Returns one cell padded to its width, right-aligned when it is a number.
510///
511/// A number is right-aligned and everything else is not, which is what makes a
512/// column of amounts line up on its digits.
513fn align(layout: &Layout, value: &Value<'static>, width: usize) -> String {
514    let text = plain(layout, value);
515    if matches!(value, Value::Integer(_) | Value::Real(_)) {
516        return format!("{text:>width$}");
517    }
518    text
519}
520
521/// Centres text in a field, leaning left when it cannot be even.
522///
523/// Every tabular mode centres its headers over left-aligned values, which is
524/// the reference's choice and looks better than it sounds: a narrow numeric
525/// column under a long name is unreadable left-aligned.
526fn centre(text: &str, width: usize) -> String {
527    let length = text.chars().count();
528    if length >= width {
529        return text.to_string();
530    }
531    let left = (width - length) / 2;
532    let right = width - length - left;
533    format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
534}
535
536/// Pads a row of cells to the given widths, trimming the trailing run.
537fn pad_row(cells: &[String], widths: &[usize]) -> String {
538    let padded: Vec<String> = cells
539        .iter()
540        .enumerate()
541        .map(|(index, cell)| {
542            let width = widths.get(index).copied().unwrap_or(0);
543            format!("{cell:<width$}")
544        })
545        .collect();
546    padded.join("  ").trim_end().to_string()
547}
548
549/// The characters one drawn table is made of.
550struct Frame {
551    left: &'static str,
552    middle: &'static str,
553    right: &'static str,
554    horizontal: &'static str,
555    vertical: &'static str,
556}
557
558/// `markdown`, `table` and `box`: a header, a rule, and the rows.
559fn drawn(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
560    let widths = widths(layout, columns, rows);
561    let frame = match layout.mode {
562        Mode::Markdown => Frame {
563            left: "|",
564            middle: "|",
565            right: "|",
566            horizontal: "-",
567            vertical: "|",
568        },
569        // The reference draws its box with rounded corners and a double
570        // rule under the header. That is copied exactly rather than
571        // approximated: the whole value of the mode is that a person
572        // recognises the output.
573        Mode::Box => Frame {
574            left: "\u{256d}",
575            middle: "\u{252c}",
576            right: "\u{256e}",
577            horizontal: "\u{2500}",
578            vertical: "\u{2502}",
579        },
580        _ => Frame {
581            left: "+",
582            middle: "+",
583            right: "+",
584            horizontal: "-",
585            vertical: "|",
586        },
587    };
588    let mut out = Vec::with_capacity(rows.len() + 4);
589    let rule = rule_line(&frame, &widths);
590    if layout.mode != Mode::Markdown {
591        out.push(rule.clone());
592    }
593    let centred: Vec<String> = columns
594        .iter()
595        .enumerate()
596        .map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
597        .collect();
598    out.push(drawn_row(&frame, &centred, &widths, layout, true));
599    out.push(match layout.mode {
600        Mode::Markdown => markdown_rule(&widths),
601        Mode::Box => rule_line(
602            &Frame {
603                left: "\u{255e}",
604                middle: "\u{256a}",
605                right: "\u{2561}",
606                horizontal: "\u{2550}",
607                ..frame
608            },
609            &widths,
610        ),
611        _ => rule.clone(),
612    });
613    for row in rows {
614        let cells: Vec<String> = row
615            .iter()
616            .enumerate()
617            .map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
618            .collect();
619        out.push(drawn_row(&frame, &cells, &widths, layout, false));
620    }
621    if layout.mode == Mode::Box {
622        out.push(rule_line(
623            &Frame {
624                left: "\u{2570}",
625                middle: "\u{2534}",
626                right: "\u{256f}",
627                ..frame
628            },
629            &widths,
630        ));
631    } else if layout.mode != Mode::Markdown {
632        out.push(rule);
633    }
634    out
635}
636
637/// Returns one horizontal rule.
638fn rule_line(frame: &Frame, widths: &[usize]) -> String {
639    let parts: Vec<String> = widths
640        .iter()
641        .map(|width| frame.horizontal.repeat(width + 2))
642        .collect();
643    format!("{}{}{}", frame.left, parts.join(frame.middle), frame.right)
644}
645
646/// Returns the `|---|---|` line Markdown wants under its header.
647fn markdown_rule(widths: &[usize]) -> String {
648    let parts: Vec<String> = widths.iter().map(|width| "-".repeat(width + 2)).collect();
649    format!("|{}|", parts.join("|"))
650}
651
652/// Returns one drawn row, padded to the widths.
653fn drawn_row(
654    frame: &Frame,
655    cells: &[String],
656    widths: &[usize],
657    layout: &Layout,
658    header: bool,
659) -> String {
660    let _ = (layout, header);
661    let padded: Vec<String> = widths
662        .iter()
663        .enumerate()
664        .map(|(index, width)| {
665            let cell = cells.get(index).cloned().unwrap_or_default();
666            format!(" {cell:<width$} ")
667        })
668        .collect();
669    format!(
670        "{}{}{}",
671        frame.vertical,
672        padded.join(frame.vertical),
673        frame.vertical
674    )
675}
676
677/// `html`: a table body, which is what a caller pastes into a page.
678///
679/// One cell per line and no closing cell tags, which is what the reference
680/// emits. It is valid HTML - a `<TD>` closes the one before it - and it is what
681/// a diff against the reference has to produce.
682fn html(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
683    let mut out = Vec::new();
684    if layout.headers {
685        out.push("<TR>".to_string());
686        for name in columns {
687            out.push(format!("<TH>{}", html_escape(name)));
688        }
689        out.push("</TR>".to_string());
690    }
691    for row in rows {
692        out.push("<TR>".to_string());
693        for value in row {
694            out.push(format!("<TD>{}", html_escape(&plain(layout, value))));
695        }
696        out.push("</TR>".to_string());
697    }
698    out
699}
700
701/// Escapes the four characters that mean something in HTML.
702fn html_escape(text: &str) -> String {
703    text.replace('&', "&amp;")
704        .replace('<', "&lt;")
705        .replace('>', "&gt;")
706        .replace('"', "&quot;")
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    /// Builds a one-row result for the tests below.
714    fn sample() -> (Vec<String>, Vec<Vec<Value<'static>>>) {
715        let columns = vec!["a".to_string(), "b".to_string()];
716        let rows = vec![vec![
717            Value::Integer(1),
718            Value::owned_text(b"two").expect("owns"),
719        ]];
720        (columns, rows)
721    }
722
723    /// The default mode is SQLite's: pipes, no headers.
724    #[test]
725    fn the_default_is_a_pipe_separated_line() {
726        let (columns, rows) = sample();
727        let out = render(&Layout::default(), &columns, &rows);
728        assert_eq!(out, vec!["1|two"]);
729    }
730
731    /// Headers are the column names, in the same layout as the rows.
732    #[test]
733    fn headers_use_the_same_layout() {
734        let (columns, rows) = sample();
735        let layout = Layout {
736            headers: true,
737            ..Layout::default()
738        };
739        let out = render(&layout, &columns, &rows);
740        assert_eq!(out, vec!["a|b", "1|two"]);
741    }
742
743    /// A CSV record ends with one carriage return unless it is going to
744    /// standard output on Windows, where it ends with two.
745    ///
746    /// The two counts are the reference's, measured at 3.53.4: `sqlite3 -csv`
747    /// emits CR CR LF on standard output on this platform, because its row
748    /// separator is CR LF and the C stream translates the LF again; the same
749    /// rows through `.once out.csv` hold CR LF, because the file is not such a
750    /// stream. Both destinations are checked here so that a change to either
751    /// one has to be a deliberate change to this case.
752    #[test]
753    fn a_csv_record_ends_the_way_its_destination_expects() {
754        let (columns, rows) = sample();
755        let to_a_file = Layout {
756            mode: Mode::Csv,
757            separator: ",".to_string(),
758            row_separator: "\r\n".to_string(),
759            to_stdout: false,
760            ..Layout::default()
761        };
762        assert_eq!(render(&to_a_file, &columns, &rows), vec!["1,two\r"]);
763        let to_the_terminal = Layout {
764            to_stdout: true,
765            ..to_a_file
766        };
767        let expected = if cfg!(windows) {
768            "1,two\r\r"
769        } else {
770            "1,two\r"
771        };
772        assert_eq!(render(&to_the_terminal, &columns, &rows), vec![expected]);
773    }
774
775    /// A CSV field is quoted only when it has to be.
776    #[test]
777    fn csv_quotes_only_what_it_must() {
778        assert_eq!(csv_field("plain"), "plain");
779        assert_eq!(csv_field("a,b"), "\"a,b\"");
780        assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
781    }
782
783    /// **An empty CSV field that is not NULL is quoted, and a NULL is not.**
784    ///
785    /// A blob is printed as a C string and stops at its first NUL, so a blob
786    /// beginning with one rendered as nothing at all - and so does a NULL
787    /// under the default `nullvalue`, which is the empty string. The reference
788    /// writes two quotes for the first and nothing for the second, so an
789    /// exported blob was indistinguishable from a NULL on the way back in
790    /// (task-2066 section 4.2, item 27).
791    #[test]
792    fn csv_tells_an_empty_value_from_a_null() {
793        let columns = vec!["x".to_string()];
794        let rows = vec![
795            vec![Value::Null],
796            vec![Value::owned_blob(&[0, 1, 2]).expect("owns")],
797            vec![Value::owned_text(b"").expect("owns")],
798            vec![Value::owned_text(b"kept").expect("owns")],
799        ];
800        let layout = Layout {
801            mode: Mode::Csv,
802            separator: ",".to_string(),
803            ..Layout::default()
804        };
805        assert_eq!(
806            render(&layout, &columns, &rows),
807            vec!["", "\"\"", "\"\"", "kept"]
808        );
809    }
810
811    /// A control character is escaped and a NUL ends the value.
812    #[test]
813    fn control_characters_are_escaped() {
814        assert_eq!(printable(b"ab"), "ab");
815        assert_eq!(printable(&[0x01, 0x02]), "^A^B");
816        assert_eq!(printable(&[0x09, b't']), "^It");
817        assert_eq!(printable(&[0x7f]), "^?");
818        assert_eq!(printable(b"a\nb"), "a\nb");
819        assert_eq!(printable(&[b'a', 0, b'b']), "a");
820    }
821
822    /// Quote mode writes values as SQL literals, blobs included.
823    #[test]
824    fn quote_mode_writes_literals() {
825        let columns = vec!["x".to_string()];
826        let rows = vec![
827            vec![Value::Null],
828            vec![Value::owned_blob(&[1, 255]).expect("owns")],
829            vec![Value::owned_text(b"it's").expect("owns")],
830        ];
831        let layout = Layout {
832            mode: Mode::Quote,
833            ..Layout::default()
834        };
835        let out = render(&layout, &columns, &rows);
836        assert_eq!(out, vec!["NULL", "x'01ff'", "'it''s'"]);
837    }
838
839    /// Every mode name round-trips.
840    #[test]
841    fn every_mode_name_round_trips() {
842        for mode in [
843            Mode::List,
844            Mode::Column,
845            Mode::Line,
846            Mode::Csv,
847            Mode::Tabs,
848            Mode::Quote,
849            Mode::Insert,
850            Mode::Json,
851            Mode::Markdown,
852            Mode::Table,
853            Mode::Box,
854            Mode::Html,
855        ] {
856            assert_eq!(Mode::from_name(mode.name()), Some(mode), "{}", mode.name());
857        }
858        assert_eq!(Mode::from_name("nonsense"), None);
859    }
860
861    /// A drawn table has a rule above and below its rows.
862    #[test]
863    fn a_table_is_drawn_with_rules() {
864        let (columns, rows) = sample();
865        let layout = Layout {
866            mode: Mode::Table,
867            headers: true,
868            ..Layout::default()
869        };
870        let out = render(&layout, &columns, &rows);
871        assert_eq!(out.len(), 5, "{out:#?}");
872        assert!(out.first().is_some_and(|line| line.starts_with('+')));
873        assert!(out.last().is_some_and(|line| line.starts_with('+')));
874    }
875
876    /// JSON escapes what JSON has to escape.
877    #[test]
878    fn the_shell_escapes_through_the_base_crate() {
879        assert_eq!(inillucent_base::json::escape("a\"b"), "a\\\"b");
880        assert_eq!(inillucent_base::json::escape("a\nb"), "a\\nb");
881        assert_eq!(inillucent_base::json::escape("a\u{1}b"), "a\\u0001b");
882        // The two the private copy spelled as `\u0008` and `\u000c`, and the
883        // one it left unescaped.
884        assert_eq!(inillucent_base::json::escape("a\u{8}b"), "a\\bb");
885        assert_eq!(inillucent_base::json::escape("a\u{c}b"), "a\\fb");
886        assert_eq!(inillucent_base::json::escape("a\u{7f}b"), "a\\u007fb");
887    }
888}