1use std::borrow::{Borrow, Cow};
4
5use thiserror::Error;
6
7const BYTE_ORDER_MARK: char = '\u{feff}';
8const RECORD_SEPARATOR: &str = "\r\n";
9const FORMULA_ESCAPE: char = '\'';
10const FORMULA_TRIGGERS: [char; 4] = ['=', '+', '-', '@'];
11const CONTROL_TRIGGERS: [char; 2] = ['\t', '\r'];
12const LEADING_WHITESPACE: [char; 4] = [' ', '\t', '\r', '\n'];
13const QUOTE_REQUIRED: [char; 4] = ['"', ',', '\r', '\n'];
14
15#[derive(Clone, Debug, Eq, PartialEq)]
17pub enum CsvCell<'a> {
18 Empty,
20 Text(Cow<'a, str>),
22 Numeric(Cow<'a, str>),
24}
25
26impl<'a> CsvCell<'a> {
27 #[must_use]
29 pub fn text(value: impl Into<Cow<'a, str>>) -> Self {
30 Self::Text(value.into())
31 }
32
33 #[must_use]
35 pub fn numeric(value: impl Into<Cow<'a, str>>) -> Self {
36 Self::Numeric(value.into())
37 }
38}
39
40impl<'a> From<&'a str> for CsvCell<'a> {
41 fn from(value: &'a str) -> Self {
42 Self::text(value)
43 }
44}
45
46impl From<String> for CsvCell<'_> {
47 fn from(value: String) -> Self {
48 Self::text(value)
49 }
50}
51
52impl From<i64> for CsvCell<'_> {
53 fn from(value: i64) -> Self {
54 Self::numeric(value.to_string())
55 }
56}
57
58impl From<u64> for CsvCell<'_> {
59 fn from(value: u64) -> Self {
60 Self::numeric(value.to_string())
61 }
62}
63
64impl From<f64> for CsvCell<'_> {
65 fn from(value: f64) -> Self {
66 Self::numeric(value.to_string())
67 }
68}
69
70impl<'a, T: Into<CsvCell<'a>>> From<Option<T>> for CsvCell<'a> {
71 fn from(value: Option<T>) -> Self {
72 value.map_or(Self::Empty, Into::into)
73 }
74}
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct CsvOptions {
79 neutralize_formulas: bool,
80 byte_order_mark: bool,
81 quote_all_cells: bool,
82 null_marker: Option<&'static str>,
83}
84
85impl CsvOptions {
86 #[must_use]
88 pub const fn new() -> Self {
89 Self {
90 neutralize_formulas: true,
91 byte_order_mark: false,
92 quote_all_cells: false,
93 null_marker: None,
94 }
95 }
96
97 #[must_use]
99 pub const fn without_formula_neutralization(mut self) -> Self {
100 self.neutralize_formulas = false;
101 self
102 }
103
104 #[must_use]
106 pub const fn with_byte_order_mark(mut self) -> Self {
107 self.byte_order_mark = true;
108 self
109 }
110
111 #[must_use]
114 pub const fn with_all_cells_quoted(mut self) -> Self {
115 self.quote_all_cells = true;
116 self
117 }
118
119 #[must_use]
126 pub const fn with_null_marker(mut self, marker: &'static str) -> Self {
127 assert!(
128 is_valid_null_marker(marker),
129 "CSV null marker must be non-empty and free of double quotes, commas, CR, and LF"
130 );
131 self.null_marker = Some(marker);
132 self
133 }
134}
135
136const fn is_valid_null_marker(marker: &str) -> bool {
137 let bytes = marker.as_bytes();
138 if bytes.is_empty() {
139 return false;
140 }
141 let mut index = 0;
142 while index < bytes.len() {
143 if matches!(bytes[index], b'"' | b',' | b'\r' | b'\n') {
144 return false;
145 }
146 index += 1;
147 }
148 true
149}
150
151impl Default for CsvOptions {
152 fn default() -> Self {
153 Self::new()
154 }
155}
156
157#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
159#[non_exhaustive]
160pub enum CsvEncodeError {
161 #[error("invalid numeric CSV cell at row {row_index}, column {column_index}")]
163 InvalidNumericCell {
164 row_index: usize,
166 column_index: usize,
168 },
169}
170
171impl CsvEncodeError {
172 #[must_use]
174 pub const fn code(self) -> &'static str {
175 match self {
176 Self::InvalidNumericCell { .. } => "invalid_numeric_cell",
177 }
178 }
179}
180
181pub fn encode_csv<'a, Rows, Row, Cell>(
187 rows: Rows,
188 options: CsvOptions,
189) -> Result<String, CsvEncodeError>
190where
191 Rows: IntoIterator<Item = Row>,
192 Row: IntoIterator<Item = Cell>,
193 Cell: Borrow<CsvCell<'a>>,
194{
195 let mut output = String::new();
196 if options.byte_order_mark {
197 output.push(BYTE_ORDER_MARK);
198 }
199 for (row_index, row) in rows.into_iter().enumerate() {
200 push_record(&mut output, row, row_index, options)?;
201 output.push_str(RECORD_SEPARATOR);
202 }
203 Ok(output)
204}
205
206fn push_record<'a, Row, Cell>(
207 output: &mut String,
208 row: Row,
209 row_index: usize,
210 options: CsvOptions,
211) -> Result<(), CsvEncodeError>
212where
213 Row: IntoIterator<Item = Cell>,
214 Cell: Borrow<CsvCell<'a>>,
215{
216 let record_start = output.len();
217 let mut column_count = 0;
218 for (column_index, cell) in row.into_iter().enumerate() {
219 if column_index > 0 {
220 output.push(',');
221 }
222 push_cell(output, cell.borrow(), row_index, column_index, options)?;
223 column_count += 1;
224 }
225 if column_count == 1 && output.len() == record_start {
226 output.push_str("\"\"");
227 }
228 Ok(())
229}
230
231fn push_cell(
232 output: &mut String,
233 cell: &CsvCell<'_>,
234 row_index: usize,
235 column_index: usize,
236 options: CsvOptions,
237) -> Result<(), CsvEncodeError> {
238 match cell {
239 CsvCell::Empty => output.push_str(options.null_marker.unwrap_or_default()),
240 CsvCell::Numeric(value) => {
241 if !is_json_number(value) {
242 return Err(CsvEncodeError::InvalidNumericCell {
243 row_index,
244 column_index,
245 });
246 }
247 push_numeric(output, value, options);
248 }
249 CsvCell::Text(value) => push_text(output, value, options),
250 }
251 Ok(())
252}
253
254fn push_numeric(output: &mut String, value: &str, options: CsvOptions) {
255 if !options.quote_all_cells {
256 output.push_str(value);
257 return;
258 }
259 output.push('"');
260 output.push_str(value);
261 output.push('"');
262}
263
264fn push_text(output: &mut String, value: &str, options: CsvOptions) {
265 let escape = options.neutralize_formulas && starts_like_formula(value);
266 if !must_quote(value, escape, options) {
267 if escape {
268 output.push(FORMULA_ESCAPE);
269 }
270 output.push_str(value);
271 return;
272 }
273 output.push('"');
274 if escape {
275 output.push(FORMULA_ESCAPE);
276 }
277 output.push_str(&value.replace('"', "\"\""));
278 output.push('"');
279}
280
281fn must_quote(value: &str, escape: bool, options: CsvOptions) -> bool {
282 options.quote_all_cells
283 || value.contains(QUOTE_REQUIRED)
284 || reads_as_null_marker(value, escape, options)
285}
286
287fn reads_as_null_marker(value: &str, escape: bool, options: CsvOptions) -> bool {
288 let Some(marker) = options.null_marker else {
289 return false;
290 };
291 let unescaped = if escape {
292 marker.strip_prefix(FORMULA_ESCAPE)
293 } else {
294 Some(marker)
295 };
296 unescaped == Some(value)
297}
298
299fn starts_like_formula(value: &str) -> bool {
300 if value.starts_with(CONTROL_TRIGGERS) {
301 return true;
302 }
303 value
304 .trim_start_matches(LEADING_WHITESPACE)
305 .starts_with(FORMULA_TRIGGERS)
306}
307
308fn is_json_number(value: &str) -> bool {
309 let bytes = value.as_bytes();
310 let mut index = usize::from(bytes.first() == Some(&b'-'));
311 match bytes.get(index) {
312 Some(b'0') => index += 1,
313 Some(b'1'..=b'9') => index = skip_digits(bytes, index),
314 _ => return false,
315 }
316 if bytes.get(index) == Some(&b'.') {
317 index = match required_digits(bytes, index + 1) {
318 Some(end) => end,
319 None => return false,
320 };
321 }
322 if matches!(bytes.get(index), Some(b'e' | b'E')) {
323 index += 1;
324 if matches!(bytes.get(index), Some(b'+' | b'-')) {
325 index += 1;
326 }
327 index = match required_digits(bytes, index) {
328 Some(end) => end,
329 None => return false,
330 };
331 }
332 index == bytes.len()
333}
334
335fn skip_digits(bytes: &[u8], start: usize) -> usize {
336 bytes[start..]
337 .iter()
338 .position(|byte| !byte.is_ascii_digit())
339 .map_or(bytes.len(), |offset| start + offset)
340}
341
342fn required_digits(bytes: &[u8], start: usize) -> Option<usize> {
343 let end = skip_digits(bytes, start.min(bytes.len()));
344 (end > start).then_some(end)
345}
346
347#[cfg(test)]
348mod tests {
349 use serde::Deserialize;
350
351 use super::*;
352
353 #[derive(Debug, Deserialize)]
354 #[serde(rename_all = "camelCase")]
355 struct FixtureCorpus {
356 version: u32,
357 byte_order_mark_code_point: u32,
358 cases: Vec<EncodeFixture>,
359 rejections: Vec<RejectFixture>,
360 }
361
362 #[derive(Debug, Deserialize)]
363 struct EncodeFixture {
364 name: String,
365 #[serde(default)]
366 options: FixtureOptions,
367 rows: Vec<Vec<FixtureCell>>,
368 csv: String,
369 }
370
371 #[derive(Debug, Deserialize)]
372 struct RejectFixture {
373 name: String,
374 #[serde(default)]
375 options: FixtureOptions,
376 rows: Vec<Vec<FixtureCell>>,
377 error: FixtureError,
378 }
379
380 #[derive(Debug, Default, Deserialize)]
381 #[serde(rename_all = "camelCase")]
382 struct FixtureOptions {
383 neutralize_formulas: Option<bool>,
384 byte_order_mark: Option<bool>,
385 quote_all_cells: Option<bool>,
386 null_marker: Option<String>,
387 }
388
389 #[derive(Debug, Deserialize)]
390 #[serde(untagged)]
391 enum FixtureCell {
392 Text(String),
393 Numeric { numeric: String },
394 Empty,
395 }
396
397 #[derive(Debug, Deserialize)]
398 #[serde(rename_all = "camelCase")]
399 struct FixtureError {
400 code: String,
401 row_index: usize,
402 column_index: usize,
403 }
404
405 fn fixtures() -> FixtureCorpus {
406 serde_json::from_str(include_str!(
407 "../../../../fixtures/export-csv/csv-encoding-v1.json"
408 ))
409 .expect("CSV fixture should parse")
410 }
411
412 fn options(fixture: &FixtureOptions) -> CsvOptions {
413 let mut options = CsvOptions::new();
414 if fixture.neutralize_formulas == Some(false) {
415 options = options.without_formula_neutralization();
416 }
417 if fixture.byte_order_mark == Some(true) {
418 options = options.with_byte_order_mark();
419 }
420 if fixture.quote_all_cells == Some(true) {
421 options = options.with_all_cells_quoted();
422 }
423 if let Some(marker) = &fixture.null_marker {
424 options = options.with_null_marker(String::leak(marker.clone()));
425 }
426 options
427 }
428
429 fn rows(fixture: &[Vec<FixtureCell>]) -> Vec<Vec<CsvCell<'_>>> {
430 fixture
431 .iter()
432 .map(|row| {
433 row.iter()
434 .map(|cell| match cell {
435 FixtureCell::Text(text) => CsvCell::text(text.as_str()),
436 FixtureCell::Numeric { numeric } => CsvCell::numeric(numeric.as_str()),
437 FixtureCell::Empty => CsvCell::Empty,
438 })
439 .collect()
440 })
441 .collect()
442 }
443
444 #[test]
445 fn shared_fixture_encodes_every_case() {
446 let corpus = fixtures();
447 assert_eq!(corpus.version, 1);
448 assert!(!corpus.cases.is_empty());
449 let byte_order_mark = char::from_u32(corpus.byte_order_mark_code_point)
450 .expect("fixture byte-order mark should be a scalar value");
451 assert_eq!(byte_order_mark, BYTE_ORDER_MARK);
452 for case in corpus.cases {
453 let encoded = encode_csv(rows(&case.rows), options(&case.options))
454 .unwrap_or_else(|error| panic!("{}: {error}", case.name));
455 let expected = if case.options.byte_order_mark == Some(true) {
456 format!("{byte_order_mark}{}", case.csv)
457 } else {
458 case.csv
459 };
460 assert_eq!(encoded, expected, "{}", case.name);
461 }
462 }
463
464 #[test]
465 fn byte_order_mark_is_the_first_code_point_only_when_requested() {
466 let rows = [["a"]];
467 let marked = encode_csv(
468 rows.map(|row| row.map(CsvCell::from)),
469 CsvOptions::new().with_byte_order_mark(),
470 )
471 .expect("text encodes");
472 assert_eq!(marked.chars().next().map(u32::from), Some(0xfeff));
473 let plain = encode_csv(rows.map(|row| row.map(CsvCell::from)), CsvOptions::new())
474 .expect("text encodes");
475 assert_eq!(plain, "a\r\n");
476 }
477
478 #[test]
479 fn shared_fixture_rejects_every_invalid_numeric_cell() {
480 let corpus = fixtures();
481 assert!(!corpus.rejections.is_empty());
482 for case in corpus.rejections {
483 let error = encode_csv(rows(&case.rows), options(&case.options)).expect_err(&case.name);
484 assert_eq!(error.code(), case.error.code, "{}", case.name);
485 assert_eq!(
486 error,
487 CsvEncodeError::InvalidNumericCell {
488 row_index: case.error.row_index,
489 column_index: case.error.column_index,
490 },
491 "{}",
492 case.name
493 );
494 }
495 }
496
497 #[test]
498 fn conversions_pick_text_numeric_and_empty_cells() {
499 let rows: [Vec<CsvCell<'_>>; 1] = [vec![
500 CsvCell::from("-5"),
501 CsvCell::from(String::from("=x")),
502 CsvCell::from(-5_i64),
503 CsvCell::from(7_u64),
504 CsvCell::from(-0.25_f64),
505 CsvCell::from(None::<i64>),
506 CsvCell::from(Some("ok")),
507 ]];
508 assert_eq!(
509 encode_csv(&rows, CsvOptions::default()),
510 Ok(String::from("'-5,'=x,-5,7,-0.25,,ok\r\n"))
511 );
512 }
513
514 #[test]
515 fn non_finite_floats_are_rejected() {
516 for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
517 let rows = [[CsvCell::from(value)]];
518 assert_eq!(
519 encode_csv(&rows, CsvOptions::new()),
520 Err(CsvEncodeError::InvalidNumericCell {
521 row_index: 0,
522 column_index: 0,
523 })
524 );
525 }
526 }
527
528 #[test]
529 fn null_marker_is_accepted_in_a_const_item() {
530 const OPTIONS: CsvOptions = CsvOptions::new().with_null_marker("\\N");
531 let rows = [[CsvCell::Empty, CsvCell::text("\\N")]];
532 assert_eq!(
533 encode_csv(&rows, OPTIONS),
534 Ok(String::from("\\N,\"\\N\"\r\n"))
535 );
536 }
537
538 #[test]
539 fn invalid_null_markers_panic() {
540 for marker in ["", "a,b", "say \"null\"", "line\n", "line\r"] {
541 let result = std::panic::catch_unwind(|| CsvOptions::new().with_null_marker(marker));
542 assert!(result.is_err(), "{marker:?}");
543 }
544 }
545
546 #[test]
547 fn a_neutralized_cell_is_quoted_when_it_would_read_as_the_null_marker() {
548 let rows = [[CsvCell::Empty, CsvCell::text("-")]];
549 assert_eq!(
550 encode_csv(&rows, CsvOptions::new().with_null_marker("'-")),
551 Ok(String::from("'-,\"'-\"\r\n"))
552 );
553 }
554
555 #[test]
556 fn error_messages_do_not_echo_cell_values() {
557 let rows = [[CsvCell::numeric("=secret()")]];
558 let error = encode_csv(&rows, CsvOptions::new()).expect_err("formula is not numeric");
559 assert!(!error.to_string().contains("secret"));
560 }
561}