Skip to main content

datagrid_export/
csv.rs

1//! CSV, as RFC 4180 writes it, with the choices a file for Excel needs.
2
3use datagrid_core::{ExportAggregate, ExportRow, ExportTable, Value};
4use std::borrow::Cow;
5
6/// How a line ends.
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
8pub enum LineEnding {
9    /// `\r\n`, which RFC 4180 specifies and every spreadsheet reads.
10    #[default]
11    Crlf,
12    /// `\n`, which Unix tools expect.
13    Lf,
14}
15
16/// What the cells say.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
18pub enum CsvValues {
19    /// The values themselves, for a program to read: `4200.5`, `true`,
20    /// `2026-10-16`. No thousands separator, no currency symbol, and a decimal
21    /// point whatever the locale.
22    #[default]
23    Raw,
24    /// The text the grid shows: `€4,200.50`, `Yes`, `16.10.2026`. For a person
25    /// who will read the file as it is on screen.
26    Shown,
27}
28
29/// How [`to_csv`] writes.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct CsvOptions {
32    /// What separates the fields: a comma, or a semicolon for the spreadsheets
33    /// of a locale that writes its decimal comma.
34    pub delimiter: char,
35    /// How a line ends.
36    pub line_ending: LineEnding,
37    /// Whether the first line names the columns.
38    pub header: bool,
39    /// Whether the cells carry the values or the text shown.
40    pub values: CsvValues,
41    /// Whether to start the file with a byte order mark. Excel reads a file
42    /// without one as the system's code page and mangles every umlaut; most
43    /// other readers accept it silently.
44    pub bom: bool,
45    /// Whether to defuse text that a spreadsheet would run as a formula.
46    ///
47    /// A cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return is
48    /// a formula to Excel, and a grid shows what its users typed. Such text gets
49    /// a `'` in front, which the spreadsheet shows as nothing. Numbers are not
50    /// touched: `-5` stays `-5`. Turn it off only for a file that no
51    /// spreadsheet will open.
52    pub guard_formulas: bool,
53}
54
55impl Default for CsvOptions {
56    fn default() -> Self {
57        Self {
58            delimiter: ',',
59            line_ending: LineEnding::Crlf,
60            header: true,
61            values: CsvValues::Raw,
62            bom: false,
63            guard_formulas: true,
64        }
65    }
66}
67
68/// Writes the table as CSV.
69///
70/// One line per data row, under a line of the column labels. A group's heading
71/// is a line of its own with the caption in the first field, and its aggregates
72/// follow its rows as a line with each in its column's field: `Sum €7,300.50`, or
73/// several of them joined with `; `. The totals line starts with the locale's
74/// "Totals". Where the table has no groups, none of that shows.
75///
76/// ```
77/// use datagrid_core::{ExportCell, ExportColumn, ExportRow, ExportTable, Value};
78/// use datagrid_export::{CsvOptions, to_csv};
79///
80/// let table = ExportTable {
81///     columns: vec![ExportColumn::new("note", "note")],
82///     rows: vec![ExportRow::Data(vec![ExportCell {
83///         value: Some(Value::Text("said \"hi\", left".into())),
84///         text: "said \"hi\", left".into(),
85///     }])],
86/// };
87/// assert_eq!(
88///     to_csv(&table, &CsvOptions::default()),
89///     "note\r\n\"said \"\"hi\"\", left\"\r\n",
90/// );
91/// ```
92#[must_use]
93pub fn to_csv(table: &ExportTable, options: &CsvOptions) -> String {
94    let writer = Writer { options };
95    let width = table.columns.len();
96    let mut out = String::new();
97    if options.bom {
98        out.push('\u{feff}');
99    }
100
101    if options.header {
102        let labels: Vec<String> = table
103            .columns
104            .iter()
105            .map(|column| writer.guard(&column.label).into_owned())
106            .collect();
107        writer.record(&mut out, &labels);
108    }
109
110    for row in &table.rows {
111        let fields: Vec<String> = match row {
112            ExportRow::Data(cells) => cells
113                .iter()
114                .map(|cell| {
115                    let text = writer.value_text(cell.value.as_ref(), &cell.text);
116                    match &cell.value {
117                        // Only text can be taken for a formula.
118                        Some(Value::Text(_)) => writer.guard(&text).into_owned(),
119                        _ => text,
120                    }
121                })
122                .collect(),
123            ExportRow::GroupHeader {
124                caption,
125                aggregates,
126                ..
127            } => {
128                let mut fields = writer.aggregate_fields(aggregates);
129                // The caption heads the line; what the first column aggregates
130                // follows it, so nothing a closed group says is dropped.
131                let first = fields.first().cloned().unwrap_or_default();
132                let heading = writer.guard(caption);
133                let head = if first.is_empty() {
134                    heading.into_owned()
135                } else {
136                    format!("{heading}; {first}")
137                };
138                match fields.first_mut() {
139                    Some(slot) => *slot = head,
140                    None => fields.push(head),
141                }
142                fields
143            }
144            ExportRow::Aggregates { label, cells, .. } => {
145                let mut fields = writer.aggregate_fields(cells);
146                if let Some(label) = label {
147                    let first = fields.first().cloned().unwrap_or_default();
148                    let head = if first.is_empty() {
149                        label.clone()
150                    } else {
151                        format!("{label}: {first}")
152                    };
153                    match fields.first_mut() {
154                        Some(slot) => *slot = head,
155                        None => fields.push(head),
156                    }
157                }
158                fields
159            }
160            // A row kind added later is one this version does not know how to
161            // write; skipping it is better than writing nonsense.
162            _ => continue,
163        };
164
165        // Every line has one field per column, whatever the row had to say.
166        let mut fields = fields;
167        fields.resize(width.max(fields.len()), String::new());
168        writer.record(&mut out, &fields);
169    }
170    out
171}
172
173struct Writer<'a> {
174    options: &'a CsvOptions,
175}
176
177impl Writer<'_> {
178    /// One cell's text, as a value or as shown.
179    fn value_text(&self, value: Option<&Value>, shown: &str) -> String {
180        if self.options.values == CsvValues::Shown {
181            return shown.to_owned();
182        }
183        match value {
184            None => String::new(),
185            Some(Value::Text(text)) => text.clone(),
186            Some(Value::Int(number)) => number.to_string(),
187            // A float that is not a number has no value to write; what the grid
188            // showed for it is the best there is.
189            Some(Value::Float(number)) if number.is_finite() => number.to_string(),
190            Some(Value::Bool(flag)) => flag.to_string(),
191            #[cfg(feature = "chrono")]
192            Some(Value::Date(date)) => date.to_string(),
193            #[cfg(feature = "chrono")]
194            Some(Value::DateTime(date)) => date.to_string(),
195            Some(_) => shown.to_owned(),
196        }
197    }
198
199    /// The text of one column's aggregates: `Sum €7,300.50; Count 2`.
200    fn aggregate_fields(&self, columns: &[Vec<ExportAggregate>]) -> Vec<String> {
201        columns
202            .iter()
203            .map(|aggregates| {
204                aggregates
205                    .iter()
206                    .filter(|aggregate| aggregate.value.is_some())
207                    .map(|aggregate| {
208                        // The text shown, whatever `values` says: an aggregate line is
209                        // a caption for a reader (`Average 29.33`), not a cell of data.
210                        format!("{} {}", aggregate.name, aggregate.text)
211                    })
212                    .collect::<Vec<_>>()
213                    .join("; ")
214            })
215            .collect()
216    }
217
218    /// Defuses text a spreadsheet would run as a formula.
219    fn guard<'t>(&self, text: &'t str) -> Cow<'t, str> {
220        let risky = matches!(
221            text.chars().next(),
222            Some('=' | '+' | '-' | '@' | '\t' | '\r')
223        );
224        if self.options.guard_formulas && risky {
225            Cow::Owned(format!("'{text}"))
226        } else {
227            Cow::Borrowed(text)
228        }
229    }
230
231    /// One line: the fields, quoted where they need it, and the line ending.
232    fn record(&self, out: &mut String, fields: &[String]) {
233        // A table with no columns has no lines either, not a blank one.
234        if fields.is_empty() {
235            return;
236        }
237        let delimiter = self.options.delimiter;
238        for (index, field) in fields.iter().enumerate() {
239            if index > 0 {
240                out.push(delimiter);
241            }
242            let needs_quotes = field
243                .chars()
244                .any(|c| c == delimiter || matches!(c, '"' | '\n' | '\r'));
245            if needs_quotes {
246                out.push('"');
247                for c in field.chars() {
248                    if c == '"' {
249                        out.push('"');
250                    }
251                    out.push(c);
252                }
253                out.push('"');
254            } else {
255                out.push_str(field);
256            }
257        }
258        out.push_str(match self.options.line_ending {
259            LineEnding::Crlf => "\r\n",
260            LineEnding::Lf => "\n",
261        });
262    }
263}