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}