use std::fmt;
use crate::Sheet;
pub const DEFAULT_EXPORT_MAX_BYTES: usize = 256 << 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CsvNewline {
Lf,
CrLf,
}
impl CsvNewline {
fn as_str(self) -> &'static str {
match self {
Self::Lf => "\n",
Self::CrLf => "\r\n",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CsvFormulaPolicy {
Preserve,
Escape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CsvOptions {
pub delimiter: char,
pub newline: CsvNewline,
pub bom: bool,
pub formula_policy: CsvFormulaPolicy,
pub max_output_bytes: usize,
}
impl Default for CsvOptions {
fn default() -> Self {
Self {
delimiter: ',',
newline: CsvNewline::Lf,
bom: false,
formula_policy: CsvFormulaPolicy::Preserve,
max_output_bytes: DEFAULT_EXPORT_MAX_BYTES,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CsvExportError {
InvalidDelimiter(char),
OutputTooLarge {
limit: usize,
},
}
impl fmt::Display for CsvExportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidDelimiter(delimiter) => {
write!(f, "invalid CSV delimiter {delimiter:?}")
}
Self::OutputTooLarge { limit } => {
write!(f, "CSV output exceeds the configured {limit}-byte limit")
}
}
}
}
impl std::error::Error for CsvExportError {}
pub fn export_csv(sheet: &Sheet, options: CsvOptions) -> Result<String, CsvExportError> {
if matches!(options.delimiter, '"' | '\r' | '\n') {
return Err(CsvExportError::InvalidDelimiter(options.delimiter));
}
let mut out = String::new();
if options.bom {
push_checked(&mut out, "\u{FEFF}", options.max_output_bytes)?;
}
let mut first_row = true;
for (row, cols) in sheet.rows() {
if !first_row {
push_checked(&mut out, options.newline.as_str(), options.max_output_bytes)?;
}
first_row = false;
let mut first_col = true;
let mut next_col = 0u32;
for (col, _cell) in cols {
let col = u32::from(col);
while next_col < col {
if !first_col {
push_char_checked(&mut out, options.delimiter, options.max_output_bytes)?;
}
first_col = false;
next_col += 1;
}
if !first_col {
push_char_checked(&mut out, options.delimiter, options.max_output_bytes)?;
}
let text = sheet.formatted(row, col as u16).unwrap_or_default();
push_csv_field(&mut out, text, options)?;
first_col = false;
next_col = col.saturating_add(1);
}
}
Ok(out)
}
fn push_csv_field(
out: &mut String,
field: &str,
options: CsvOptions,
) -> Result<(), CsvExportError> {
let escape_formula = options.formula_policy == CsvFormulaPolicy::Escape
&& field
.chars()
.next()
.is_some_and(|ch| matches!(ch, '=' | '+' | '-' | '@' | '\t' | '\r' | '\n'));
let quote = escape_formula
|| field.contains(options.delimiter)
|| field.contains('"')
|| field.contains('\n')
|| field.contains('\r');
let escaped_quotes = field.bytes().filter(|byte| *byte == b'"').count();
let required = field
.len()
.saturating_add(escaped_quotes)
.saturating_add(usize::from(escape_formula))
.saturating_add(if quote { 2 } else { 0 });
ensure_capacity(out, required, options.max_output_bytes)?;
if quote {
out.push('"');
}
if escape_formula {
out.push('\'');
}
for ch in field.chars() {
if ch == '"' {
out.push('"');
}
out.push(ch);
}
if quote {
out.push('"');
}
Ok(())
}
fn push_checked(out: &mut String, value: &str, limit: usize) -> Result<(), CsvExportError> {
ensure_capacity(out, value.len(), limit)?;
out.push_str(value);
Ok(())
}
fn push_char_checked(out: &mut String, value: char, limit: usize) -> Result<(), CsvExportError> {
ensure_capacity(out, value.len_utf8(), limit)?;
out.push(value);
Ok(())
}
fn ensure_capacity(out: &str, added: usize, limit: usize) -> Result<(), CsvExportError> {
if out.len().saturating_add(added) > limit {
Err(CsvExportError::OutputTooLarge { limit })
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Workbook;
#[test]
fn defaults_match_the_existing_csv_contract() {
let mut workbook = Workbook::new();
let sheet = workbook.add_sheet("Data");
sheet.write(0, 0, "name");
sheet.write(0, 2, "note, \"quoted\"");
sheet.write(2, 0, "last");
assert_eq!(
export_csv(sheet, CsvOptions::default()).unwrap(),
sheet.to_csv()
);
}
#[test]
fn options_define_bom_newline_delimiter_and_injection_policy() {
let mut workbook = Workbook::new();
let sheet = workbook.add_sheet("Data");
sheet.write(0, 0, "name");
sheet.write(0, 1, "formula");
sheet.write(1, 0, "Alice; Bob");
sheet.write(1, 1, "=HYPERLINK(\"https://example.test\")");
let options = CsvOptions {
delimiter: ';',
newline: CsvNewline::CrLf,
bom: true,
formula_policy: CsvFormulaPolicy::Escape,
..CsvOptions::default()
};
assert_eq!(
export_csv(sheet, options).unwrap(),
"\u{FEFF}name;formula\r\n\"Alice; Bob\";\"'=HYPERLINK(\"\"https://example.test\"\")\""
);
}
#[test]
fn output_limit_returns_an_error_without_a_partial_result() {
let mut workbook = Workbook::new();
let sheet = workbook.add_sheet("Data");
sheet.write(0, 0, "12345");
let error = export_csv(
sheet,
CsvOptions {
max_output_bytes: 4,
..CsvOptions::default()
},
)
.unwrap_err();
assert_eq!(error, CsvExportError::OutputTooLarge { limit: 4 });
}
#[test]
fn repeated_exports_are_byte_identical() {
let mut workbook = Workbook::new();
let sheet = workbook.add_sheet("Data");
sheet.write(4, 2, "later");
sheet.write(0, 1, "first");
sheet.write(4, 0, "row");
let options = CsvOptions::default();
assert_eq!(
export_csv(sheet, options).unwrap(),
export_csv(sheet, options).unwrap()
);
}
}