Skip to main content

gpui_kit_grid/
export.rs

1//! Copying and exporting rows: how one cell becomes text ([`CellExporter`],
2//! [`CellExport`]), how a table of cells becomes clipboard text
3//! ([`Exporter`], [`TsvExporter`]) and how it becomes the bytes of a file
4//! ([`ExportFormat`], [`CsvFormat`]). A table holds the application's rows
5//! and the summary rows of a grouped view: group captions, group footers and
6//! the totals ([`ExportLine`]).
7//!
8//! This module has no GPUI types, so an export runs in the background on one
9//! snapshot. Every seam is `Send + Sync`, and the per-cell seam is dispatched
10//! statically for the built-in exporters.
11//!
12//! The grid writes CSV and TSV itself and depends on no spreadsheet library:
13//! an application writes xlsx with its own, from the typed values and the
14//! metadata of the table ([`ExportFormat`], "Spreadsheets").
15
16use std::{
17    cell::RefCell,
18    convert::Infallible,
19    fmt,
20    fmt::Write as _,
21    io,
22    sync::{
23        Arc,
24        atomic::{AtomicBool, AtomicUsize, Ordering},
25    },
26};
27
28use crate::{
29    engine::{AggregateRow, Branch, GroupInfo},
30    model::{
31        CellValue, ColumnSet, GridColumns, GridRow, NumberFormat, OwnedValue, display_text_in,
32        number_fraction_digits, push_fixed, push_timestamp,
33    },
34    state::{ColumnAlign, ColumnZone},
35    store::PinnedEdge,
36};
37
38/// Which rows an export takes. A grid copies any of them with
39/// [`LiveGridState::copy_scope`].
40///
41/// [`LiveGridState::copy_scope`]: crate::LiveGridState::copy_scope
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum ExportScope {
45    /// Every shown line in the shown order: group captions, the rows of
46    /// expanded groups and tree rows, group footers; the pinned bands and
47    /// the totals row around them.
48    Visible,
49    /// The command scope: the selection within the shown order, or the rows
50    /// of the group caption the command is over.
51    Selection,
52    /// As `Visible`, with every group and tree row expanded (LiveGrid's
53    /// `forceExpand`).
54    Expanded,
55}
56
57/// What an exported row is: a row of the application, or a summary row of a
58/// grouped view.
59///
60/// The built-in exporters write a summary row as any other row, so pasted
61/// columns stay uniform: a caption holds its group's key value under the
62/// group's column and its aggregates under theirs, a footer and the totals
63/// hold aggregates. To write them another way, an [`Exporter`] tells rows
64/// apart by [`ExportRow::line`] and a [`CellExporter`] tells cells apart by
65/// [`ExportCell::line`]; [`LineFilter`] leaves lines out. For example, the
66/// row count after a group's key value, `AAPL (12)`:
67///
68/// ```
69/// use std::fmt::Write as _;
70///
71/// use gpui_kit_grid::{CellExporter, ExportCell, ExportLine, GridRow, RawCells};
72///
73/// struct CountedCaptions;
74///
75/// impl<R: GridRow> CellExporter<R> for CountedCaptions {
76///     fn write_cell(&self, cell: &ExportCell<'_, R>, out: &mut String) {
77///         RawCells::new().write_cell(cell, out);
78///         if cell.line() == ExportLine::Caption
79///             && !cell.is_aggregate()
80///             && let Some(group) = cell.group()
81///         {
82///             let _ = write!(out, " ({})", group.row_count());
83///         }
84///     }
85/// }
86/// ```
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
88#[non_exhaustive]
89pub enum ExportLine {
90    /// A row of the application.
91    Data,
92    /// A group caption: the group's key value and its aggregates.
93    Caption,
94    /// A group footer: the group's aggregates.
95    Footer,
96    /// The totals row: the aggregates of the whole view.
97    Totals,
98}
99
100impl ExportLine {
101    /// Every line, in the order [`ExportLines`] lists them.
102    const ALL: [Self; 4] = [Self::Data, Self::Caption, Self::Footer, Self::Totals];
103
104    /// The line's bit in [`ExportLines`].
105    const fn bit(self) -> u8 {
106        1 << self as u8
107    }
108}
109
110/// A set of [`ExportLine`]s: the lines a [`LineFilter`] writes.
111#[derive(Clone, Copy, PartialEq, Eq, Hash)]
112pub struct ExportLines(u8);
113
114impl ExportLines {
115    /// No line.
116    pub const EMPTY: Self = Self(0);
117
118    /// Every line: what the built-in exporters write.
119    pub const ALL: Self = Self((1 << ExportLine::ALL.len()) - 1);
120
121    /// The set with `line` added.
122    pub const fn with(self, line: ExportLine) -> Self {
123        Self(self.0 | line.bit())
124    }
125
126    /// The set with `line` left out.
127    pub const fn without(self, line: ExportLine) -> Self {
128        Self(self.0 & !line.bit())
129    }
130
131    /// Returns true if the set holds `line`.
132    pub const fn contains(self, line: ExportLine) -> bool {
133        self.0 & line.bit() != 0
134    }
135}
136
137impl Default for ExportLines {
138    fn default() -> Self {
139        Self::ALL
140    }
141}
142
143impl fmt::Debug for ExportLines {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.debug_set()
146            .entries(ExportLine::ALL.iter().filter(|line| self.contains(**line)))
147            .finish()
148    }
149}
150
151/// Writes the text of one exported cell.
152///
153/// Called for every exported cell, in the background: it must not lock, and
154/// it should not allocate. Implementations append to `out` and never change
155/// what is already there; quoting is the [`Exporter`]'s job.
156pub trait CellExporter<R: GridRow>: Send + Sync + 'static {
157    /// Appends the text of `cell` to `out`.
158    fn write_cell(&self, cell: &ExportCell<'_, R>, out: &mut String);
159}
160
161/// Raw values that spreadsheets parse back: numbers without digit grouping
162/// with the decimals the grid shows, `Bool` as `TRUE` or `FALSE`. A
163/// summary cell is written by its value as any other: an aggregate `Float`
164/// as `1234.50`, a `Bool` group key as `TRUE`.
165///
166/// | Value | Text |
167/// |---|---|
168/// | `Int` | `-1234` |
169/// | `Float` | With the decimals of its column's [`NumberFormat`] (`-0.49`, `1234.50` with two), never with an exponent; with [`NumberPrecision::Full`], the shortest text that parses back to the same number (`-0.4899999999999949`, `1234.5`). `NaN` and infinities: empty |
170/// | `Decimal` | Its digits with its scale (`12.30`), never with an exponent (feature `decimal`) |
171/// | `Bool` | `TRUE`, `FALSE` |
172/// | `Text` | As is |
173/// | `Timestamp` | In UTC as `2026-11-04 12:34:56`, the text of the cell: Excel and LibreOffice read it as a date and time |
174/// | `Icon`, `Image` | Its text |
175/// | `Custom` | [`GridValue::export_value`](crate::GridValue::export_value) as above, or the text of the cell |
176/// | `Empty` | Empty |
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub struct RawCells {
179    decimal_separator: char,
180    precision: NumberPrecision,
181}
182
183/// How many decimals [`RawCells`] writes for a `Float`.
184#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
185pub enum NumberPrecision {
186    /// The decimals the grid shows (the column's [`NumberFormat`]): a copy
187    /// pastes what the user saw, `-0.49` and not `-0.4899999999999949`. The
188    /// default.
189    #[default]
190    Shown,
191    /// Every digit of the value: the shortest text that parses back to the
192    /// same number, for a file another program computes with.
193    Full,
194}
195
196impl RawCells {
197    /// Raw values with `.` as the decimal separator and the decimals the
198    /// grid shows.
199    pub const fn new() -> Self {
200        Self {
201            decimal_separator: '.',
202            precision: NumberPrecision::Shown,
203        }
204    }
205
206    /// Sets how many decimals a `Float` gets (default
207    /// [`NumberPrecision::Shown`]).
208    pub const fn with_precision(mut self, precision: NumberPrecision) -> Self {
209        self.precision = precision;
210        self
211    }
212
213    /// How many decimals a `Float` gets.
214    pub const fn precision(&self) -> NumberPrecision {
215        self.precision
216    }
217
218    /// Writes `separator` between the whole and fractional digits of a
219    /// `Float` or a `Decimal`, for example `,` for a spreadsheet in a Russian
220    /// locale.
221    pub const fn with_decimal_separator(mut self, separator: char) -> Self {
222        self.decimal_separator = separator;
223        self
224    }
225
226    /// The decimal separator of a `Float` or a `Decimal`.
227    pub const fn decimal_separator(&self) -> char {
228        self.decimal_separator
229    }
230
231    fn write_float(self, value: f64, format: NumberFormat, out: &mut String) {
232        if !value.is_finite() {
233            return;
234        }
235        let start = out.len();
236        match self.precision {
237            NumberPrecision::Shown => push_fixed(value, format.decimals(), out),
238            // `Display` for `f64` writes the shortest text that parses back
239            // to the same value, with at most one `.` and never an exponent.
240            NumberPrecision::Full => {
241                let _ = write!(out, "{value}");
242            }
243        }
244        self.replace_point(start, out);
245    }
246
247    /// Writes a decimal with its scale (`12.30`); `Display` for `Decimal`
248    /// never writes an exponent.
249    #[cfg(feature = "decimal")]
250    fn write_decimal(self, value: rust_decimal::Decimal, out: &mut String) {
251        let start = out.len();
252        let _ = write!(out, "{value}");
253        self.replace_point(start, out);
254    }
255
256    /// Replaces the `.` written after `start` with the decimal separator.
257    fn replace_point(self, start: usize, out: &mut String) {
258        if self.decimal_separator != '.'
259            && let Some(dot) = out[start..].find('.')
260        {
261            let dot = start + dot;
262            let mut separator = [0; 4];
263            out.replace_range(
264                dot..dot + 1,
265                self.decimal_separator.encode_utf8(&mut separator),
266            );
267        }
268    }
269}
270
271impl Default for RawCells {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277impl<R: GridRow> CellExporter<R> for RawCells {
278    #[inline]
279    fn write_cell(&self, cell: &ExportCell<'_, R>, out: &mut String) {
280        match cell.value() {
281            CellValue::Custom(value) => match value.export_value() {
282                // A custom value again is written by its text, as `write_value` does.
283                Some(export) => self.write_value(export, cell.number_format(), out),
284                None => value.write_text(out),
285            },
286            value => self.write_value(value, cell.number_format(), out),
287        }
288    }
289}
290
291impl RawCells {
292    /// Writes a value other than `Custom`, which `write_cell` resolves.
293    #[inline]
294    fn write_value(self, value: CellValue<'_>, format: NumberFormat, out: &mut String) {
295        match value {
296            CellValue::Empty => {}
297            CellValue::Bool(value) => out.push_str(if value { "TRUE" } else { "FALSE" }),
298            CellValue::Int(value) => {
299                let _ = write!(out, "{value}");
300            }
301            CellValue::Float(value) => self.write_float(value, format, out),
302            #[cfg(feature = "decimal")]
303            CellValue::Decimal(value) => self.write_decimal(value, out),
304            CellValue::Text(text) => out.push_str(text),
305            CellValue::Timestamp(value) => push_timestamp(value, out),
306            CellValue::Icon(icon) => out.push_str(icon.text()),
307            CellValue::Image(image) => out.push_str(image.text()),
308            // A link is copied as its text, what the cell shows; its address
309            // is in `ExportCell::value` for an application's format.
310            CellValue::Link(link) => out.push_str(link.text()),
311            CellValue::Custom(value) => value.write_text(out),
312        }
313    }
314}
315
316/// The text painted in the cell: the same function the body paints with
317/// (`Float` with its column's decimals, `Bool(true)` as "✓"). A summary
318/// cell is written by its value as any other: an aggregate `Float` as
319/// `1234.50`.
320#[derive(Clone, Copy, Debug, Default)]
321pub struct DisplayedCells;
322
323thread_local! {
324    /// The number buffer of `DisplayedCells`, reused across cells so that an
325    /// export does not allocate per cell.
326    static DISPLAY_BUFFER: RefCell<String> = const { RefCell::new(String::new()) };
327}
328
329impl<R: GridRow> CellExporter<R> for DisplayedCells {
330    #[inline]
331    fn write_cell(&self, cell: &ExportCell<'_, R>, out: &mut String) {
332        DISPLAY_BUFFER.with_borrow_mut(|buffer| {
333            out.push_str(display_text_in(cell.value(), cell.number_format(), buffer))
334        });
335    }
336}
337
338/// The cell exporter of a grid: the built-in exporters dispatched
339/// statically, plus a custom one.
340///
341/// The default is `Raw(RawCells::new())`. A custom exporter is called through
342/// its `Arc` without allocating per cell. For example, raw values for a
343/// spreadsheet in a Russian locale:
344///
345/// ```
346/// use std::sync::Arc;
347///
348/// use gpui_kit_grid::{CellExport, CellExporter, CellValue, ExportCell, GridRow, RawCells};
349///
350/// /// Raw values with a decimal comma and `ИСТИНА` or `ЛОЖЬ`.
351/// struct RussianSpreadsheetCells(RawCells);
352///
353/// impl<R: GridRow> CellExporter<R> for RussianSpreadsheetCells {
354///     fn write_cell(&self, cell: &ExportCell<'_, R>, out: &mut String) {
355///         match cell.value() {
356///             CellValue::Bool(value) => out.push_str(if value { "ИСТИНА" } else { "ЛОЖЬ" }),
357///             _ => self.0.write_cell(cell, out),
358///         }
359///     }
360/// }
361///
362/// fn russian_spreadsheet_cells<R: GridRow>() -> CellExport<R> {
363///     CellExport::Custom(Arc::new(RussianSpreadsheetCells(
364///         RawCells::new().with_decimal_separator(','),
365///     )))
366/// }
367/// ```
368#[non_exhaustive]
369pub enum CellExport<R: GridRow> {
370    /// Raw values that spreadsheets parse back.
371    Raw(RawCells),
372    /// The text painted in the cell.
373    Displayed(DisplayedCells),
374    /// An application exporter.
375    Custom(Arc<dyn CellExporter<R>>),
376}
377
378impl<R: GridRow> Default for CellExport<R> {
379    fn default() -> Self {
380        Self::Raw(RawCells::new())
381    }
382}
383
384impl<R: GridRow> Clone for CellExport<R> {
385    fn clone(&self) -> Self {
386        match self {
387            Self::Raw(raw) => Self::Raw(*raw),
388            Self::Displayed(displayed) => Self::Displayed(*displayed),
389            Self::Custom(custom) => Self::Custom(custom.clone()),
390        }
391    }
392}
393
394impl<R: GridRow> CellExporter<R> for CellExport<R> {
395    #[inline]
396    fn write_cell(&self, cell: &ExportCell<'_, R>, out: &mut String) {
397        match self {
398            Self::Raw(raw) => raw.write_cell(cell, out),
399            Self::Displayed(displayed) => displayed.write_cell(cell, out),
400            Self::Custom(custom) => custom.write_cell(cell, out),
401        }
402    }
403}
404
405/// Turns an export table into text for the clipboard, or for a file with
406/// [`TextFile`].
407///
408/// Called once per export, in the background.
409pub trait Exporter<R: GridRow>: Send + Sync + 'static {
410    /// Appends the text of `table` to `out`, writing each cell with `cells`.
411    fn export(&self, table: &ExportTable<'_, R>, cells: &CellExport<R>, out: &mut String);
412}
413
414/// Turns an export table into the bytes of a file or a stream: CSV, TSV, or
415/// an application format such as xlsx written with the application's own
416/// library.
417///
418/// Called once per export, in the background (a `TaskRunner` job). It must
419/// call [`ExportControl::advance`] as rows are written, at least once per
420/// 1024 rows, and stop with the error it returns: that is how progress shows
421/// and how a cancelled export stops.
422///
423/// # Spreadsheets
424///
425/// The grid depends on no spreadsheet library. An application writes xlsx,
426/// or any other format, with its own: in a format of its own, or in a
427/// closure the grid hands the table to
428/// ([`LiveGridState::export_with`](crate::LiveGridState::export_with)). The
429/// table carries what such a file needs:
430///
431/// | The table tells | A spreadsheet makes of it |
432/// |---|---|
433/// | [`ExportCell::value`]: `Int`, `Float`, `Decimal`, `Bool`, `Text`, `Timestamp` | A number, a boolean, a string or a date and time cell |
434/// | [`ExportCell::number_hint`] | The number format: the digits after the point, as on screen |
435/// | [`ExportColumn::title`] | The header row |
436/// | [`ExportColumn::bands`], [`ExportTable::band_depth`] | Rows of merged header cells above the titles |
437/// | [`ExportColumn::width`] | The column width |
438/// | [`ExportColumn::align`] | The horizontal alignment |
439/// | [`ExportTable::has_header`], [`ExportTable::frozen_columns`], [`ExportRow::band`] | The frozen panes: the header rows, the leading columns and the top band |
440/// | [`ExportRow::depth`], [`ExportRow::is_hidden`], [`ExportRow::branch`], [`GroupInfo::is_expanded`] | The outline level of a row, and rows hidden under a collapsed level |
441/// | [`ExportRow::line`], [`ExportCell::is_aggregate`] | Summary rows set apart, or formulas in place of their values |
442/// | [`ExportCell::is_pending`], [`ExportCell::row_value`] | An edit not yet confirmed: its value, or the stored one |
443///
444/// A spreadsheet keeps 15 significant digits: an `Int` beyond 2^53 loses its
445/// last digits unless the format writes it as text.
446///
447/// The same walk over the table, without a library: a report with one line
448/// per row, indented by its depth, numbers with the digits the screen shows,
449/// captions and totals marked.
450///
451/// ```
452/// use std::io::Write;
453///
454/// use gpui_kit_grid::{
455///     CellExport, CellValue, ExportControl, ExportError, ExportFormat, ExportLine, ExportTable,
456///     GridRow,
457/// };
458///
459/// struct OutlineReport;
460///
461/// impl<R: GridRow> ExportFormat<R> for OutlineReport {
462///     fn extension(&self) -> &str {
463///         "txt"
464///     }
465///
466///     fn export(
467///         &self,
468///         table: &ExportTable<'_, R>,
469///         _cells: &CellExport<R>,
470///         out: &mut dyn Write,
471///         control: &ExportControl,
472///     ) -> Result<(), ExportError> {
473///         for (ix, row) in table.rows().iter().enumerate() {
474///             let mark = match row.line() {
475///                 ExportLine::Caption => "▸ ",
476///                 ExportLine::Totals => "Σ ",
477///                 _ => "",
478///             };
479///             write!(out, "{:indent$}{mark}", "", indent = usize::from(row.depth()) * 2)?;
480///             for column in table.columns() {
481///                 let Some(cell) = row.cell(column.column()) else {
482///                     continue;
483///                 };
484///                 let digits = cell
485///                     .number_hint()
486///                     .map_or(0, |hint| hint.fraction_digits() as usize);
487///                 match cell.value() {
488///                     CellValue::Float(value) => write!(out, "{value:.digits$} ")?,
489///                     CellValue::Int(value) => write!(out, "{value} ")?,
490///                     CellValue::Text(text) => write!(out, "{text} ")?,
491///                     _ => {}
492///                 }
493///             }
494///             writeln!(out)?;
495///             if ix % 1024 == 1023 {
496///                 control.advance(1024)?;
497///             }
498///         }
499///         control.advance(table.rows().len() % 1024)
500///     }
501/// }
502/// ```
503pub trait ExportFormat<R: GridRow>: Send + Sync + 'static {
504    /// The file name extension, without a dot: "csv", "tsv".
505    fn extension(&self) -> &str;
506
507    /// Writes `table` to `out`; text cells with `cells`, as a text format
508    /// does (a spreadsheet format reads [`ExportCell::value`] instead).
509    fn export(
510        &self,
511        table: &ExportTable<'_, R>,
512        cells: &CellExport<R>,
513        out: &mut dyn io::Write,
514        control: &ExportControl,
515    ) -> Result<(), ExportError>;
516}
517
518/// The progress and cancellation of one export, shared between the job that
519/// writes it and the application that waits for it. Clones share them.
520#[derive(Clone, Debug)]
521pub struct ExportControl {
522    shared: Arc<ControlState>,
523}
524
525#[derive(Debug)]
526struct ControlState {
527    rows_total: AtomicUsize,
528    rows_written: AtomicUsize,
529    cancelled: AtomicBool,
530}
531
532impl ExportControl {
533    /// A control for an export of `rows_total` rows; the grid makes one per
534    /// export, a test of a format makes its own.
535    pub fn new(rows_total: usize) -> Self {
536        Self {
537            shared: Arc::new(ControlState {
538                rows_total: AtomicUsize::new(rows_total),
539                rows_written: Default::default(),
540                cancelled: Default::default(),
541            }),
542        }
543    }
544
545    /// The rows of the table, summary and band rows included, the header
546    /// line not. A grid's export counts them ahead when it starts and
547    /// exactly once its job has built the table.
548    pub fn rows_total(&self) -> usize {
549        self.shared.rows_total.load(Ordering::Relaxed)
550    }
551
552    /// Sets the rows of the table, once the job has built it.
553    pub(crate) fn set_rows_total(&self, rows_total: usize) {
554        self.shared.rows_total.store(rows_total, Ordering::Relaxed);
555    }
556
557    /// The rows written so far, as the format reported them.
558    pub fn rows_written(&self) -> usize {
559        self.shared.rows_written.load(Ordering::Relaxed)
560    }
561
562    /// Records `rows` more rows written. Returns
563    /// `Err(ExportError::Cancelled)` once the export is cancelled: the
564    /// format stops and returns it. `advance(0)` only checks.
565    pub fn advance(&self, rows: usize) -> Result<(), ExportError> {
566        self.shared.rows_written.fetch_add(rows, Ordering::Relaxed);
567        if self.is_cancelled() {
568            return Err(ExportError::Cancelled);
569        }
570        Ok(())
571    }
572
573    /// Returns true if the export is cancelled.
574    pub fn is_cancelled(&self) -> bool {
575        self.shared.cancelled.load(Ordering::Relaxed)
576    }
577
578    /// Cancels the export: the next [`advance`](Self::advance) fails.
579    pub fn cancel(&self) {
580        self.shared.cancelled.store(true, Ordering::Relaxed);
581    }
582}
583
584/// Why an export did not finish.
585///
586/// `Display` writes the reason; for `Io` and `Other` that is the text of the
587/// error inside. What to tell the user is the application's choice.
588#[derive(Clone, Debug)]
589#[non_exhaustive]
590pub enum ExportError {
591    /// No line, pinned row or totals row in the scope: nothing was written
592    /// and no file was created.
593    Nothing,
594    /// The export was cancelled, or the application stopped waiting for it.
595    Cancelled,
596    /// Writing failed; a file being written to a path was removed.
597    Io(Arc<io::Error>),
598    /// An application format failed, for example its spreadsheet library.
599    Other(Arc<dyn std::error::Error + Send + Sync>),
600}
601
602impl ExportError {
603    /// An error of an application format: an error value, or a message.
604    pub fn other(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
605        Self::Other(Arc::from(error.into()))
606    }
607}
608
609impl From<io::Error> for ExportError {
610    fn from(error: io::Error) -> Self {
611        Self::Io(Arc::new(error))
612    }
613}
614
615impl fmt::Display for ExportError {
616    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617        match self {
618            Self::Nothing => f.write_str("nothing to export"),
619            Self::Cancelled => f.write_str("the export was cancelled"),
620            Self::Io(error) => fmt::Display::fmt(error, f),
621            Self::Other(error) => fmt::Display::fmt(error, f),
622        }
623    }
624}
625
626impl std::error::Error for ExportError {
627    /// The source of the error inside, whose text `Display` already wrote.
628    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
629        match self {
630            Self::Io(error) => error.source(),
631            Self::Other(error) => error.source(),
632            Self::Nothing | Self::Cancelled => None,
633        }
634    }
635}
636
637/// Tab-separated values that Excel, Numbers and LibreOffice paste as cells.
638///
639/// Cells are separated by `\t` and lines by `\n`, with nothing after the last
640/// line. A field that holds `\t`, `\n`, `\r` or `"` is put in quotes, with
641/// inner quotes doubled, as Excel does. A cell the row does not export is an
642/// empty field.
643///
644/// A summary row is a line like any other ([`ExportLine`]): a caption's key
645/// value and the aggregates land under their columns, its other fields are
646/// empty. A row's depth adds no indent, so the key value stays data; wrap the
647/// exporter in a [`LineFilter`] to leave lines out.
648///
649/// As an [`ExportFormat`] ("tsv") it writes the same text as UTF-8 without a
650/// byte order mark, 1024 rows at a time. Both traits name their method
651/// `export`: with both in scope, call it through the trait, as
652/// `Exporter::export(&TsvExporter, &table, &cells, &mut text)`.
653#[derive(Clone, Copy, Debug, Default)]
654pub struct TsvExporter;
655
656/// The bytes a cell usually takes in delimited text, to reserve the output.
657const CELL_ESTIMATE: usize = 8;
658
659/// The rows a file format writes between two writes to its sink: one
660/// progress step, and a buffer of about `1024 × columns × 8` bytes.
661const FLUSH_ROWS: usize = 1024;
662
663/// How delimited text separates its fields and lines.
664#[derive(Clone, Copy)]
665struct TextDialect {
666    separator: char,
667    /// Written before every line but the first.
668    between_lines: &'static str,
669    /// Written after every line, the last included.
670    after_line: &'static str,
671}
672
673impl TextDialect {
674    /// Tab-separated, lines between lines: the clipboard text.
675    const TSV: Self = Self {
676        separator: '\t',
677        between_lines: "\n",
678        after_line: "",
679    };
680
681    /// RFC 4180: CRLF after every line.
682    const fn csv(separator: char) -> Self {
683        Self {
684            separator,
685            between_lines: "",
686            after_line: "\r\n",
687        }
688    }
689}
690
691impl<R: GridRow> Exporter<R> for TsvExporter {
692    fn export(&self, table: &ExportTable<'_, R>, cells: &CellExport<R>, out: &mut String) {
693        out.reserve(table.rows().len() * (table.columns().len() * CELL_ESTIMATE + 1));
694        // The whole text stays in `out`: nothing to flush, nothing to fail.
695        write_delimited(table, cells, TextDialect::TSV, out, |_, _| {
696            Ok::<_, Infallible>(())
697        })
698        .unwrap_or_else(|never| match never {});
699    }
700}
701
702impl<R: GridRow> ExportFormat<R> for TsvExporter {
703    fn extension(&self) -> &str {
704        "tsv"
705    }
706
707    fn export(
708        &self,
709        table: &ExportTable<'_, R>,
710        cells: &CellExport<R>,
711        out: &mut dyn io::Write,
712        control: &ExportControl,
713    ) -> Result<(), ExportError> {
714        write_delimited_file(table, cells, TextDialect::TSV, "", out, control)
715    }
716}
717
718/// Comma-separated values as RFC 4180 has them: fields separated by `,`,
719/// every line ended by CRLF, a field in quotes when it holds the separator,
720/// a quote, CR or LF, inner quotes doubled. Leading and trailing spaces are
721/// kept without quotes.
722///
723/// Cells are written by the export's [`CellExport`]: raw values by default
724/// (numbers with a `.`, `TRUE` and `FALSE`). A summary row is a line like
725/// any other and a cell the row does not export is an empty field, as in
726/// [`TsvExporter`]. The header line holds the column titles; header bands
727/// are not written.
728///
729/// The text is UTF-8 with a byte order mark by default, which Excel needs to
730/// read it as UTF-8 ([`CsvEncoding`]). A spreadsheet whose decimal separator
731/// is a comma reads `;` between fields:
732///
733/// ```
734/// use gpui_kit_grid::{CellExport, CsvEncoding, CsvFormat, GridRow, RawCells};
735///
736/// let format = CsvFormat::new().with_separator(';');
737/// assert_eq!(format.encoding(), CsvEncoding::Utf8WithBom);
738///
739/// // The cells to export with it: `1234,5`.
740/// fn decimal_comma<R: GridRow>() -> CellExport<R> {
741///     CellExport::Raw(RawCells::new().with_decimal_separator(','))
742/// }
743/// ```
744///
745/// No protection against formulas: a field that starts with `=`, `+`, `-` or
746/// `@` is written as it is (`-5` is a number). An application that exports
747/// text its users typed adds a `'` with its own [`CellExport::Custom`].
748#[derive(Clone, Copy, Debug, PartialEq, Eq)]
749pub struct CsvFormat {
750    separator: char,
751    encoding: CsvEncoding,
752}
753
754impl CsvFormat {
755    /// `,` between fields, CRLF after lines, UTF-8 with a byte order mark.
756    pub const fn new() -> Self {
757        Self {
758            separator: ',',
759            encoding: CsvEncoding::Utf8WithBom,
760        }
761    }
762
763    /// Separates fields with `separator`: `;` for a spreadsheet whose
764    /// decimal separator is a comma, with
765    /// [`RawCells::with_decimal_separator`]`(',')`.
766    ///
767    /// # Panics
768    ///
769    /// If `separator` is a quote, CR or LF, which no reader tells apart.
770    pub const fn with_separator(mut self, separator: char) -> Self {
771        assert!(
772            !matches!(separator, '"' | '\r' | '\n'),
773            "a CSV separator cannot be a quote or a line break"
774        );
775        self.separator = separator;
776        self
777    }
778
779    /// The separator between fields.
780    pub const fn separator(&self) -> char {
781        self.separator
782    }
783
784    /// Encodes the text with `encoding`.
785    pub const fn with_encoding(mut self, encoding: CsvEncoding) -> Self {
786        self.encoding = encoding;
787        self
788    }
789
790    /// How the text is encoded.
791    pub const fn encoding(&self) -> CsvEncoding {
792        self.encoding
793    }
794}
795
796impl Default for CsvFormat {
797    fn default() -> Self {
798        Self::new()
799    }
800}
801
802impl<R: GridRow> ExportFormat<R> for CsvFormat {
803    fn extension(&self) -> &str {
804        "csv"
805    }
806
807    fn export(
808        &self,
809        table: &ExportTable<'_, R>,
810        cells: &CellExport<R>,
811        out: &mut dyn io::Write,
812        control: &ExportControl,
813    ) -> Result<(), ExportError> {
814        write_delimited_file(
815            table,
816            cells,
817            TextDialect::csv(self.separator),
818            self.encoding.preamble(),
819            out,
820            control,
821        )
822    }
823}
824
825/// How the text of a CSV file is encoded.
826#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
827#[non_exhaustive]
828pub enum CsvEncoding {
829    /// UTF-8 starting with the byte order mark `EF BB BF`: Excel reads it as
830    /// UTF-8, and without the mark as the system's legacy code page.
831    #[default]
832    Utf8WithBom,
833    /// UTF-8 without a mark: for scripts, Google Sheets and `diff`.
834    Utf8,
835}
836
837impl CsvEncoding {
838    /// The text written before the first line.
839    const fn preamble(self) -> &'static str {
840        match self {
841            Self::Utf8WithBom => "\u{feff}",
842            Self::Utf8 => "",
843        }
844    }
845}
846
847/// Writes `table` as delimited text into `buffer`: the header line, then the
848/// rows. Calls `flush` after every [`FLUSH_ROWS`] rows and after the last,
849/// with the rows written since the previous call; `flush` takes the text out
850/// of `buffer` or leaves it there to grow.
851fn write_delimited<R: GridRow, E>(
852    table: &ExportTable<'_, R>,
853    cells: &CellExport<R>,
854    dialect: TextDialect,
855    buffer: &mut String,
856    mut flush: impl FnMut(&mut String, usize) -> Result<(), E>,
857) -> Result<(), E> {
858    let columns = table.columns();
859    let separator = dialect.separator;
860    // Holds a field while it is quoted; quoting is rare, so this rarely
861    // allocates at all.
862    let mut scratch = String::new();
863    let mut is_first_line = true;
864    let mut begin_line = |buffer: &mut String| {
865        if !is_first_line {
866            buffer.push_str(dialect.between_lines);
867        }
868        is_first_line = false;
869    };
870
871    if table.has_header() {
872        begin_line(buffer);
873        for (ix, column) in columns.iter().enumerate() {
874            if ix > 0 {
875                buffer.push(separator);
876            }
877            let start = buffer.len();
878            buffer.push_str(column.title());
879            quote_field(buffer, start, separator, &mut scratch);
880        }
881        buffer.push_str(dialect.after_line);
882    }
883    if table.rows().is_empty() {
884        return flush(buffer, 0);
885    }
886    for chunk in table.rows().chunks(FLUSH_ROWS) {
887        for row in chunk {
888            begin_line(buffer);
889            for (ix, column) in columns.iter().enumerate() {
890                if ix > 0 {
891                    buffer.push(separator);
892                }
893                if let Some(cell) = row.cell(column.column()) {
894                    let start = buffer.len();
895                    cells.write_cell(&cell, buffer);
896                    quote_field(buffer, start, separator, &mut scratch);
897                }
898            }
899            buffer.push_str(dialect.after_line);
900        }
901        flush(buffer, chunk.len())?;
902    }
903    Ok(())
904}
905
906/// Writes `table` as delimited text to `out`, after `preamble`: a buffer of
907/// [`FLUSH_ROWS`] rows at a time, one progress step per write.
908fn write_delimited_file<R: GridRow>(
909    table: &ExportTable<'_, R>,
910    cells: &CellExport<R>,
911    dialect: TextDialect,
912    preamble: &str,
913    out: &mut dyn io::Write,
914    control: &ExportControl,
915) -> Result<(), ExportError> {
916    let rows = table.rows().len().min(FLUSH_ROWS);
917    let mut buffer = String::with_capacity(
918        preamble.len() + (rows + 1) * (table.columns().len() * CELL_ESTIMATE + 2),
919    );
920    buffer.push_str(preamble);
921    write_delimited(table, cells, dialect, &mut buffer, |buffer, rows| {
922        out.write_all(buffer.as_bytes())?;
923        buffer.clear();
924        control.advance(rows)
925    })?;
926    out.flush()?;
927    Ok(())
928}
929
930/// Quotes the field from `start` to the end of `out` if it holds the
931/// separator, a quote, CR or LF, doubling its quotes: one rule for TSV and
932/// CSV.
933#[inline]
934fn quote_field(out: &mut String, start: usize, separator: char, scratch: &mut String) {
935    let field = &out.as_bytes()[start..];
936    let needs_quotes = if separator.is_ascii() {
937        let separator = separator as u8;
938        field
939            .iter()
940            .any(|&byte| byte == separator || matches!(byte, b'\n' | b'\r' | b'"'))
941    } else {
942        field
943            .iter()
944            .any(|byte| matches!(byte, b'\n' | b'\r' | b'"'))
945            || out[start..].contains(separator)
946    };
947    if !needs_quotes {
948        return;
949    }
950    scratch.clear();
951    scratch.push_str(&out[start..]);
952    out.truncate(start);
953    out.push('"');
954    for (ix, part) in scratch.split('"').enumerate() {
955        if ix > 0 {
956            out.push_str("\"\"");
957        }
958        out.push_str(part);
959    }
960    out.push('"');
961}
962
963/// Any clipboard [`Exporter`] as a file format: the whole text is written,
964/// then saved as UTF-8 without a byte order mark. Progress jumps from 0 to
965/// done.
966///
967/// ```
968/// use gpui_kit_grid::{TextFile, TsvExporter};
969///
970/// let format = TextFile::new(TsvExporter, "txt");
971/// let _: &TsvExporter = format.exporter();
972/// ```
973#[derive(Clone, Debug)]
974pub struct TextFile<E> {
975    exporter: E,
976    extension: Arc<str>,
977}
978
979impl<E> TextFile<E> {
980    /// Saves the text of `exporter` in a file named with `extension`, given
981    /// without a dot.
982    pub fn new(exporter: E, extension: impl Into<Arc<str>>) -> Self {
983        Self {
984            exporter,
985            extension: extension.into(),
986        }
987    }
988
989    /// The exporter that writes the text.
990    pub fn exporter(&self) -> &E {
991        &self.exporter
992    }
993}
994
995impl<R: GridRow, E: Exporter<R>> ExportFormat<R> for TextFile<E> {
996    fn extension(&self) -> &str {
997        &self.extension
998    }
999
1000    fn export(
1001        &self,
1002        table: &ExportTable<'_, R>,
1003        cells: &CellExport<R>,
1004        out: &mut dyn io::Write,
1005        control: &ExportControl,
1006    ) -> Result<(), ExportError> {
1007        // A cancelled export does not build its text.
1008        control.advance(0)?;
1009        let mut text = String::new();
1010        self.exporter.export(table, cells, &mut text);
1011        out.write_all(text.as_bytes())?;
1012        out.flush()?;
1013        control.advance(table.rows().len())
1014    }
1015}
1016
1017/// Writes only some lines of a table, with another exporter or format: for
1018/// example a copy without group footers and the totals, or the application's
1019/// rows alone. It wraps any clipboard format, and a grid takes it as any
1020/// exporter (`LiveGridState::with_exporter`); it wraps any file format, and
1021/// an export takes it as any format.
1022///
1023/// ```
1024/// use gpui_kit_grid::{CsvFormat, ExportLine, ExportLines, LineFilter, TsvExporter};
1025///
1026/// let exporter = LineFilter::new(
1027///     TsvExporter,
1028///     ExportLines::ALL
1029///         .without(ExportLine::Footer)
1030///         .without(ExportLine::Totals),
1031/// );
1032/// assert!(!exporter.lines().contains(ExportLine::Footer));
1033///
1034/// // The application's rows alone, to a CSV file.
1035/// let format = LineFilter::new(CsvFormat::new(), ExportLines::EMPTY.with(ExportLine::Data));
1036/// ```
1037///
1038/// A table without a left-out line goes to the exporter as it is; otherwise
1039/// the kept rows are copied into one list per export, in the background. A
1040/// file format's progress counts the left-out rows as written at the start.
1041#[derive(Clone, Copy, Debug, Default)]
1042pub struct LineFilter<E> {
1043    exporter: E,
1044    lines: ExportLines,
1045}
1046
1047impl<E> LineFilter<E> {
1048    /// Writes the `lines` of a table with `exporter`.
1049    pub fn new(exporter: E, lines: ExportLines) -> Self {
1050        Self { exporter, lines }
1051    }
1052
1053    /// The exporter that writes the kept lines.
1054    pub fn exporter(&self) -> &E {
1055        &self.exporter
1056    }
1057
1058    /// The lines written.
1059    pub fn lines(&self) -> ExportLines {
1060        self.lines
1061    }
1062
1063    /// The kept rows of `table`, or `None` if it keeps every row.
1064    fn kept_rows<'a, R: GridRow>(
1065        &self,
1066        table: &ExportTable<'a, R>,
1067    ) -> Option<Vec<ExportRow<'a, R>>> {
1068        let is_kept = |row: &ExportRow<'_, R>| self.lines.contains(row.line());
1069        if table.rows().iter().all(is_kept) {
1070            return None;
1071        }
1072        Some(table.rows().iter().copied().filter(is_kept).collect())
1073    }
1074}
1075
1076impl<R: GridRow, E: Exporter<R>> Exporter<R> for LineFilter<E> {
1077    fn export(&self, table: &ExportTable<'_, R>, cells: &CellExport<R>, out: &mut String) {
1078        match self.kept_rows(table) {
1079            None => self.exporter.export(table, cells, out),
1080            Some(kept) => self.exporter.export(&table.with_rows(&kept), cells, out),
1081        }
1082    }
1083}
1084
1085impl<R: GridRow, E: ExportFormat<R>> ExportFormat<R> for LineFilter<E> {
1086    fn extension(&self) -> &str {
1087        self.exporter.extension()
1088    }
1089
1090    fn export(
1091        &self,
1092        table: &ExportTable<'_, R>,
1093        cells: &CellExport<R>,
1094        out: &mut dyn io::Write,
1095        control: &ExportControl,
1096    ) -> Result<(), ExportError> {
1097        match self.kept_rows(table) {
1098            None => self.exporter.export(table, cells, out, control),
1099            Some(kept) => {
1100                control.advance(table.rows().len() - kept.len())?;
1101                self.exporter
1102                    .export(&table.with_rows(&kept), cells, out, control)
1103            }
1104        }
1105    }
1106}
1107
1108/// A column of an [`ExportTable`]: the application column, its header text,
1109/// and how the grid shows it, for a spreadsheet format to match.
1110#[derive(Clone, Copy, Debug, PartialEq)]
1111pub struct ExportColumn<'a, C> {
1112    column: C,
1113    title: &'a str,
1114    width: Option<f32>,
1115    align: ColumnAlign,
1116    zone: ColumnZone,
1117    bands: &'a [&'a str],
1118}
1119
1120impl<'a, C: GridColumns> ExportColumn<'a, C> {
1121    /// Creates a column with its header text: no width, `Auto` alignment, in
1122    /// the scrolling zone, under no band.
1123    pub const fn new(column: C, title: &'a str) -> Self {
1124        Self {
1125            column,
1126            title,
1127            width: None,
1128            align: ColumnAlign::Auto,
1129            zone: ColumnZone::Scrolling,
1130            bands: &[],
1131        }
1132    }
1133
1134    /// Sets the column's width on screen, in logical pixels. A spreadsheet
1135    /// format derives its own width from it: in Excel, about
1136    /// `(width - 5) / 7` characters of Calibri 11.
1137    pub fn with_width(self, width: f32) -> Self {
1138        Self {
1139            width: Some(width),
1140            ..self
1141        }
1142    }
1143
1144    /// Sets the column's alignment.
1145    pub fn with_align(self, align: ColumnAlign) -> Self {
1146        Self { align, ..self }
1147    }
1148
1149    /// Sets the zone the column is shown in.
1150    pub fn with_zone(self, zone: ColumnZone) -> Self {
1151        Self { zone, ..self }
1152    }
1153
1154    /// Sets the titles of the header bands over the column, outermost first
1155    /// (`ColumnBand`): rows of merged header cells in a spreadsheet.
1156    pub fn with_bands(self, bands: &'a [&'a str]) -> Self {
1157        Self { bands, ..self }
1158    }
1159
1160    /// The application column.
1161    pub fn column(&self) -> C {
1162        self.column
1163    }
1164
1165    /// The header text, written when the table has a header line.
1166    pub fn title(&self) -> &'a str {
1167        self.title
1168    }
1169
1170    /// The column's width on screen, in logical pixels; `None` if not given.
1171    pub fn width(&self) -> Option<f32> {
1172        self.width
1173    }
1174
1175    /// The column's alignment; `Auto` puts numbers right and the rest left.
1176    pub fn align(&self) -> ColumnAlign {
1177        self.align
1178    }
1179
1180    /// The zone the column is shown in: the leading `FrozenLeft` columns are
1181    /// what a spreadsheet freezes ([`ExportTable::frozen_columns`]).
1182    pub fn zone(&self) -> ColumnZone {
1183        self.zone
1184    }
1185
1186    /// The titles of the header bands over the column, outermost first;
1187    /// empty under no band.
1188    pub fn bands(&self) -> &'a [&'a str] {
1189        self.bands
1190    }
1191}
1192
1193/// A row of an [`ExportTable`]: a row of the application, or a summary row
1194/// of a grouped view ([`ExportLine`]).
1195///
1196/// | Row | Cell of a column | Value |
1197/// |---|---|---|
1198/// | [`new`](Self::new) | Every column | The row's value |
1199/// | [`caption`](Self::caption) | `value_column` | The group's key value, also where the column has an aggregate |
1200/// | | An aggregated column | The group's aggregate |
1201/// | [`footer`](Self::footer) | An aggregated column | The group's aggregate |
1202/// | [`totals`](Self::totals) | An aggregated column | The view's aggregate |
1203/// | | The label's column without an aggregate | The label |
1204///
1205/// A summary row has no cell in any other column: an empty field. An empty
1206/// aggregate is a cell with [`CellValue::Empty`].
1207///
1208/// Marks tell where the row is on screen, for a spreadsheet's outline and
1209/// frozen panes: its pinned band ([`band`](Self::band)), whether a collapsed
1210/// group or tree row hides it ([`is_hidden`](Self::is_hidden)), and its
1211/// children in a tree ([`branch`](Self::branch)). A data row may carry the
1212/// optimistic values of edits still waiting for their answer
1213/// ([`with_pending`](Self::with_pending)): its cells show them, as the
1214/// screen does.
1215pub struct ExportRow<'a, R: GridRow> {
1216    source: RowSource<'a, R>,
1217    columns: ColumnSet,
1218    /// How each column shows its numbers, by `ColumnIx`; empty: the
1219    /// default.
1220    formats: &'a [NumberFormat],
1221    depth: u16,
1222    band: Option<PinnedEdge>,
1223    hidden: bool,
1224}
1225
1226/// What an exported row is made of.
1227enum RowSource<'a, R: GridRow> {
1228    /// A row of the application, with what only a data row has. Smaller than
1229    /// the totals variant, so the extras cost the row no space.
1230    Data {
1231        row: &'a R,
1232        /// Optimistic values by column, which replace the row's values.
1233        pending: &'a [(R::Column, OwnedValue)],
1234        branch: Option<Branch>,
1235    },
1236    /// The group's key value goes to `value_column`.
1237    Caption {
1238        group: GroupInfo<'a>,
1239        value_column: R::Column,
1240    },
1241    Footer(GroupInfo<'a>),
1242    /// `label` goes to its column when the column has no aggregate.
1243    Totals {
1244        aggregates: AggregateRow<'a>,
1245        label: Option<(R::Column, &'a str)>,
1246    },
1247}
1248
1249impl<R: GridRow> Clone for RowSource<'_, R> {
1250    fn clone(&self) -> Self {
1251        *self
1252    }
1253}
1254
1255impl<R: GridRow> Copy for RowSource<'_, R> {}
1256
1257impl<'a, R: GridRow> ExportRow<'a, R> {
1258    /// A row of the application that exports every column of the table, at
1259    /// depth 0.
1260    pub fn new(row: &'a R) -> Self {
1261        Self::of(
1262            RowSource::Data {
1263                row,
1264                pending: &[],
1265                branch: None,
1266            },
1267            0,
1268        )
1269    }
1270
1271    /// The caption of `group`: its key value in `value_column`, its
1272    /// aggregates in their columns, at the group's depth. The grid passes
1273    /// the group's own column when it is exported, else the first exported
1274    /// column.
1275    pub fn caption(value_column: R::Column, group: GroupInfo<'a>) -> Self {
1276        Self::of(
1277            RowSource::Caption {
1278                group,
1279                value_column,
1280            },
1281            group.depth(),
1282        )
1283    }
1284
1285    /// The footer of `group`: its aggregates in their columns, at the
1286    /// group's depth.
1287    pub fn footer(group: GroupInfo<'a>) -> Self {
1288        Self::of(RowSource::Footer(group), group.depth())
1289    }
1290
1291    /// The totals row: the aggregates of the whole view in their columns,
1292    /// and `label` in its column when that column has no aggregate; at
1293    /// depth 0.
1294    pub fn totals(aggregates: AggregateRow<'a>, label: Option<(R::Column, &'a str)>) -> Self {
1295        Self::of(RowSource::Totals { aggregates, label }, 0)
1296    }
1297
1298    fn of(source: RowSource<'a, R>, depth: u16) -> Self {
1299        Self {
1300            source,
1301            columns: ColumnSet::first(R::Column::all().len()),
1302            formats: &[],
1303            depth,
1304            band: None,
1305            hidden: false,
1306        }
1307    }
1308
1309    /// Sets how each column shows its numbers, by `ColumnIx`
1310    /// (`GridColumn::with_number_format`): a column past the end shows the
1311    /// default. The grid sets its columns' formats on every row it
1312    /// exports, so a cell is written as the screen shows it.
1313    pub fn with_number_formats(self, formats: &'a [NumberFormat]) -> Self {
1314        Self { formats, ..self }
1315    }
1316
1317    /// Sets the row's level under group captions or tree parents: a data
1318    /// row of a group is one level below the group's caption.
1319    pub fn with_depth(self, depth: u16) -> Self {
1320        Self { depth, ..self }
1321    }
1322
1323    /// Exports only `columns` of this row; its other cells in the table are
1324    /// empty fields. A cell selection copies its bounding rectangle this way.
1325    pub fn with_columns(self, columns: ColumnSet) -> Self {
1326        Self { columns, ..self }
1327    }
1328
1329    /// Marks a row of a pinned band, or the totals row in it, with the band's
1330    /// edge. The top band comes before the other lines and is frozen with the
1331    /// header on screen.
1332    pub fn with_band(self, edge: PinnedEdge) -> Self {
1333        Self {
1334            band: Some(edge),
1335            ..self
1336        }
1337    }
1338
1339    /// Marks the row hidden on screen: under a collapsed group caption or
1340    /// tree row. Only an `ExportScope::Expanded` export writes such rows; a
1341    /// spreadsheet may write them hidden under a collapsed outline level.
1342    pub fn with_hidden(self, hidden: bool) -> Self {
1343        Self { hidden, ..self }
1344    }
1345
1346    /// Marks a tree row with shown children: their count and whether it is
1347    /// expanded on screen. A summary row is no tree row: ignored there.
1348    pub fn with_branch(self, branch: Branch) -> Self {
1349        let source = match self.source {
1350            RowSource::Data { row, pending, .. } => RowSource::Data {
1351                row,
1352                pending,
1353                branch: Some(branch),
1354            },
1355            source => source,
1356        };
1357        Self { source, ..self }
1358    }
1359
1360    /// Sets the optimistic values of edits to this row that still wait for
1361    /// their answer, by column: they replace the row's values of their
1362    /// columns, as on screen ([`ExportCell::is_pending`]). A summary row has
1363    /// none: ignored there.
1364    pub fn with_pending(self, pending: &'a [(R::Column, OwnedValue)]) -> Self {
1365        let source = match self.source {
1366            RowSource::Data { row, branch, .. } => RowSource::Data {
1367                row,
1368                pending,
1369                branch,
1370            },
1371            source => source,
1372        };
1373        Self { source, ..self }
1374    }
1375
1376    /// What the row is.
1377    pub fn line(&self) -> ExportLine {
1378        match self.source {
1379            RowSource::Data { .. } => ExportLine::Data,
1380            RowSource::Caption { .. } => ExportLine::Caption,
1381            RowSource::Footer(_) => ExportLine::Footer,
1382            RowSource::Totals { .. } => ExportLine::Totals,
1383        }
1384    }
1385
1386    /// The application row; `None` for a caption, a footer or the totals.
1387    pub fn row(&self) -> Option<&'a R> {
1388        match self.source {
1389            RowSource::Data { row, .. } => Some(row),
1390            _ => None,
1391        }
1392    }
1393
1394    /// The edge of the pinned band the row is in; `None` outside the bands.
1395    pub fn band(&self) -> Option<PinnedEdge> {
1396        self.band
1397    }
1398
1399    /// Returns true if the row is hidden on screen, under a collapsed group
1400    /// caption or tree row.
1401    pub fn is_hidden(&self) -> bool {
1402        self.hidden
1403    }
1404
1405    /// The shown children of a tree row; `None` for a leaf, outside a tree
1406    /// and for a summary row.
1407    pub fn branch(&self) -> Option<Branch> {
1408        match self.source {
1409            RowSource::Data { branch, .. } => branch,
1410            _ => None,
1411        }
1412    }
1413
1414    /// The group of a caption or a footer: its path, row count and
1415    /// aggregates.
1416    pub fn group(&self) -> Option<GroupInfo<'a>> {
1417        match self.source {
1418            RowSource::Caption { group, .. } | RowSource::Footer(group) => Some(group),
1419            _ => None,
1420        }
1421    }
1422
1423    /// The row's level under group captions or tree parents; 0 at the top.
1424    pub fn depth(&self) -> u16 {
1425        self.depth
1426    }
1427
1428    /// The columns this row exports.
1429    pub fn columns(&self) -> ColumnSet {
1430        self.columns
1431    }
1432
1433    /// The cell of `column`, or `None` if this row does not export it or,
1434    /// in a summary row, has nothing in it.
1435    pub fn cell(&self, column: R::Column) -> Option<ExportCell<'a, R>> {
1436        let ix = column.ix();
1437        if !self.columns.contains(ix) {
1438            return None;
1439        }
1440        let mut is_pending = false;
1441        let (source, value, is_aggregate) = match self.source {
1442            RowSource::Data { row, pending, .. } => {
1443                let value = match pending.iter().find(|(edited, _)| *edited == column) {
1444                    Some((_, value)) => {
1445                        is_pending = true;
1446                        value.as_cell()
1447                    }
1448                    None => row.value(column),
1449                };
1450                (CellSource::Data(row), value, false)
1451            }
1452            RowSource::Caption {
1453                group,
1454                value_column,
1455            } => {
1456                let source = CellSource::Caption(group);
1457                if column == value_column {
1458                    (source, group.value().as_cell(), false)
1459                } else {
1460                    (source, group.aggregates().get(ix)?.as_cell(), true)
1461                }
1462            }
1463            RowSource::Footer(group) => (
1464                CellSource::Footer(group),
1465                group.aggregates().get(ix)?.as_cell(),
1466                true,
1467            ),
1468            RowSource::Totals { aggregates, label } => match (aggregates.get(ix), label) {
1469                (Some(aggregate), _) => (CellSource::Totals, aggregate.as_cell(), true),
1470                (None, Some((label_column, label))) if label_column == column => {
1471                    (CellSource::Totals, CellValue::Text(label), false)
1472                }
1473                (None, _) => return None,
1474            },
1475        };
1476        Some(ExportCell {
1477            source,
1478            column,
1479            value,
1480            number_format: self.formats.get(ix.get()).copied().unwrap_or_default(),
1481            depth: self.depth,
1482            is_aggregate,
1483            is_pending,
1484        })
1485    }
1486}
1487
1488impl<R: GridRow> Clone for ExportRow<'_, R> {
1489    fn clone(&self) -> Self {
1490        *self
1491    }
1492}
1493
1494impl<R: GridRow> Copy for ExportRow<'_, R> {}
1495
1496/// One exported cell: what a [`CellExporter`] writes.
1497pub struct ExportCell<'a, R: GridRow> {
1498    source: CellSource<'a, R>,
1499    column: R::Column,
1500    value: CellValue<'a>,
1501    number_format: NumberFormat,
1502    depth: u16,
1503    is_aggregate: bool,
1504    is_pending: bool,
1505}
1506
1507/// The row an exported cell is of: what [`ExportCell`] reads without the
1508/// row's other cells.
1509enum CellSource<'a, R: GridRow> {
1510    Data(&'a R),
1511    Caption(GroupInfo<'a>),
1512    Footer(GroupInfo<'a>),
1513    Totals,
1514}
1515
1516impl<R: GridRow> Clone for CellSource<'_, R> {
1517    fn clone(&self) -> Self {
1518        *self
1519    }
1520}
1521
1522impl<R: GridRow> Copy for CellSource<'_, R> {}
1523
1524impl<'a, R: GridRow> ExportCell<'a, R> {
1525    /// The application row of the cell; `None` in a caption, a footer or
1526    /// the totals.
1527    pub fn row(&self) -> Option<&'a R> {
1528        match self.source {
1529            CellSource::Data(row) => Some(row),
1530            _ => None,
1531        }
1532    }
1533
1534    /// The column of the cell.
1535    pub fn column(&self) -> R::Column {
1536        self.column
1537    }
1538
1539    /// The value of the cell, read once: the row's value, or the optimistic
1540    /// value of an edit waiting for its answer, as on screen; a summary
1541    /// row's group key value, aggregate or totals label.
1542    pub fn value(&self) -> CellValue<'a> {
1543        self.value
1544    }
1545
1546    /// Returns true if [`value`](Self::value) is an edit's optimistic value,
1547    /// not yet confirmed.
1548    pub fn is_pending(&self) -> bool {
1549        self.is_pending
1550    }
1551
1552    /// The row's own value of the cell, under a pending one; the same as
1553    /// [`value`](Self::value) otherwise. `None` in a summary row.
1554    pub fn row_value(&self) -> Option<CellValue<'a>> {
1555        match self.source {
1556            CellSource::Data(row) if self.is_pending => Some(row.value(self.column)),
1557            CellSource::Data(_) => Some(self.value),
1558            _ => None,
1559        }
1560    }
1561
1562    /// How the cell's column shows its numbers
1563    /// (`GridColumn::with_number_format`).
1564    pub fn number_format(&self) -> NumberFormat {
1565        self.number_format
1566    }
1567
1568    /// How the grid shows the number in the cell, so that a spreadsheet
1569    /// format can match the screen: `Float` with its column's digits after
1570    /// the point,
1571    /// `Decimal` with its scale, `Int` with none; a custom value by its
1572    /// [`GridValue::export_value`](crate::GridValue::export_value). `None`
1573    /// for a value that is not a number, a `Timestamp` included. No
1574    /// allocation.
1575    pub fn number_hint(&self) -> Option<NumberHint> {
1576        number_fraction_digits(self.value, self.number_format)
1577            .map(|fraction_digits| NumberHint { fraction_digits })
1578    }
1579
1580    /// What the cell's row is.
1581    pub fn line(&self) -> ExportLine {
1582        match self.source {
1583            CellSource::Data(_) => ExportLine::Data,
1584            CellSource::Caption(_) => ExportLine::Caption,
1585            CellSource::Footer(_) => ExportLine::Footer,
1586            CellSource::Totals => ExportLine::Totals,
1587        }
1588    }
1589
1590    /// The level of the cell's row under group captions or tree parents.
1591    pub fn depth(&self) -> u16 {
1592        self.depth
1593    }
1594
1595    /// The group of a caption or a footer cell: its path, row count and
1596    /// aggregates.
1597    pub fn group(&self) -> Option<GroupInfo<'a>> {
1598        match self.source {
1599            CellSource::Caption(group) | CellSource::Footer(group) => Some(group),
1600            _ => None,
1601        }
1602    }
1603
1604    /// Returns true if the cell holds an aggregate: its group's in a caption
1605    /// or a footer, the whole view's in the totals. A caption's key value and
1606    /// the totals label are not aggregates.
1607    pub fn is_aggregate(&self) -> bool {
1608        self.is_aggregate
1609    }
1610}
1611
1612impl<R: GridRow> Clone for ExportCell<'_, R> {
1613    fn clone(&self) -> Self {
1614        *self
1615    }
1616}
1617
1618impl<R: GridRow> Copy for ExportCell<'_, R> {}
1619
1620/// How the grid shows a number, for an export format to match it
1621/// ([`ExportCell::number_hint`]): today the digits after the decimal point,
1622/// from the column's [`NumberFormat`].
1623#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1624pub struct NumberHint {
1625    fraction_digits: u32,
1626}
1627
1628impl NumberHint {
1629    /// The digits the grid shows after the decimal point: a spreadsheet
1630    /// format such as `0.00` for 2.
1631    pub const fn fraction_digits(&self) -> u32 {
1632        self.fraction_digits
1633    }
1634}
1635
1636/// The rows and columns an [`Exporter`] turns into text and an
1637/// [`ExportFormat`] into a file.
1638///
1639/// Columns are in screen order and rows in the shown order, summary rows
1640/// among them ([`ExportLine`]). The grid builds the table from one snapshot,
1641/// so an export never mixes two store versions.
1642pub struct ExportTable<'a, R: GridRow> {
1643    columns: &'a [ExportColumn<'a, R::Column>],
1644    rows: &'a [ExportRow<'a, R>],
1645    header: bool,
1646}
1647
1648impl<'a, R: GridRow> ExportTable<'a, R> {
1649    /// A table without a header line.
1650    pub fn new(columns: &'a [ExportColumn<'a, R::Column>], rows: &'a [ExportRow<'a, R>]) -> Self {
1651        Self {
1652            columns,
1653            rows,
1654            header: false,
1655        }
1656    }
1657
1658    /// Writes a line of column titles before the rows.
1659    pub fn with_header(self, header: bool) -> Self {
1660        Self { header, ..self }
1661    }
1662
1663    /// The exported columns, in screen order.
1664    pub fn columns(&self) -> &'a [ExportColumn<'a, R::Column>] {
1665        self.columns
1666    }
1667
1668    /// Returns true if a line of column titles comes before the rows.
1669    pub fn has_header(&self) -> bool {
1670        self.header
1671    }
1672
1673    /// The exported rows, in the shown order.
1674    pub fn rows(&self) -> &'a [ExportRow<'a, R>] {
1675        self.rows
1676    }
1677
1678    /// The leading columns in the `FrozenLeft` zone: the columns a
1679    /// spreadsheet freezes.
1680    pub fn frozen_columns(&self) -> usize {
1681        self.columns
1682            .iter()
1683            .take_while(|column| column.zone() == ColumnZone::FrozenLeft)
1684            .count()
1685    }
1686
1687    /// The depth of the deepest band over the exported columns: the rows of
1688    /// band titles a multi-level header takes above the column titles.
1689    pub fn band_depth(&self) -> usize {
1690        self.columns
1691            .iter()
1692            .map(|column| column.bands().len())
1693            .max()
1694            .unwrap_or(0)
1695    }
1696
1697    /// The same table with `rows`.
1698    fn with_rows<'b>(&self, rows: &'b [ExportRow<'b, R>]) -> ExportTable<'b, R>
1699    where
1700        'a: 'b,
1701    {
1702        ExportTable {
1703            columns: self.columns,
1704            rows,
1705            header: self.header,
1706        }
1707    }
1708}
1709
1710impl<R: GridRow> Clone for ExportTable<'_, R> {
1711    fn clone(&self) -> Self {
1712        *self
1713    }
1714}
1715
1716impl<R: GridRow> Copy for ExportTable<'_, R> {}
1717
1718#[cfg(test)]
1719mod tests {
1720    use std::{
1721        sync::{
1722            Mutex,
1723            atomic::{AtomicUsize, Ordering},
1724        },
1725        time::Instant,
1726    };
1727
1728    use super::*;
1729    use crate::{
1730        engine::{
1731            Aggregate, EveryGroupFooter, GroupBy, GroupId, Projection, ProjectionEngine as _,
1732            Rebuild, ViewSpec,
1733        },
1734        highlight::HighlightPolicy,
1735        model::{ColumnIx, OwnedValue, display_text},
1736        store::{Ingest, Op, StoreSnapshot},
1737    };
1738
1739    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1740    enum Column {
1741        Symbol,
1742        Price,
1743        Note,
1744    }
1745
1746    impl GridColumns for Column {
1747        fn all() -> &'static [Self] {
1748            &[Self::Symbol, Self::Price, Self::Note]
1749        }
1750
1751        fn id(self) -> &'static str {
1752            match self {
1753                Self::Symbol => "symbol",
1754                Self::Price => "price",
1755                Self::Note => "note",
1756            }
1757        }
1758
1759        fn ix(self) -> ColumnIx {
1760            ColumnIx::new(self as u16)
1761        }
1762    }
1763
1764    struct Row {
1765        key: u32,
1766        cells: [CellValue<'static>; 3],
1767        parent: Option<u32>,
1768    }
1769
1770    impl Row {
1771        fn new(key: u32, cells: [CellValue<'static>; 3]) -> Self {
1772            Self {
1773                key,
1774                cells,
1775                parent: None,
1776            }
1777        }
1778
1779        fn with_parent(self, parent: u32) -> Self {
1780            Self {
1781                parent: Some(parent),
1782                ..self
1783            }
1784        }
1785    }
1786
1787    impl GridRow for Row {
1788        type Key = u32;
1789        type Column = Column;
1790
1791        fn key(&self) -> &u32 {
1792            &self.key
1793        }
1794
1795        fn value(&self, column: Column) -> CellValue<'_> {
1796            self.cells[column.ix().get()]
1797        }
1798
1799        fn parent_key(&self) -> Option<&u32> {
1800            self.parent.as_ref()
1801        }
1802    }
1803
1804    const COLUMNS: [ExportColumn<'static, Column>; 3] = [
1805        ExportColumn::new(Column::Symbol, "Symbol"),
1806        ExportColumn::new(Column::Price, "Price"),
1807        ExportColumn::new(Column::Note, "Note \"short\""),
1808    ];
1809
1810    fn columns(columns: &[Column]) -> ColumnSet {
1811        let mut set = ColumnSet::EMPTY;
1812        for column in columns {
1813            set.insert(column.ix());
1814        }
1815        set
1816    }
1817
1818    /// The text `cells` writes for one value.
1819    fn cell_text(cells: &CellExport<Row>, value: CellValue<'static>) -> String {
1820        let row = Row::new(0, [value, CellValue::Empty, CellValue::Empty]);
1821        let cell = ExportRow::new(&row).cell(Column::Symbol).unwrap();
1822        let mut out = String::new();
1823        cells.write_cell(&cell, &mut out);
1824        out
1825    }
1826
1827    fn tsv(table: &ExportTable<'_, Row>, cells: &CellExport<Row>) -> String {
1828        let mut out = String::new();
1829        Exporter::export(&TsvExporter, table, cells, &mut out);
1830        out
1831    }
1832
1833    #[test]
1834    fn test_tsv_quoting_and_raw_cells() {
1835        // Every digit with `NumberPrecision::Full`.
1836        let raw = CellExport::Raw(RawCells::new().with_precision(NumberPrecision::Full));
1837        let cases = [
1838            (CellValue::Int(-1234), "-1234"),
1839            (CellValue::Int(i64::MIN), "-9223372036854775808"),
1840            (CellValue::Float(1234.5), "1234.5"),
1841            (CellValue::Float(0.1), "0.1"),
1842            (CellValue::Float(0.1 + 0.2), "0.30000000000000004"),
1843            (CellValue::Float(-0.5), "-0.5"),
1844            (CellValue::Float(2.0), "2"),
1845            (CellValue::Float(1e21), "1000000000000000000000"),
1846            (CellValue::Float(1e-7), "0.0000001"),
1847            (CellValue::Float(f64::NAN), ""),
1848            (CellValue::Float(f64::INFINITY), ""),
1849            (CellValue::Float(f64::NEG_INFINITY), ""),
1850            (CellValue::Bool(true), "TRUE"),
1851            (CellValue::Bool(false), "FALSE"),
1852            (CellValue::Empty, ""),
1853            (CellValue::Text("Сбербанк"), "Сбербанк"),
1854        ];
1855        for (value, expected) in cases {
1856            assert_eq!(cell_text(&raw, value), expected, "{value:?}");
1857        }
1858        // By default, the decimals the grid shows: two unless the column's
1859        // format says otherwise.
1860        let shown = CellExport::default();
1861        for (value, expected) in [
1862            (CellValue::Float(-0.489_999_999_999_994_9), "-0.49"),
1863            (CellValue::Float(1234.5), "1234.50"),
1864            (CellValue::Float(0.1 + 0.2), "0.30"),
1865            (CellValue::Float(2.0), "2.00"),
1866            (CellValue::Float(1e21), "1000000000000000000000.00"),
1867            (CellValue::Float(f64::NAN), ""),
1868            (CellValue::Int(-1234), "-1234"),
1869        ] {
1870            assert_eq!(cell_text(&shown, value), expected, "{value:?}");
1871        }
1872        let row = Row::new(0, [CellValue::Float(-0.489_999_999_999_994_9); 3]);
1873        let formats = [
1874            NumberFormat::new().with_decimals(1),
1875            NumberFormat::new().with_decimals(4),
1876        ];
1877        let exported = ExportRow::new(&row).with_number_formats(&formats);
1878        let written = |cells: &CellExport<Row>, column| {
1879            let mut out = String::new();
1880            cells.write_cell(&exported.cell(column).unwrap(), &mut out);
1881            out
1882        };
1883        assert_eq!(written(&shown, Column::Symbol), "-0.5");
1884        assert_eq!(written(&shown, Column::Price), "-0.4900");
1885        // A column past the formats shows the default.
1886        assert_eq!(written(&shown, Column::Note), "-0.49");
1887        assert_eq!(
1888            written(&CellExport::Displayed(DisplayedCells), Column::Symbol),
1889            "-0.5"
1890        );
1891        assert_eq!(written(&raw, Column::Symbol), "-0.4899999999999949");
1892        assert_eq!(
1893            exported.cell(Column::Price).unwrap().number_hint(),
1894            Some(NumberHint { fraction_digits: 4 })
1895        );
1896
1897        // The decimal separator replaces only the dot of a `Float`, also with
1898        // a separator longer than one byte.
1899        let comma = RawCells::new().with_decimal_separator(',');
1900        assert_eq!(comma.decimal_separator(), ',');
1901        let comma = CellExport::Raw(comma);
1902        assert_eq!(cell_text(&comma, CellValue::Float(-1234.5)), "-1234,50");
1903        assert_eq!(
1904            cell_text(&comma, CellValue::Float(1e21)),
1905            "1000000000000000000000,00"
1906        );
1907        assert_eq!(cell_text(&comma, CellValue::Int(-1234)), "-1234");
1908        assert_eq!(cell_text(&comma, CellValue::Text("1.5")), "1.5");
1909        let arabic = CellExport::Raw(RawCells::new().with_decimal_separator('٫'));
1910        assert_eq!(cell_text(&arabic, CellValue::Float(0.25)), "0٫25");
1911        assert_eq!(cell_text(&arabic, CellValue::Float(0.5)), "0٫50");
1912
1913        // A header line, quoted fields and no line break after the last row.
1914        let rows = [
1915            Row::new(
1916                1,
1917                [
1918                    CellValue::Text("AAPL"),
1919                    CellValue::Float(187.25),
1920                    CellValue::Text("tab\there"),
1921                ],
1922            ),
1923            Row::new(
1924                2,
1925                [
1926                    CellValue::Text("say \"hi\""),
1927                    CellValue::Int(-3),
1928                    CellValue::Text("two\nlines\r"),
1929                ],
1930            ),
1931            Row::new(
1932                3,
1933                [CellValue::Empty, CellValue::Bool(true), CellValue::Empty],
1934            ),
1935        ];
1936        let all: Vec<_> = rows.iter().map(ExportRow::new).collect();
1937        let table = ExportTable::new(&COLUMNS, &all).with_header(true);
1938        assert!(table.has_header());
1939        assert_eq!(
1940            tsv(&table, &raw),
1941            "Symbol\tPrice\t\"Note \"\"short\"\"\"\n\
1942             AAPL\t187.25\t\"tab\there\"\n\
1943             \"say \"\"hi\"\"\"\t-3\t\"two\nlines\r\"\n\
1944             \tTRUE\t"
1945        );
1946
1947        // A cell rectangle: cells a row does not export are empty fields, and
1948        // the exporter is not asked for them.
1949        let rectangle = [
1950            ExportRow::new(&rows[0]).with_columns(columns(&[Column::Symbol])),
1951            ExportRow::new(&rows[1]).with_columns(columns(&[Column::Note])),
1952        ];
1953        assert_eq!(rectangle[1].columns(), columns(&[Column::Note]));
1954        assert!(rectangle[1].cell(Column::Symbol).is_none());
1955        let table = ExportTable::new(&COLUMNS, &rectangle);
1956        assert!(!table.has_header());
1957        assert_eq!(tsv(&table, &raw), "AAPL\t\t\n\t\t\"two\nlines\r\"");
1958
1959        // Columns are written in the table's order, not the schema's.
1960        let reordered = [
1961            ExportColumn::new(Column::Price, "Price"),
1962            ExportColumn::new(Column::Symbol, "Symbol"),
1963        ];
1964        let table = ExportTable::new(&reordered, &all[..1]);
1965        assert_eq!(tsv(&table, &comma), "187,25\tAAPL");
1966
1967        // An empty table writes nothing, or only its header line; an export
1968        // appends to what is already in the buffer.
1969        assert_eq!(tsv(&ExportTable::new(&COLUMNS[..2], &[]), &raw), "");
1970        let header_only = ExportTable::new(&COLUMNS[..2], &[]).with_header(true);
1971        assert_eq!(tsv(&header_only, &raw), "Symbol\tPrice");
1972        let mut out = String::from("x");
1973        Exporter::export(
1974            &TsvExporter,
1975            &ExportTable::new(&COLUMNS[..1], &all[..1]),
1976            &raw,
1977            &mut out,
1978        );
1979        assert_eq!(out, "xAAPL");
1980    }
1981
1982    #[test]
1983    fn test_raw_cells_new_values() {
1984        use chrono::{TimeZone as _, Utc};
1985
1986        use crate::model::{GridValue, IconValue, ImageValue};
1987
1988        /// Exports as a number, shows as `3M`.
1989        #[derive(Debug)]
1990        struct Tenor(i64);
1991
1992        impl GridValue for Tenor {
1993            fn cmp_value(&self, other: &dyn GridValue) -> std::cmp::Ordering {
1994                other
1995                    .downcast_ref::<Tenor>()
1996                    .map_or(std::cmp::Ordering::Less, |other| self.0.cmp(&other.0))
1997            }
1998
1999            fn write_text(&self, out: &mut String) {
2000                let _ = write!(out, "{}M", self.0 / 30);
2001            }
2002
2003            fn export_value(&self) -> Option<CellValue<'_>> {
2004                Some(CellValue::Float(self.0 as f64 / 360.0))
2005            }
2006        }
2007
2008        /// Shows its name and has no export of its own.
2009        #[derive(Debug)]
2010        struct Severity;
2011
2012        impl GridValue for Severity {
2013            fn cmp_value(&self, _: &dyn GridValue) -> std::cmp::Ordering {
2014                std::cmp::Ordering::Equal
2015            }
2016
2017            fn write_text(&self, out: &mut String) {
2018                out.push_str("Critical");
2019            }
2020        }
2021
2022        /// Exports another custom value: written as that value's text.
2023        #[derive(Debug)]
2024        struct Alias;
2025
2026        impl GridValue for Alias {
2027            fn cmp_value(&self, _: &dyn GridValue) -> std::cmp::Ordering {
2028                std::cmp::Ordering::Equal
2029            }
2030
2031            fn write_text(&self, out: &mut String) {
2032                out.push_str("alias");
2033            }
2034
2035            fn export_value(&self) -> Option<CellValue<'_>> {
2036                Some(CellValue::Custom(&Severity))
2037            }
2038        }
2039
2040        static TENOR: Tenor = Tenor(90);
2041        let placed = Utc.with_ymd_and_hms(2026, 11, 4, 12, 34, 56).unwrap();
2042        let raw = CellExport::default();
2043        let comma = CellExport::Raw(RawCells::new().with_decimal_separator(','));
2044        let displayed = CellExport::Displayed(DisplayedCells);
2045        let mut buffer = String::new();
2046        for (value, expected, with_comma) in [
2047            (
2048                CellValue::Timestamp(placed),
2049                "2026-11-04 12:34:56",
2050                "2026-11-04 12:34:56",
2051            ),
2052            (
2053                CellValue::Icon(IconValue::new("icons/check.svg", "Filled")),
2054                "Filled",
2055                "Filled",
2056            ),
2057            (
2058                CellValue::Image(ImageValue::new("images/ada.png", "Ada")),
2059                "Ada",
2060                "Ada",
2061            ),
2062            (CellValue::Custom(&Severity), "Critical", "Critical"),
2063            (CellValue::Custom(&TENOR), "0.25", "0,25"),
2064            (CellValue::Custom(&Alias), "Critical", "Critical"),
2065        ] {
2066            assert_eq!(cell_text(&raw, value), expected, "{value:?}");
2067            assert_eq!(cell_text(&comma, value), with_comma, "{value:?}");
2068            // A displayed copy writes the text the body paints.
2069            assert_eq!(
2070                cell_text(&displayed, value),
2071                display_text(value, &mut buffer),
2072                "{value:?}"
2073            );
2074        }
2075        assert_eq!(cell_text(&displayed, CellValue::Custom(&TENOR)), "3M");
2076
2077        // In a line, a custom value appends to what is there.
2078        let rows = [Row::new(
2079            1,
2080            [
2081                CellValue::Icon(IconValue::new("icons/check.svg", "Filled")),
2082                CellValue::Custom(&TENOR),
2083                CellValue::Custom(&Severity),
2084            ],
2085        )];
2086        let all: Vec<_> = rows.iter().map(ExportRow::new).collect();
2087        assert_eq!(
2088            tsv(&ExportTable::new(&COLUMNS, &all), &raw),
2089            "Filled\t0.25\tCritical"
2090        );
2091        assert_eq!(
2092            tsv(&ExportTable::new(&COLUMNS, &all), &displayed),
2093            "Filled\t3M\tCritical"
2094        );
2095    }
2096
2097    #[test]
2098    fn test_cell_export_variants() {
2099        fn assert_send_sync<T: Send + Sync + 'static>() {}
2100        assert_send_sync::<CellExport<Row>>();
2101        assert_send_sync::<TsvExporter>();
2102        assert_send_sync::<Box<dyn Exporter<Row>>>();
2103        assert_send_sync::<Box<dyn ExportFormat<Row>>>();
2104        assert_send_sync::<CsvFormat>();
2105        assert_send_sync::<TextFile<TsvExporter>>();
2106        assert_send_sync::<LineFilter<CsvFormat>>();
2107        assert_send_sync::<ExportControl>();
2108        assert_send_sync::<ExportError>();
2109
2110        // The default is raw values with a dot.
2111        assert!(matches!(
2112            CellExport::<Row>::default(),
2113            CellExport::Raw(raw) if raw == RawCells::new() && raw.decimal_separator() == '.'
2114        ));
2115        assert_eq!(RawCells::default(), RawCells::new());
2116
2117        // `Displayed` writes the text the body paints.
2118        let displayed = CellExport::Displayed(DisplayedCells);
2119        let mut buffer = String::new();
2120        for value in [
2121            CellValue::Empty,
2122            CellValue::Bool(true),
2123            CellValue::Bool(false),
2124            CellValue::Int(-42),
2125            CellValue::Float(1234.567),
2126            CellValue::Float(0.1),
2127            CellValue::Float(f64::NAN),
2128            CellValue::Text("tab\there"),
2129        ] {
2130            assert_eq!(
2131                cell_text(&displayed, value),
2132                display_text(value, &mut buffer),
2133                "{value:?}"
2134            );
2135        }
2136
2137        // `Custom` is called once for every exported cell, with its row and
2138        // column, and its text is quoted like any other.
2139        #[derive(Default)]
2140        struct KeyAndColumn {
2141            calls: AtomicUsize,
2142        }
2143
2144        impl CellExporter<Row> for KeyAndColumn {
2145            fn write_cell(&self, cell: &ExportCell<'_, Row>, out: &mut String) {
2146                self.calls.fetch_add(1, Ordering::Relaxed);
2147                let key = cell.row().expect("a data row").key();
2148                let _ = write!(out, "{key}:{}", cell.column().id());
2149                if cell.value() == CellValue::Empty {
2150                    out.push('\t');
2151                }
2152            }
2153        }
2154
2155        let custom = Arc::new(KeyAndColumn::default());
2156        let cells = CellExport::Custom(custom.clone());
2157        let rows = [
2158            Row::new(7, [CellValue::Int(1), CellValue::Empty, CellValue::Int(3)]),
2159            Row::new(9, [CellValue::Int(4); 3]),
2160        ];
2161        let exported = [
2162            ExportRow::new(&rows[0]),
2163            ExportRow::new(&rows[1]).with_columns(columns(&[Column::Price])),
2164        ];
2165        let table = ExportTable::new(&COLUMNS, &exported).with_header(true);
2166        assert_eq!(
2167            tsv(&table, &cells.clone()),
2168            "Symbol\tPrice\t\"Note \"\"short\"\"\"\n\
2169             7:symbol\t\"7:price\t\"\t7:note\n\
2170             \t9:price\t"
2171        );
2172        assert_eq!(custom.calls.load(Ordering::Relaxed), 4);
2173    }
2174
2175    /// A snapshot of `rows`, keyed by their position.
2176    fn snapshot_of(
2177        rows: impl IntoIterator<Item = [CellValue<'static>; 3]>,
2178    ) -> Arc<StoreSnapshot<Row>> {
2179        snapshot_of_rows(
2180            rows.into_iter()
2181                .zip(0..)
2182                .map(|(cells, key)| Row::new(key, cells)),
2183        )
2184    }
2185
2186    /// A snapshot of `rows`.
2187    fn snapshot_of_rows(rows: impl IntoIterator<Item = Row>) -> Arc<StoreSnapshot<Row>> {
2188        let epoch = Instant::now();
2189        let mut ingest = Ingest::new(HighlightPolicy::default(), epoch);
2190        ingest.apply(
2191            rows.into_iter().map(|row| Op::UpsertSilent(Arc::new(row))),
2192            epoch,
2193        );
2194        ingest.snapshot()
2195    }
2196
2197    /// Twelve trades of three symbols. AAPL is two rows, keys 0 and 3, whose
2198    /// prices sum to 1234.5 and one of which has a note; the prices of all
2199    /// sum to 1392, and nine rows have a note.
2200    fn trades() -> Arc<StoreSnapshot<Row>> {
2201        const TRADES: [(&str, f64, &str); 12] = [
2202            ("AAPL", 1000.25, "buy"),
2203            ("MSFT", 10.0, "x"),
2204            ("SBER", 1.5, "y"),
2205            ("AAPL", 234.25, ""),
2206            ("MSFT", 20.0, "x"),
2207            ("SBER", 1.5, ""),
2208            ("MSFT", 30.0, "x"),
2209            ("SBER", 1.5, "y"),
2210            ("MSFT", 40.0, ""),
2211            ("SBER", 1.5, "y"),
2212            ("MSFT", 50.0, "x"),
2213            ("SBER", 1.5, "y"),
2214        ];
2215        snapshot_of(TRADES.map(|(symbol, price, note)| {
2216            [
2217                CellValue::Text(symbol),
2218                CellValue::Float(price),
2219                if note.is_empty() {
2220                    CellValue::Empty
2221                } else {
2222                    CellValue::Text(note)
2223                },
2224            ]
2225        }))
2226    }
2227
2228    /// `snapshot` grouped by `levels` with `aggregates`, every group with a
2229    /// footer.
2230    fn grouped(
2231        snapshot: &StoreSnapshot<Row>,
2232        levels: &[Column],
2233        aggregates: impl IntoIterator<Item = (Column, Aggregate<Row>)>,
2234    ) -> Projection {
2235        let spec = ViewSpec::new()
2236            .with_group_by(levels.iter().map(|&level| GroupBy::column(level)))
2237            .with_aggregates(aggregates)
2238            .with_group_footer(EveryGroupFooter);
2239        Rebuild::default()
2240            .with_max_threads(1)
2241            .rebuild(snapshot, &spec)
2242    }
2243
2244    /// The top-level group whose key is `value`.
2245    fn group<'a>(projection: &'a Projection, value: &str) -> GroupInfo<'a> {
2246        projection
2247            .groups()
2248            .iter()
2249            .find(|group| group.depth() == 0 && group.value() == &OwnedValue::Text(value.into()))
2250            .expect("the group")
2251    }
2252
2253    /// The caption of AAPL, its two rows, its footer and the totals labelled
2254    /// in the symbol column.
2255    fn aapl_lines<'a>(
2256        snapshot: &'a StoreSnapshot<Row>,
2257        projection: &'a Projection,
2258    ) -> Vec<ExportRow<'a, Row>> {
2259        let aapl = group(projection, "AAPL");
2260        let mut rows = vec![ExportRow::caption(Column::Symbol, aapl)];
2261        rows.extend(
2262            aapl.rows()
2263                .iter()
2264                .map(|&id| ExportRow::new(&**snapshot.row(id).unwrap()).with_depth(1)),
2265        );
2266        rows.push(ExportRow::footer(aapl));
2267        rows.push(ExportRow::totals(
2268            projection.totals(),
2269            Some((Column::Symbol, "Total")),
2270        ));
2271        rows
2272    }
2273
2274    const SUMMARY_COLUMNS: [ExportColumn<'static, Column>; 3] = [
2275        ExportColumn::new(Column::Symbol, "Symbol"),
2276        ExportColumn::new(Column::Price, "Price"),
2277        ExportColumn::new(Column::Note, "Note"),
2278    ];
2279
2280    /// Each column's cell of `row`: its value, and whether it is an
2281    /// aggregate.
2282    fn cells_of<'a>(row: &ExportRow<'a, Row>) -> Vec<Option<(CellValue<'a>, bool)>> {
2283        Column::all()
2284            .iter()
2285            .map(|&column| {
2286                row.cell(column)
2287                    .map(|cell| (cell.value(), cell.is_aggregate()))
2288            })
2289            .collect()
2290    }
2291
2292    #[test]
2293    fn test_export_summary_cells() {
2294        use CellValue::{Empty, Float, Int, Text};
2295
2296        let snapshot = trades();
2297        let projection = grouped(
2298            &snapshot,
2299            &[Column::Symbol],
2300            [
2301                (Column::Symbol, Aggregate::Count),
2302                (Column::Price, Aggregate::Sum),
2303                // A sum of text: an empty aggregate.
2304                (Column::Note, Aggregate::Sum),
2305            ],
2306        );
2307        let aapl = group(&projection, "AAPL");
2308
2309        // A caption: the key value in its column, over that column's
2310        // aggregate; the other aggregates in theirs; an empty aggregate is a
2311        // cell with an empty value.
2312        let caption = ExportRow::caption(Column::Symbol, aapl);
2313        assert_eq!(caption.line(), ExportLine::Caption);
2314        assert!(caption.row().is_none());
2315        assert_eq!(caption.group().map(|group| group.id()), Some(aapl.id()));
2316        assert_eq!(caption.depth(), 0);
2317        assert_eq!(
2318            cells_of(&caption),
2319            [
2320                Some((Text("AAPL"), false)),
2321                Some((Float(1234.5), true)),
2322                Some((Empty, true)),
2323            ]
2324        );
2325        // The key value in another column, as when the group's column is
2326        // hidden: the group's column shows its aggregate.
2327        assert_eq!(
2328            cells_of(&ExportRow::caption(Column::Price, aapl)),
2329            [
2330                Some((Int(2), true)),
2331                Some((Text("AAPL"), false)),
2332                Some((Empty, true)),
2333            ]
2334        );
2335        // `with_columns` narrows a summary row as it does a data row.
2336        assert_eq!(
2337            cells_of(&caption.with_columns(columns(&[Column::Price]))),
2338            [None, Some((Float(1234.5), true)), None]
2339        );
2340
2341        // A footer: the group's aggregates, at the group's depth.
2342        let footer = ExportRow::footer(aapl);
2343        assert_eq!(footer.line(), ExportLine::Footer);
2344        assert!(footer.row().is_none());
2345        assert_eq!(footer.group().map(|group| group.id()), Some(aapl.id()));
2346        assert_eq!(footer.depth(), 0);
2347        assert_eq!(
2348            cells_of(&footer),
2349            [
2350                Some((Int(2), true)),
2351                Some((Float(1234.5), true)),
2352                Some((Empty, true)),
2353            ]
2354        );
2355
2356        // The totals: an aggregate wins over the label.
2357        let totals = ExportRow::totals(projection.totals(), Some((Column::Symbol, "Total")));
2358        assert_eq!(totals.line(), ExportLine::Totals);
2359        assert!(totals.row().is_none() && totals.group().is_none());
2360        assert_eq!(
2361            cells_of(&totals),
2362            [
2363                Some((Int(12), true)),
2364                Some((Float(1392.0), true)),
2365                Some((Empty, true)),
2366            ]
2367        );
2368
2369        // Columns without an aggregate have no summary cell, except the
2370        // caption's key value and the totals label.
2371        let summed = grouped(
2372            &snapshot,
2373            &[Column::Symbol],
2374            [(Column::Price, Aggregate::Sum)],
2375        );
2376        let aapl = group(&summed, "AAPL");
2377        assert_eq!(
2378            cells_of(&ExportRow::caption(Column::Symbol, aapl)),
2379            [
2380                Some((Text("AAPL"), false)),
2381                Some((Float(1234.5), true)),
2382                None,
2383            ]
2384        );
2385        assert_eq!(
2386            cells_of(&ExportRow::footer(aapl)),
2387            [None, Some((Float(1234.5), true)), None]
2388        );
2389        for (label, expected) in [
2390            (
2391                Some((Column::Symbol, "Total")),
2392                Some((Text("Total"), false)),
2393            ),
2394            (Some((Column::Price, "Total")), None),
2395            (None, None),
2396        ] {
2397            assert_eq!(
2398                cells_of(&ExportRow::totals(summed.totals(), label)),
2399                [expected, Some((Float(1392.0), true)), None],
2400                "{label:?}"
2401            );
2402        }
2403
2404        // A data row: the application row and its values, at depth 0 until
2405        // set.
2406        let trade = &**snapshot.row(aapl.rows()[0]).unwrap();
2407        let data = ExportRow::new(trade);
2408        assert_eq!(data.line(), ExportLine::Data);
2409        assert!(data.row().is_some_and(|row| std::ptr::eq(row, trade)));
2410        assert!(data.group().is_none());
2411        assert_eq!(data.depth(), 0);
2412        assert_eq!(data.with_depth(1).depth(), 1);
2413        assert_eq!(
2414            cells_of(&data),
2415            [
2416                Some((Text("AAPL"), false)),
2417                Some((Float(1000.25), false)),
2418                Some((Text("buy"), false)),
2419            ]
2420        );
2421
2422        // A subgroup's caption and footer are at its depth, and their cells
2423        // know it.
2424        let nested = grouped(&snapshot, &[Column::Symbol, Column::Note], []);
2425        let subgroup = nested
2426            .groups()
2427            .iter()
2428            .find(|group| group.depth() == 1)
2429            .expect("a subgroup");
2430        let caption = ExportRow::<Row>::caption(Column::Note, subgroup);
2431        assert_eq!(caption.depth(), 1);
2432        assert_eq!(caption.cell(Column::Note).map(|cell| cell.depth()), Some(1));
2433        assert_eq!(ExportRow::<Row>::footer(subgroup).depth(), 1);
2434        assert_eq!(caption.with_depth(3).depth(), 3);
2435    }
2436
2437    #[test]
2438    fn test_tsv_with_summaries() {
2439        let snapshot = trades();
2440        let projection = grouped(
2441            &snapshot,
2442            &[Column::Symbol],
2443            [
2444                (Column::Price, Aggregate::Sum),
2445                (Column::Note, Aggregate::Count),
2446            ],
2447        );
2448        let rows = aapl_lines(&snapshot, &projection);
2449        let table = ExportTable::new(&SUMMARY_COLUMNS, &rows).with_header(true);
2450
2451        // Fields without a cell are empty; aggregates are raw, or as on
2452        // screen; the totals label is in the first column.
2453        let raw = CellExport::default();
2454        assert_eq!(
2455            tsv(&table, &raw),
2456            "Symbol\tPrice\tNote\n\
2457             AAPL\t1234.50\t1\n\
2458             AAPL\t1000.25\tbuy\n\
2459             AAPL\t234.25\t\n\
2460             \t1234.50\t1\n\
2461             Total\t1392.00\t9"
2462        );
2463        assert_eq!(
2464            tsv(&table, &CellExport::Displayed(DisplayedCells)),
2465            "Symbol\tPrice\tNote\n\
2466             AAPL\t1234.50\t1\n\
2467             AAPL\t1000.25\tbuy\n\
2468             AAPL\t234.25\t\n\
2469             \t1234.50\t1\n\
2470             Total\t1392.00\t9"
2471        );
2472        // The decimal separator is an aggregate's too.
2473        let comma = CellExport::Raw(RawCells::new().with_decimal_separator(','));
2474        assert!(tsv(&table, &comma).ends_with("\n\t1234,50\t1\nTotal\t1392,00\t9"));
2475
2476        // Without a header line, the first line is the caption.
2477        assert!(
2478            tsv(&ExportTable::new(&SUMMARY_COLUMNS, &rows), &raw)
2479                .starts_with("AAPL\t1234.50\t1\nAAPL\t1000.25\tbuy\n")
2480        );
2481
2482        // A key value is a value: `Bool` raw as `TRUE`, as on screen "✓";
2483        // one that needs quotes is quoted.
2484        let snapshot = snapshot_of([
2485            [
2486                CellValue::Text("a\tb"),
2487                CellValue::Int(1),
2488                CellValue::Bool(true),
2489            ],
2490            [
2491                CellValue::Text("a\tb"),
2492                CellValue::Int(2),
2493                CellValue::Bool(false),
2494            ],
2495        ]);
2496        let by_flag = grouped(&snapshot, &[Column::Note, Column::Symbol], []);
2497        let captions: Vec<_> = by_flag
2498            .groups()
2499            .iter()
2500            .map(|group| {
2501                let value_column = if group.depth() == 0 {
2502                    Column::Note
2503                } else {
2504                    Column::Symbol
2505                };
2506                ExportRow::caption(value_column, group)
2507            })
2508            .collect();
2509        let table = ExportTable::new(&SUMMARY_COLUMNS, &captions);
2510        assert_eq!(
2511            tsv(&table, &raw),
2512            "\t\tFALSE\n\"a\tb\"\t\t\n\t\tTRUE\n\"a\tb\"\t\t"
2513        );
2514        assert_eq!(
2515            tsv(&table, &CellExport::Displayed(DisplayedCells)),
2516            "\t\t\n\"a\tb\"\t\t\n\t\t✓\n\"a\tb\"\t\t"
2517        );
2518    }
2519
2520    #[test]
2521    fn test_line_filter() {
2522        assert_eq!(ExportLines::default(), ExportLines::ALL);
2523        for line in ExportLine::ALL {
2524            assert!(ExportLines::ALL.contains(line));
2525            assert!(!ExportLines::EMPTY.contains(line));
2526            assert_eq!(ExportLines::ALL.without(line).with(line), ExportLines::ALL);
2527            assert!(!ExportLines::ALL.without(line).contains(line));
2528        }
2529        assert_eq!(
2530            format!(
2531                "{:?}",
2532                ExportLines::EMPTY
2533                    .with(ExportLine::Totals)
2534                    .with(ExportLine::Data)
2535            ),
2536            "{Data, Totals}"
2537        );
2538
2539        let snapshot = trades();
2540        let projection = grouped(
2541            &snapshot,
2542            &[Column::Symbol],
2543            [
2544                (Column::Price, Aggregate::Sum),
2545                (Column::Note, Aggregate::Count),
2546            ],
2547        );
2548        let rows = aapl_lines(&snapshot, &projection);
2549        let table = ExportTable::new(&SUMMARY_COLUMNS, &rows).with_header(true);
2550        let raw = CellExport::default();
2551        let filtered = |lines: ExportLines| {
2552            let filter = LineFilter::new(TsvExporter, lines);
2553            assert_eq!(filter.lines(), lines);
2554            let mut out = String::new();
2555            Exporter::export(&filter, &table, &raw, &mut out);
2556            out
2557        };
2558
2559        // Every line: the table as it is.
2560        assert_eq!(filtered(ExportLines::ALL), tsv(&table, &raw));
2561        // Without the footers and the totals; the application's rows alone;
2562        // the summaries alone; nothing but the header line.
2563        assert_eq!(
2564            filtered(
2565                ExportLines::ALL
2566                    .without(ExportLine::Footer)
2567                    .without(ExportLine::Totals)
2568            ),
2569            "Symbol\tPrice\tNote\nAAPL\t1234.50\t1\nAAPL\t1000.25\tbuy\nAAPL\t234.25\t"
2570        );
2571        assert_eq!(
2572            filtered(ExportLines::EMPTY.with(ExportLine::Data)),
2573            "Symbol\tPrice\tNote\nAAPL\t1000.25\tbuy\nAAPL\t234.25\t"
2574        );
2575        assert_eq!(
2576            filtered(ExportLines::ALL.without(ExportLine::Data)),
2577            "Symbol\tPrice\tNote\nAAPL\t1234.50\t1\n\t1234.50\t1\nTotal\t1392.00\t9"
2578        );
2579        assert_eq!(filtered(ExportLines::EMPTY), "Symbol\tPrice\tNote");
2580
2581        // The wrapped exporter gets the table's columns and header line.
2582        struct Shape;
2583
2584        impl Exporter<Row> for Shape {
2585            fn export(&self, table: &ExportTable<'_, Row>, _: &CellExport<Row>, out: &mut String) {
2586                let lines: Vec<_> = table.rows().iter().map(ExportRow::line).collect();
2587                let _ = write!(
2588                    out,
2589                    "{} {} {lines:?}",
2590                    table.columns().len(),
2591                    table.has_header()
2592                );
2593            }
2594        }
2595
2596        let filter = LineFilter::new(Shape, ExportLines::EMPTY.with(ExportLine::Caption));
2597        let mut out = String::new();
2598        Exporter::export(&filter, &table, &raw, &mut out);
2599        assert_eq!(out, "3 true [Caption]");
2600        let _: &Shape = filter.exporter();
2601    }
2602
2603    #[test]
2604    fn test_export_cell_row_is_optional() {
2605        /// Records each cell's row key, line, depth, whether it is an
2606        /// aggregate and its group; writes raw values with the row count
2607        /// after a caption's key value.
2608        #[derive(Default)]
2609        struct Seen(Mutex<Vec<(Option<u32>, ExportLine, u16, bool, Option<GroupId>)>>);
2610
2611        impl CellExporter<Row> for Seen {
2612            fn write_cell(&self, cell: &ExportCell<'_, Row>, out: &mut String) {
2613                self.0.lock().unwrap().push((
2614                    cell.row().map(|row| row.key),
2615                    cell.line(),
2616                    cell.depth(),
2617                    cell.is_aggregate(),
2618                    cell.group().map(|group| group.id()),
2619                ));
2620                RawCells::new().write_cell(cell, out);
2621                if cell.line() == ExportLine::Caption
2622                    && !cell.is_aggregate()
2623                    && let Some(group) = cell.group()
2624                {
2625                    let _ = write!(out, " ({})", group.row_count());
2626                }
2627            }
2628        }
2629
2630        let snapshot = trades();
2631        let projection = grouped(
2632            &snapshot,
2633            &[Column::Symbol],
2634            [(Column::Price, Aggregate::Sum)],
2635        );
2636        let rows = aapl_lines(&snapshot, &projection);
2637        let aapl = group(&projection, "AAPL").id();
2638        let seen = Arc::new(Seen::default());
2639        let table = ExportTable::new(&SUMMARY_COLUMNS, &rows);
2640        assert_eq!(
2641            tsv(&table, &CellExport::Custom(seen.clone())),
2642            "AAPL (2)\t1234.50\t\n\
2643             AAPL\t1000.25\tbuy\n\
2644             AAPL\t234.25\t\n\
2645             \t1234.50\t\n\
2646             Total\t1392.00\t"
2647        );
2648        use ExportLine::{Caption, Data, Footer, Totals};
2649        assert_eq!(
2650            *seen.0.lock().unwrap(),
2651            [
2652                (None, Caption, 0, false, Some(aapl)),
2653                (None, Caption, 0, true, Some(aapl)),
2654                (Some(0), Data, 1, false, None),
2655                (Some(0), Data, 1, false, None),
2656                (Some(0), Data, 1, false, None),
2657                (Some(3), Data, 1, false, None),
2658                (Some(3), Data, 1, false, None),
2659                (Some(3), Data, 1, false, None),
2660                (None, Footer, 0, true, Some(aapl)),
2661                (None, Totals, 0, false, None),
2662                (None, Totals, 0, true, None),
2663            ]
2664        );
2665    }
2666
2667    #[test]
2668    fn test_export_scope_variants() {
2669        // Exhaustive matches: a new scope or line fails to build here, in
2670        // the crate, where `#[non_exhaustive]` does not apply.
2671        fn scope_name(scope: ExportScope) -> &'static str {
2672            match scope {
2673                ExportScope::Visible => "Visible",
2674                ExportScope::Selection => "Selection",
2675                ExportScope::Expanded => "Expanded",
2676            }
2677        }
2678        fn line_ix(line: ExportLine) -> usize {
2679            match line {
2680                ExportLine::Data => 0,
2681                ExportLine::Caption => 1,
2682                ExportLine::Footer => 2,
2683                ExportLine::Totals => 3,
2684            }
2685        }
2686
2687        let scopes = [
2688            ExportScope::Visible,
2689            ExportScope::Selection,
2690            ExportScope::Expanded,
2691        ];
2692        for (ix, scope) in scopes.into_iter().enumerate() {
2693            assert_eq!(format!("{scope:?}"), scope_name(scope));
2694            for (other_ix, other) in scopes.into_iter().enumerate() {
2695                assert_eq!(scope == other, ix == other_ix, "{scope:?} {other:?}");
2696            }
2697        }
2698        // `ExportLines::ALL` lists every line.
2699        for (ix, line) in ExportLine::ALL.into_iter().enumerate() {
2700            assert_eq!(line_ix(line), ix);
2701        }
2702    }
2703
2704    #[cfg(feature = "decimal")]
2705    #[test]
2706    fn test_decimal_group_value() {
2707        let price = rust_decimal::Decimal::new(1230, 2);
2708        let snapshot = snapshot_of([[
2709            CellValue::Text("AAPL"),
2710            CellValue::Decimal(price),
2711            CellValue::Empty,
2712        ]]);
2713        let projection = grouped(
2714            &snapshot,
2715            &[Column::Price],
2716            [(Column::Symbol, Aggregate::Count)],
2717        );
2718        let caption = [ExportRow::caption(
2719            Column::Price,
2720            projection.groups().iter().next().expect("a group"),
2721        )];
2722        let table = ExportTable::new(&SUMMARY_COLUMNS, &caption);
2723        assert_eq!(tsv(&table, &CellExport::default()), "1\t12.30\t");
2724        let comma = CellExport::Raw(RawCells::new().with_decimal_separator(','));
2725        assert_eq!(tsv(&table, &comma), "1\t12,30\t");
2726        assert_eq!(
2727            tsv(&table, &CellExport::Displayed(DisplayedCells)),
2728            "1\t12.30\t"
2729        );
2730    }
2731
2732    /// The bytes `format` writes for `table`; its progress reaches the
2733    /// table's rows.
2734    fn file(
2735        format: &dyn ExportFormat<Row>,
2736        table: &ExportTable<'_, Row>,
2737        cells: &CellExport<Row>,
2738    ) -> Vec<u8> {
2739        let control = ExportControl::new(table.rows().len());
2740        let mut out = Vec::new();
2741        format
2742            .export(table, cells, &mut out, &control)
2743            .expect("the export");
2744        assert_eq!(control.rows_written(), control.rows_total());
2745        out
2746    }
2747
2748    /// The text `format` writes for `table`.
2749    fn file_text(
2750        format: &dyn ExportFormat<Row>,
2751        table: &ExportTable<'_, Row>,
2752        cells: &CellExport<Row>,
2753    ) -> String {
2754        String::from_utf8(file(format, table, cells)).expect("UTF-8")
2755    }
2756
2757    #[test]
2758    fn test_csv_rfc4180() {
2759        use CellValue::{Bool, Empty, Float, Int, Text};
2760
2761        let rows = [
2762            Row::new(1, [Text("a,b"), Float(1234.5), Text("say \"hi\"")]),
2763            Row::new(2, [Text("two\nlines"), Bool(true), Text("cr\rhere")]),
2764            Row::new(3, [Text(" padded "), Empty, Text("semi;colon")]),
2765            Row::new(4, [Text("plain"), Int(-3), Bool(false)]),
2766        ];
2767        let all: Vec<_> = rows.iter().map(ExportRow::new).collect();
2768        let table = ExportTable::new(&COLUMNS, &all).with_header(true);
2769        let raw = CellExport::default();
2770
2771        let format = CsvFormat::new();
2772        assert_eq!(format, CsvFormat::default());
2773        assert_eq!(format.separator(), ',');
2774        assert_eq!(format.encoding(), CsvEncoding::Utf8WithBom);
2775        assert_eq!(CsvEncoding::default(), CsvEncoding::Utf8WithBom);
2776        assert_eq!(ExportFormat::<Row>::extension(&format), "csv");
2777
2778        // A byte order mark, then quotes where a field holds the separator, a
2779        // quote, CR or LF; spaces, empty fields and raw values without; CRLF
2780        // after every line, the last included.
2781        let expected = "Symbol,Price,\"Note \"\"short\"\"\"\r\n\
2782                        \"a,b\",1234.50,\"say \"\"hi\"\"\"\r\n\
2783                        \"two\nlines\",TRUE,\"cr\rhere\"\r\n\
2784                        \x20padded ,,semi;colon\r\n\
2785                        plain,-3,FALSE\r\n";
2786        let with_mark = file(&format, &table, &raw);
2787        assert_eq!(with_mark[..3], [0xEF, 0xBB, 0xBF]);
2788        assert_eq!(with_mark[3..], *expected.as_bytes());
2789        let utf8 = format.with_encoding(CsvEncoding::Utf8);
2790        assert_eq!(utf8.encoding(), CsvEncoding::Utf8);
2791        assert_eq!(file_text(&utf8, &table, &raw), expected);
2792
2793        // `;` with a decimal comma: `1234,50` needs no quotes, `semi;colon`
2794        // does, and `a,b` no longer.
2795        let semicolon = utf8.with_separator(';');
2796        assert_eq!(semicolon.separator(), ';');
2797        let comma = CellExport::Raw(RawCells::new().with_decimal_separator(','));
2798        assert_eq!(
2799            file_text(&semicolon, &table, &comma),
2800            "Symbol;Price;\"Note \"\"short\"\"\"\r\n\
2801             a,b;1234,50;\"say \"\"hi\"\"\"\r\n\
2802             \"two\nlines\";TRUE;\"cr\rhere\"\r\n\
2803             \x20padded ;;\"semi;colon\"\r\n\
2804             plain;-3;FALSE\r\n"
2805        );
2806
2807        // Cells as the screen shows them; a cell the row does not export is
2808        // an empty field.
2809        let narrowed = [
2810            ExportRow::new(&rows[0]).with_columns(columns(&[Column::Price])),
2811            ExportRow::new(&rows[3]),
2812        ];
2813        assert_eq!(
2814            file_text(
2815                &utf8,
2816                &ExportTable::new(&COLUMNS, &narrowed),
2817                &CellExport::Displayed(DisplayedCells)
2818            ),
2819            ",1234.50,\r\nplain,-3,\r\n"
2820        );
2821
2822        // The header line alone; no line at all, only the mark.
2823        let header_only = ExportTable::new(&COLUMNS[..2], &[]).with_header(true);
2824        assert_eq!(file_text(&utf8, &header_only, &raw), "Symbol,Price\r\n");
2825        assert_eq!(
2826            file(&format, &ExportTable::new(&COLUMNS, &[]), &raw),
2827            [0xEF, 0xBB, 0xBF]
2828        );
2829
2830        // A separator beyond ASCII quotes as any other.
2831        let mut field = String::from("x¦a¦b");
2832        quote_field(&mut field, 3, '¦', &mut String::new());
2833        assert_eq!(field, "x¦\"a¦b\"");
2834        let mut field = String::from("a|b");
2835        quote_field(&mut field, 0, '¦', &mut String::new());
2836        assert_eq!(field, "a|b");
2837    }
2838
2839    #[test]
2840    #[should_panic(expected = "a CSV separator cannot be a quote or a line break")]
2841    fn test_csv_separator_is_not_a_quote() {
2842        let _ = CsvFormat::new().with_separator('"');
2843    }
2844
2845    #[test]
2846    fn test_tsv_file_matches_clipboard() {
2847        use CellValue::{Bool, Empty, Float, Int, Text};
2848
2849        let rows = [
2850            Row::new(1, [Text("AAPL"), Float(187.25), Text("tab\there")]),
2851            Row::new(2, [Text("say \"hi\""), Int(-3), Text("two\nlines\r")]),
2852            Row::new(3, [Empty, Bool(true), Empty]),
2853        ];
2854        let all: Vec<_> = rows.iter().map(ExportRow::new).collect();
2855        let rectangle = [
2856            ExportRow::new(&rows[0]).with_columns(columns(&[Column::Symbol])),
2857            ExportRow::new(&rows[1]).with_columns(columns(&[Column::Note])),
2858        ];
2859        let snapshot = trades();
2860        let projection = grouped(
2861            &snapshot,
2862            &[Column::Symbol],
2863            [
2864                (Column::Price, Aggregate::Sum),
2865                (Column::Note, Aggregate::Count),
2866            ],
2867        );
2868        let summaries = aapl_lines(&snapshot, &projection);
2869        let tables = [
2870            ExportTable::new(&COLUMNS, &all).with_header(true),
2871            ExportTable::new(&COLUMNS, &all),
2872            ExportTable::new(&COLUMNS, &rectangle),
2873            ExportTable::new(&SUMMARY_COLUMNS, &summaries).with_header(true),
2874            ExportTable::new(&SUMMARY_COLUMNS, &summaries),
2875            ExportTable::new(&COLUMNS[..2], &[]).with_header(true),
2876            ExportTable::new(&COLUMNS, &[]),
2877        ];
2878        assert_eq!(ExportFormat::<Row>::extension(&TsvExporter), "tsv");
2879        for cells in [
2880            CellExport::default(),
2881            CellExport::Displayed(DisplayedCells),
2882            CellExport::Raw(RawCells::new().with_decimal_separator(',')),
2883        ] {
2884            for table in &tables {
2885                // The clipboard's text as UTF-8: no mark, `\n` between lines.
2886                assert_eq!(
2887                    file(&TsvExporter, table, &cells),
2888                    tsv(table, &cells).as_bytes()
2889                );
2890            }
2891        }
2892        assert!(file(&TsvExporter, &tables[5], &CellExport::default()) == b"Symbol\tPrice");
2893    }
2894
2895    /// A sink that records the rows its control counted at each write.
2896    struct ProgressSink {
2897        control: ExportControl,
2898        bytes: Vec<u8>,
2899        writes: Vec<usize>,
2900    }
2901
2902    impl ProgressSink {
2903        fn new(control: &ExportControl) -> Self {
2904            Self {
2905                control: control.clone(),
2906                bytes: Vec::new(),
2907                writes: Vec::new(),
2908            }
2909        }
2910    }
2911
2912    impl io::Write for ProgressSink {
2913        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
2914            self.writes.push(self.control.rows_written());
2915            self.bytes.extend_from_slice(bytes);
2916            Ok(bytes.len())
2917        }
2918
2919        fn flush(&mut self) -> io::Result<()> {
2920            Ok(())
2921        }
2922    }
2923
2924    #[test]
2925    fn test_formats_report_progress_and_stop() {
2926        // A control: clones share the progress and the cancellation.
2927        let control = ExportControl::new(10);
2928        assert_eq!((control.rows_total(), control.rows_written()), (10, 0));
2929        assert!(control.advance(4).is_ok());
2930        let shared = control.clone();
2931        assert_eq!(shared.rows_written(), 4);
2932        assert!(!control.is_cancelled());
2933        shared.cancel();
2934        assert!(control.is_cancelled());
2935        assert!(matches!(control.advance(0), Err(ExportError::Cancelled)));
2936
2937        let rows: Vec<Row> = (0..3000)
2938            .map(|key| {
2939                Row::new(
2940                    key,
2941                    [
2942                        CellValue::Int(key.into()),
2943                        CellValue::Float(f64::from(key) / 4.0),
2944                        CellValue::Text("x"),
2945                    ],
2946                )
2947            })
2948            .collect();
2949        let lines: Vec<_> = rows.iter().map(ExportRow::new).collect();
2950        let table = ExportTable::new(&COLUMNS, &lines).with_header(true);
2951        let raw = CellExport::default();
2952
2953        // (format, line break, line breaks with the header and 1024 rows)
2954        let csv = CsvFormat::new();
2955        let formats: [(&dyn ExportFormat<Row>, &str, usize); 2] =
2956            [(&csv, "\r\n", 1025), (&TsvExporter, "\n", 1024)];
2957        for (format, line_break, first_buffer_breaks) in formats {
2958            let name = format.extension();
2959            let control = ExportControl::new(table.rows().len());
2960            let mut sink = ProgressSink::new(&control);
2961            format
2962                .export(&table, &raw, &mut sink, &control)
2963                .expect("the export");
2964            // A write every 1024 rows, each counted before the next.
2965            assert_eq!(sink.writes, [0, 1024, 2048], "{name}");
2966            assert_eq!(control.rows_written(), control.rows_total(), "{name}");
2967            let whole = sink.bytes;
2968            assert_eq!(whole, file(format, &table, &raw), "{name}");
2969
2970            // Cancelled before it starts: one buffer written, then
2971            // `Cancelled`.
2972            let control = ExportControl::new(table.rows().len());
2973            control.cancel();
2974            let mut sink = ProgressSink::new(&control);
2975            assert!(
2976                matches!(
2977                    format.export(&table, &raw, &mut sink, &control),
2978                    Err(ExportError::Cancelled)
2979                ),
2980                "{name}"
2981            );
2982            assert_eq!(sink.writes, [0], "{name}");
2983            assert!(whole.starts_with(&sink.bytes), "{name}");
2984            let text = String::from_utf8(sink.bytes).expect("UTF-8");
2985            assert_eq!(
2986                text.matches(line_break).count(),
2987                first_buffer_breaks,
2988                "{name}"
2989            );
2990        }
2991
2992        // A text file is written whole: progress jumps from 0 to done, and a
2993        // cancelled one writes nothing.
2994        let text_file = TextFile::new(TsvExporter, "tsv");
2995        let control = ExportControl::new(table.rows().len());
2996        let mut sink = ProgressSink::new(&control);
2997        text_file
2998            .export(&table, &raw, &mut sink, &control)
2999            .expect("the export");
3000        assert_eq!(sink.writes, [0]);
3001        assert_eq!(control.rows_written(), 3000);
3002        let control = ExportControl::new(table.rows().len());
3003        control.cancel();
3004        let mut sink = ProgressSink::new(&control);
3005        assert!(matches!(
3006            text_file.export(&table, &raw, &mut sink, &control),
3007            Err(ExportError::Cancelled)
3008        ));
3009        assert!(sink.bytes.is_empty());
3010
3011        // A failing sink: the error comes back as `Io`.
3012        struct Full;
3013
3014        impl io::Write for Full {
3015            fn write(&mut self, _: &[u8]) -> io::Result<usize> {
3016                Err(io::Error::new(io::ErrorKind::StorageFull, "disk full"))
3017            }
3018
3019            fn flush(&mut self) -> io::Result<()> {
3020                Ok(())
3021            }
3022        }
3023
3024        let control = ExportControl::new(table.rows().len());
3025        let error = CsvFormat::new()
3026            .export(&table, &raw, &mut Full, &control)
3027            .expect_err("a full disk");
3028        assert!(
3029            matches!(&error, ExportError::Io(error) if error.kind() == io::ErrorKind::StorageFull)
3030        );
3031        assert_eq!(control.rows_written(), 0);
3032    }
3033
3034    #[test]
3035    fn test_text_file_and_line_filter() {
3036        let snapshot = trades();
3037        let projection = grouped(
3038            &snapshot,
3039            &[Column::Symbol],
3040            [
3041                (Column::Price, Aggregate::Sum),
3042                (Column::Note, Aggregate::Count),
3043            ],
3044        );
3045        let rows = aapl_lines(&snapshot, &projection);
3046        let table = ExportTable::new(&SUMMARY_COLUMNS, &rows).with_header(true);
3047        let raw = CellExport::default();
3048
3049        // Any clipboard exporter, saved under its extension.
3050        let text_file = TextFile::new(TsvExporter, "tsv");
3051        let _: &TsvExporter = text_file.exporter();
3052        assert_eq!(ExportFormat::<Row>::extension(&text_file), "tsv");
3053        assert_eq!(file(&text_file, &table, &raw), tsv(&table, &raw).as_bytes());
3054        let data_only = TextFile::new(
3055            LineFilter::new(TsvExporter, ExportLines::EMPTY.with(ExportLine::Data)),
3056            String::from("txt"),
3057        );
3058        assert_eq!(ExportFormat::<Row>::extension(&data_only), "txt");
3059        assert_eq!(
3060            file_text(&data_only, &table, &raw),
3061            "Symbol\tPrice\tNote\nAAPL\t1000.25\tbuy\nAAPL\t234.25\t"
3062        );
3063
3064        // A filter over a file format keeps its extension, and its progress
3065        // counts the rows left out (`file` checks it).
3066        let utf8 = CsvFormat::new().with_encoding(CsvEncoding::Utf8);
3067        let without_footers = LineFilter::new(utf8, ExportLines::ALL.without(ExportLine::Footer));
3068        assert_eq!(ExportFormat::<Row>::extension(&without_footers), "csv");
3069        assert_eq!(
3070            file_text(&without_footers, &table, &raw),
3071            "Symbol,Price,Note\r\n\
3072             AAPL,1234.50,1\r\n\
3073             AAPL,1000.25,buy\r\n\
3074             AAPL,234.25,\r\n\
3075             Total,1392.00,9\r\n"
3076        );
3077        // Every line: the table as it is; no line: the header line alone.
3078        assert_eq!(
3079            file(&LineFilter::new(utf8, ExportLines::ALL), &table, &raw),
3080            file(&utf8, &table, &raw)
3081        );
3082        assert_eq!(
3083            file_text(&LineFilter::new(utf8, ExportLines::EMPTY), &table, &raw),
3084            "Symbol,Price,Note\r\n"
3085        );
3086    }
3087
3088    #[test]
3089    fn test_export_metadata() {
3090        use crate::engine::TreeSpec;
3091
3092        // A column without metadata.
3093        let price = ExportColumn::new(Column::Price, "Price");
3094        assert_eq!(price.width(), None);
3095        assert_eq!(price.align(), ColumnAlign::Auto);
3096        assert_eq!(price.zone(), ColumnZone::Scrolling);
3097        assert!(price.bands().is_empty());
3098
3099        // And with it.
3100        let bands = ["Market", "Quote"];
3101        let price = price
3102            .with_width(140.0)
3103            .with_align(ColumnAlign::End)
3104            .with_zone(ColumnZone::FrozenLeft)
3105            .with_bands(&bands);
3106        assert_eq!(price.width(), Some(140.0));
3107        assert_eq!(price.align(), ColumnAlign::End);
3108        assert_eq!(price.zone(), ColumnZone::FrozenLeft);
3109        assert_eq!(price.bands(), ["Market", "Quote"]);
3110        assert_eq!((price.column(), price.title()), (Column::Price, "Price"));
3111        assert_ne!(price, ExportColumn::new(Column::Price, "Price"));
3112
3113        // Only the leading `FrozenLeft` columns freeze; the deepest band
3114        // sets the header rows over the titles.
3115        let symbol = ExportColumn::new(Column::Symbol, "Symbol").with_zone(ColumnZone::FrozenLeft);
3116        let note = ExportColumn::new(Column::Note, "Note").with_bands(&bands[..1]);
3117        let metadata = [symbol, price, note, symbol];
3118        let table = ExportTable::<Row>::new(&metadata, &[]);
3119        assert_eq!(table.frozen_columns(), 2);
3120        assert_eq!(table.band_depth(), 2);
3121        let right = note.with_zone(ColumnZone::FrozenRight);
3122        for (columns, frozen, depth) in [
3123            (&[note, symbol][..], 0, 1),
3124            (&[symbol, right][..], 1, 1),
3125            (&COLUMNS[..], 0, 0),
3126            (&[][..], 0, 0),
3127        ] {
3128            let table = ExportTable::<Row>::new(columns, &[]);
3129            assert_eq!(table.frozen_columns(), frozen, "{columns:?}");
3130            assert_eq!(table.band_depth(), depth, "{columns:?}");
3131        }
3132
3133        // Rows carry no marks until set.
3134        let snapshot = snapshot_of_rows([
3135            Row::new(0, [CellValue::Text("Book"); 3]),
3136            Row::new(1, [CellValue::Text("Desk"); 3]).with_parent(0),
3137            Row::new(2, [CellValue::Text("Trader"); 3]).with_parent(1),
3138            Row::new(3, [CellValue::Text("Trader"); 3]).with_parent(1),
3139        ]);
3140        let tree = Rebuild::default().with_max_threads(1).rebuild(
3141            &snapshot,
3142            &ViewSpec::new().with_tree(TreeSpec::new().with_column(Column::Symbol)),
3143        );
3144        let row = |key: u32| &**snapshot.get(&key).expect("the row");
3145        let branch = |key: u32| tree.branch(snapshot.row_id(&key).expect("the row"));
3146        let desk = branch(1).expect("a branch");
3147        assert_eq!(desk.child_count(), 2);
3148        assert_eq!(branch(2), None);
3149
3150        let data = ExportRow::new(row(1));
3151        assert_eq!(data.band(), None);
3152        assert!(!data.is_hidden());
3153        assert_eq!(data.branch(), None);
3154        let marked = data
3155            .with_band(PinnedEdge::Top)
3156            .with_hidden(true)
3157            .with_branch(desk);
3158        assert_eq!(marked.band(), Some(PinnedEdge::Top));
3159        assert!(marked.is_hidden());
3160        assert_eq!(marked.branch(), Some(desk));
3161        assert!(!marked.with_hidden(false).is_hidden());
3162        // Other builders keep the marks.
3163        let pending = [(Column::Note, OwnedValue::Int(1))];
3164        let rebuilt = marked
3165            .with_depth(1)
3166            .with_columns(columns(&[Column::Note]))
3167            .with_pending(&pending);
3168        assert_eq!(
3169            (rebuilt.band(), rebuilt.is_hidden(), rebuilt.branch()),
3170            (Some(PinnedEdge::Top), true, Some(desk))
3171        );
3172        assert!(
3173            rebuilt
3174                .cell(Column::Note)
3175                .is_some_and(|cell| cell.is_pending())
3176        );
3177
3178        // A summary row takes a band and the hidden mark, but no branch.
3179        let trades = trades();
3180        let projection = grouped(
3181            &trades,
3182            &[Column::Symbol],
3183            [(Column::Price, Aggregate::Sum)],
3184        );
3185        let totals = ExportRow::<Row>::totals(projection.totals(), None)
3186            .with_band(PinnedEdge::Bottom)
3187            .with_branch(desk);
3188        assert_eq!(
3189            (totals.band(), totals.branch()),
3190            (Some(PinnedEdge::Bottom), None)
3191        );
3192        let footer = ExportRow::<Row>::footer(group(&projection, "AAPL")).with_hidden(true);
3193        assert!(footer.is_hidden() && footer.band().is_none());
3194
3195        // The marks fit in the row: a data row's extras in its variant, the
3196        // number formats in one shared slice.
3197        #[cfg(target_pointer_width = "64")]
3198        assert!(
3199            size_of::<ExportRow<'_, Row>>() <= 128,
3200            "{} bytes",
3201            size_of::<ExportRow<'_, Row>>()
3202        );
3203    }
3204
3205    #[test]
3206    fn test_number_hint() {
3207        use CellValue::{Bool, Empty, Float, Int, Text, Timestamp};
3208
3209        let digits = |value: CellValue<'static>| {
3210            let row = Row::new(0, [value, Empty, Empty]);
3211            ExportRow::new(&row)
3212                .cell(Column::Symbol)
3213                .unwrap()
3214                .number_hint()
3215                .map(|hint| hint.fraction_digits())
3216        };
3217        assert_eq!(digits(Float(1.0)), Some(2));
3218        assert_eq!(digits(Float(-0.125)), Some(2));
3219        assert_eq!(digits(Int(5)), Some(0));
3220        for value in [
3221            Text("1.5"),
3222            Bool(true),
3223            Empty,
3224            Timestamp(chrono::DateTime::<chrono::Utc>::UNIX_EPOCH),
3225        ] {
3226            assert_eq!(digits(value), None, "{value:?}");
3227        }
3228        #[cfg(feature = "decimal")]
3229        {
3230            use rust_decimal::Decimal;
3231
3232            assert_eq!(digits(CellValue::Decimal(Decimal::new(1230, 2))), Some(2));
3233            assert_eq!(digits(CellValue::Decimal(Decimal::new(5, 0))), Some(0));
3234        }
3235
3236        // Summary cells by the aggregate's value: a `Float` sum and an `Int`
3237        // count; a caption's key text has none.
3238        let snapshot = trades();
3239        let projection = grouped(
3240            &snapshot,
3241            &[Column::Symbol],
3242            [
3243                (Column::Price, Aggregate::Sum),
3244                (Column::Note, Aggregate::Count),
3245            ],
3246        );
3247        let aapl = group(&projection, "AAPL");
3248        let hint = |row: ExportRow<'_, Row>, column| row.cell(column).unwrap().number_hint();
3249        let two = hint(ExportRow::footer(aapl), Column::Price).expect("a hint");
3250        assert_eq!(two.fraction_digits(), 2);
3251        assert_eq!(two, two.clone());
3252        assert_eq!(
3253            hint(ExportRow::footer(aapl), Column::Note).map(|hint| hint.fraction_digits()),
3254            Some(0)
3255        );
3256        assert_eq!(
3257            hint(ExportRow::caption(Column::Symbol, aapl), Column::Symbol),
3258            None
3259        );
3260        assert_eq!(
3261            hint(ExportRow::totals(projection.totals(), None), Column::Price),
3262            Some(two)
3263        );
3264    }
3265
3266    #[test]
3267    fn test_pending_values() {
3268        use CellValue::{Float, Int, Text};
3269
3270        let rows = [Row::new(1, [Text("AAPL"), Float(187.25), Text("buy")])];
3271        let pending = [
3272            (Column::Price, OwnedValue::Float(2.5)),
3273            (Column::Note, OwnedValue::Int(7)),
3274        ];
3275        let row = ExportRow::new(&rows[0]).with_pending(&pending);
3276
3277        // A pending value replaces the row's value; the row's stays readable.
3278        let price = row.cell(Column::Price).unwrap();
3279        assert_eq!(price.value(), Float(2.5));
3280        assert!(price.is_pending());
3281        assert_eq!(price.row_value(), Some(Float(187.25)));
3282        // The hint follows the value shown.
3283        let note = row.cell(Column::Note).unwrap();
3284        assert_eq!(
3285            (note.value(), note.row_value()),
3286            (Int(7), Some(Text("buy")))
3287        );
3288        assert_eq!(
3289            note.number_hint().map(|hint| hint.fraction_digits()),
3290            Some(0)
3291        );
3292        // Other columns are as they were.
3293        let symbol = row.cell(Column::Symbol).unwrap();
3294        assert!(!symbol.is_pending());
3295        assert_eq!(
3296            (symbol.value(), symbol.row_value()),
3297            (Text("AAPL"), Some(Text("AAPL")))
3298        );
3299        // Without pending values, nothing is.
3300        let plain = ExportRow::new(&rows[0]).cell(Column::Price).unwrap();
3301        assert!(!plain.is_pending());
3302        assert_eq!(plain.row_value(), Some(Float(187.25)));
3303        // Narrowing the columns keeps them.
3304        assert!(
3305            row.with_columns(columns(&[Column::Price]))
3306                .cell(Column::Price)
3307                .is_some_and(|cell| cell.is_pending())
3308        );
3309
3310        // A text format writes what the screen shows; a cell exporter may
3311        // write the stored value instead.
3312        struct Stored;
3313
3314        impl CellExporter<Row> for Stored {
3315            fn write_cell(&self, cell: &ExportCell<'_, Row>, out: &mut String) {
3316                RawCells::new().write_value(
3317                    cell.row_value().unwrap_or(cell.value()),
3318                    cell.number_format(),
3319                    out,
3320                );
3321            }
3322        }
3323
3324        let table = ExportTable::new(&COLUMNS, std::slice::from_ref(&row));
3325        assert_eq!(tsv(&table, &CellExport::default()), "AAPL\t2.50\t7");
3326        assert_eq!(
3327            file_text(
3328                &CsvFormat::new().with_encoding(CsvEncoding::Utf8),
3329                &table,
3330                &CellExport::default()
3331            ),
3332            "AAPL,2.50,7\r\n"
3333        );
3334        assert_eq!(
3335            tsv(&table, &CellExport::Custom(Arc::new(Stored))),
3336            "AAPL\t187.25\tbuy"
3337        );
3338
3339        // A summary row has no pending values and no row value.
3340        let snapshot = trades();
3341        let projection = grouped(
3342            &snapshot,
3343            &[Column::Symbol],
3344            [(Column::Price, Aggregate::Sum)],
3345        );
3346        let footer = ExportRow::<Row>::footer(group(&projection, "AAPL")).with_pending(&pending);
3347        let sum = footer.cell(Column::Price).unwrap();
3348        assert_eq!(sum.value(), Float(1234.5));
3349        assert!(!sum.is_pending());
3350        assert_eq!(sum.row_value(), None);
3351    }
3352
3353    #[test]
3354    fn test_export_error() {
3355        use std::error::Error as _;
3356
3357        fn assert_error<T: std::error::Error + Clone + Send + Sync + 'static>() {}
3358        assert_error::<ExportError>();
3359
3360        assert_eq!(ExportError::Nothing.to_string(), "nothing to export");
3361        assert_eq!(
3362            ExportError::Cancelled.to_string(),
3363            "the export was cancelled"
3364        );
3365        assert!(ExportError::Cancelled.source().is_none());
3366
3367        // An I/O error converts with `?`, shows its own text and clones by
3368        // sharing it.
3369        fn write_to_missing_directory() -> Result<(), ExportError> {
3370            Err(io::Error::new(io::ErrorKind::NotFound, "no such directory"))?
3371        }
3372        let error = write_to_missing_directory().expect_err("an error");
3373        assert_eq!(error.to_string(), "no such directory");
3374        let ExportError::Io(io_error) = &error else {
3375            panic!("{error:?}");
3376        };
3377        assert_eq!(io_error.kind(), io::ErrorKind::NotFound);
3378        let ExportError::Io(cloned) = error.clone() else {
3379            panic!("a clone of {error:?}");
3380        };
3381        assert!(Arc::ptr_eq(io_error, &cloned));
3382
3383        // An application error, from a message or an error value with a
3384        // source of its own.
3385        let message = ExportError::other("the workbook is too large");
3386        assert!(matches!(message, ExportError::Other(_)));
3387        assert_eq!(message.to_string(), "the workbook is too large");
3388        assert!(message.source().is_none());
3389
3390        #[derive(Debug)]
3391        struct Library(io::Error);
3392
3393        impl fmt::Display for Library {
3394            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3395                f.write_str("the library failed")
3396            }
3397        }
3398
3399        impl std::error::Error for Library {
3400            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3401                Some(&self.0)
3402            }
3403        }
3404
3405        let library = ExportError::other(Library(io::Error::other("zip")));
3406        assert_eq!(library.to_string(), "the library failed");
3407        assert_eq!(
3408            library.source().map(ToString::to_string).as_deref(),
3409            Some("zip")
3410        );
3411        assert_eq!(library.clone().to_string(), "the library failed");
3412    }
3413}