use datagrid_core::{ExportAggregate, ExportRow, ExportTable, Value};
use std::borrow::Cow;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum LineEnding {
#[default]
Crlf,
Lf,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum CsvValues {
#[default]
Raw,
Shown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CsvOptions {
pub delimiter: char,
pub line_ending: LineEnding,
pub header: bool,
pub values: CsvValues,
pub bom: bool,
pub guard_formulas: bool,
}
impl Default for CsvOptions {
fn default() -> Self {
Self {
delimiter: ',',
line_ending: LineEnding::Crlf,
header: true,
values: CsvValues::Raw,
bom: false,
guard_formulas: true,
}
}
}
#[must_use]
pub fn to_csv(table: &ExportTable, options: &CsvOptions) -> String {
let writer = Writer { options };
let width = table.columns.len();
let mut out = String::new();
if options.bom {
out.push('\u{feff}');
}
if options.header {
let labels: Vec<String> = table
.columns
.iter()
.map(|column| writer.guard(&column.label).into_owned())
.collect();
writer.record(&mut out, &labels);
}
for row in &table.rows {
let fields: Vec<String> = match row {
ExportRow::Data(cells) => cells
.iter()
.map(|cell| {
let text = writer.value_text(cell.value.as_ref(), &cell.text);
match &cell.value {
Some(Value::Text(_)) => writer.guard(&text).into_owned(),
_ => text,
}
})
.collect(),
ExportRow::GroupHeader {
caption,
aggregates,
..
} => {
let mut fields = writer.aggregate_fields(aggregates);
let first = fields.first().cloned().unwrap_or_default();
let heading = writer.guard(caption);
let head = if first.is_empty() {
heading.into_owned()
} else {
format!("{heading}; {first}")
};
match fields.first_mut() {
Some(slot) => *slot = head,
None => fields.push(head),
}
fields
}
ExportRow::Aggregates { label, cells, .. } => {
let mut fields = writer.aggregate_fields(cells);
if let Some(label) = label {
let first = fields.first().cloned().unwrap_or_default();
let head = if first.is_empty() {
label.clone()
} else {
format!("{label}: {first}")
};
match fields.first_mut() {
Some(slot) => *slot = head,
None => fields.push(head),
}
}
fields
}
_ => continue,
};
let mut fields = fields;
fields.resize(width.max(fields.len()), String::new());
writer.record(&mut out, &fields);
}
out
}
struct Writer<'a> {
options: &'a CsvOptions,
}
impl Writer<'_> {
fn value_text(&self, value: Option<&Value>, shown: &str) -> String {
if self.options.values == CsvValues::Shown {
return shown.to_owned();
}
match value {
None => String::new(),
Some(Value::Text(text)) => text.clone(),
Some(Value::Int(number)) => number.to_string(),
Some(Value::Float(number)) if number.is_finite() => number.to_string(),
Some(Value::Bool(flag)) => flag.to_string(),
#[cfg(feature = "chrono")]
Some(Value::Date(date)) => date.to_string(),
#[cfg(feature = "chrono")]
Some(Value::DateTime(date)) => date.to_string(),
Some(_) => shown.to_owned(),
}
}
fn aggregate_fields(&self, columns: &[Vec<ExportAggregate>]) -> Vec<String> {
columns
.iter()
.map(|aggregates| {
aggregates
.iter()
.filter(|aggregate| aggregate.value.is_some())
.map(|aggregate| {
format!("{} {}", aggregate.name, aggregate.text)
})
.collect::<Vec<_>>()
.join("; ")
})
.collect()
}
fn guard<'t>(&self, text: &'t str) -> Cow<'t, str> {
let risky = matches!(
text.chars().next(),
Some('=' | '+' | '-' | '@' | '\t' | '\r')
);
if self.options.guard_formulas && risky {
Cow::Owned(format!("'{text}"))
} else {
Cow::Borrowed(text)
}
}
fn record(&self, out: &mut String, fields: &[String]) {
if fields.is_empty() {
return;
}
let delimiter = self.options.delimiter;
for (index, field) in fields.iter().enumerate() {
if index > 0 {
out.push(delimiter);
}
let needs_quotes = field
.chars()
.any(|c| c == delimiter || matches!(c, '"' | '\n' | '\r'));
if needs_quotes {
out.push('"');
for c in field.chars() {
if c == '"' {
out.push('"');
}
out.push(c);
}
out.push('"');
} else {
out.push_str(field);
}
}
out.push_str(match self.options.line_ending {
LineEnding::Crlf => "\r\n",
LineEnding::Lf => "\n",
});
}
}