Skip to main content

baukit_core/
export.rs

1//! RFC 4180 CSV encoding with spreadsheet formula neutralization.
2
3use 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/// One CSV cell.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub enum CsvCell<'a> {
18    /// An empty cell.
19    Empty,
20    /// Text that is neutralized when it starts like a spreadsheet formula.
21    Text(Cow<'a, str>),
22    /// A JSON-grammar number, written unchanged and never neutralized.
23    Numeric(Cow<'a, str>),
24}
25
26impl<'a> CsvCell<'a> {
27    /// Creates a text cell.
28    #[must_use]
29    pub fn text(value: impl Into<Cow<'a, str>>) -> Self {
30        Self::Text(value.into())
31    }
32
33    /// Creates a numeric cell from preformatted text such as `-5` or `1.50`.
34    #[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/// Options for [`encode_csv`].
77#[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    /// Neutralizes formulas and writes no byte-order mark.
87    #[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    /// Writes text cells unchanged even when they start like a formula.
98    #[must_use]
99    pub const fn without_formula_neutralization(mut self) -> Self {
100        self.neutralize_formulas = false;
101        self
102    }
103
104    /// Starts the output with a UTF-8 byte-order mark.
105    #[must_use]
106    pub const fn with_byte_order_mark(mut self) -> Self {
107        self.byte_order_mark = true;
108        self
109    }
110
111    /// Quotes every text and numeric cell. Empty cells stay unquoted, so a reader can tell them
112    /// from empty text.
113    #[must_use]
114    pub const fn with_all_cells_quoted(mut self) -> Self {
115        self.quote_all_cells = true;
116        self
117    }
118
119    /// Writes `marker` unquoted for an empty cell and quotes a text cell with the same content.
120    ///
121    /// # Panics
122    ///
123    /// Panics when `marker` is empty or contains a double quote, comma, CR, or LF. In a `const`
124    /// item the check fails the build instead.
125    #[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/// A cell the encoder cannot write.
158#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
159#[non_exhaustive]
160pub enum CsvEncodeError {
161    /// A numeric cell does not match the JSON number grammar.
162    #[error("invalid numeric CSV cell at row {row_index}, column {column_index}")]
163    InvalidNumericCell {
164        /// Zero-based row index.
165        row_index: usize,
166        /// Zero-based column index.
167        column_index: usize,
168    },
169}
170
171impl CsvEncodeError {
172    /// Returns the stable error code shared with `@baukit/data-contracts`.
173    #[must_use]
174    pub const fn code(self) -> &'static str {
175        match self {
176            Self::InvalidNumericCell { .. } => "invalid_numeric_cell",
177        }
178    }
179}
180
181/// Encodes rows as RFC 4180 CSV with CRLF record separators.
182///
183/// # Errors
184///
185/// Returns [`CsvEncodeError::InvalidNumericCell`] when a numeric cell is not a JSON-grammar number.
186pub 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}