use std::collections::HashMap;
use std::io;
use axum::response::{IntoResponse, Response};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CsvRowError {
pub line: u64,
pub column: Option<String>,
pub message: String,
}
impl CsvRowError {
#[must_use]
pub fn row(line: u64, message: impl Into<String>) -> Self {
Self {
line,
column: None,
message: message.into(),
}
}
#[must_use]
pub fn field(line: u64, column: impl Into<String>, message: impl Into<String>) -> Self {
Self {
line,
column: Some(column.into()),
message: message.into(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub struct ImportReport {
pub inserted: u64,
pub updated: u64,
pub skipped: u64,
pub errors: Vec<CsvRowError>,
}
impl ImportReport {
#[must_use]
pub const fn total_rows(&self) -> u64 {
self.inserted + self.updated + self.skipped + self.errors.len() as u64
}
#[must_use]
pub const fn is_ok(&self) -> bool {
self.errors.is_empty()
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum ImportMode {
Insert,
Upsert {
by: Vec<String>,
},
DryRun,
}
#[derive(Debug, Clone)]
pub struct ImportOptions {
pub mode: ImportMode,
pub batch_size: usize,
}
impl Default for ImportOptions {
fn default() -> Self {
Self {
mode: ImportMode::Insert,
batch_size: 500,
}
}
}
pub enum ImportRowResult {
Inserted,
Updated,
Skipped,
RowError(String),
FieldError { column: String, message: String },
}
pub trait CsvSchema {
fn csv_columns() -> &'static [&'static str]
where
Self: Sized;
fn to_csv_record(&self) -> Vec<String>;
}
pub fn export_csv<T, W>(records: impl IntoIterator<Item = T>, mut writer: W) -> io::Result<()>
where
T: CsvSchema,
W: io::Write,
{
let mut wtr = csv::WriterBuilder::new()
.has_headers(true)
.from_writer(&mut writer);
wtr.write_record(T::csv_columns())?;
for record in records {
wtr.write_record(record.to_csv_record())?;
}
wtr.flush()?;
Ok(())
}
pub struct CsvExport<I>(pub String, pub I);
impl<I, T> IntoResponse for CsvExport<I>
where
I: IntoIterator<Item = T>,
T: CsvSchema,
{
fn into_response(self) -> Response {
let mut out = Vec::new();
if export_csv(self.1, &mut out).is_err() {
return (
http::StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate CSV export",
)
.into_response();
}
crate::download::Download::from_bytes(out)
.filename(self.0)
.content_type("text/csv; charset=utf-8")
.into_response()
}
}
pub fn count_data_rows<R>(reader: R) -> u64
where
R: io::Read,
{
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(reader);
rdr.byte_records().count() as u64
}
pub fn read_header<R>(reader: R) -> Vec<String>
where
R: io::Read,
{
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(reader);
rdr.headers()
.map(|header| header.iter().map(str::to_owned).collect())
.unwrap_or_default()
}
fn without_parser_position(message: &str) -> String {
const OPEN: &str = " (line: ";
let Some(start) = message.find(OPEN) else {
return message.to_owned();
};
let Some(len) = message[start..].find(')') else {
return message.to_owned();
};
let mut out = String::with_capacity(message.len());
out.push_str(&message[..start]);
out.push_str(&message[start + len + 1..]);
out
}
pub fn import_csv<R, F>(reader: R, opts: &ImportOptions, mut handler: F) -> ImportReport
where
R: io::Read,
F: FnMut(u64, HashMap<String, String>, &ImportMode) -> ImportRowResult,
{
let mut report = ImportReport::default();
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(reader);
let headers: Vec<String> = match rdr.headers() {
Ok(h) => h.iter().map(str::to_owned).collect(),
Err(e) => {
report
.errors
.push(CsvRowError::row(1, format!("CSV header error: {e}")));
return report;
}
};
let header_lines: u64 = 1 + headers
.iter()
.map(|field| field.matches('\n').count() as u64)
.sum::<u64>();
let line_offset: i64 = i64::try_from(header_lines.saturating_add(1)).unwrap_or(2)
- i64::try_from(rdr.position().line()).unwrap_or(2);
let absolute_line = move |raw: u64| -> u64 {
let raw = i64::try_from(raw).unwrap_or(i64::MAX);
u64::try_from(raw.saturating_add(line_offset)).unwrap_or(0)
};
for result in rdr.records() {
let (line, record) = match result {
Ok(r) => {
let pos = absolute_line(r.position().map_or(0, csv::Position::line));
(pos, r)
}
Err(e) => {
let pos = absolute_line(e.position().map_or(0, csv::Position::line));
report.errors.push(CsvRowError::row(
pos,
format!(
"CSV parse error: {}",
without_parser_position(&e.to_string())
),
));
continue;
}
};
let row: HashMap<String, String> = headers
.iter()
.zip(record.iter())
.map(|(k, v)| (k.clone(), v.to_owned()))
.collect();
let outcome = handler(line, row, &opts.mode);
match outcome {
ImportRowResult::Inserted => report.inserted += 1,
ImportRowResult::Updated => report.updated += 1,
ImportRowResult::Skipped => report.skipped += 1,
ImportRowResult::RowError(msg) => {
report.errors.push(CsvRowError::row(line, msg));
}
ImportRowResult::FieldError { column, message } => {
report
.errors
.push(CsvRowError::field(line, column, message));
}
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
struct Post {
id: i64,
title: String,
published: bool,
}
impl CsvSchema for Post {
fn csv_columns() -> &'static [&'static str] {
&["id", "title", "published"]
}
fn to_csv_record(&self) -> Vec<String> {
vec![
self.id.to_string(),
self.title.clone(),
self.published.to_string(),
]
}
}
fn sample_posts() -> Vec<Post> {
vec![
Post {
id: 1,
title: "Hello, World".to_string(),
published: true,
},
Post {
id: 2,
title: "Goodbye cruel \"world\"".to_string(),
published: false,
},
]
}
#[test]
fn csv_row_error_row_constructor() {
let e = CsvRowError::row(42, "bad value");
assert_eq!(e.line, 42);
assert!(e.column.is_none());
assert_eq!(e.message, "bad value");
}
#[test]
fn csv_row_error_field_constructor() {
let e = CsvRowError::field(5, "email", "invalid email");
assert_eq!(e.line, 5);
assert_eq!(e.column.as_deref(), Some("email"));
assert_eq!(e.message, "invalid email");
}
#[test]
fn import_report_default_is_zero() {
let r = ImportReport::default();
assert_eq!(r.inserted, 0);
assert_eq!(r.updated, 0);
assert_eq!(r.skipped, 0);
assert!(r.errors.is_empty());
assert_eq!(r.total_rows(), 0);
assert!(r.is_ok());
}
#[test]
fn import_report_total_rows_sums_all_buckets() {
let r = ImportReport {
inserted: 3,
updated: 2,
skipped: 1,
errors: vec![CsvRowError::row(10, "oops")],
};
assert_eq!(r.total_rows(), 7);
assert!(!r.is_ok());
}
#[test]
fn export_csv_writes_header_and_rows() {
let mut out = Vec::new();
export_csv(sample_posts(), &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
let mut lines = s.lines();
assert_eq!(lines.next().unwrap(), "id,title,published");
assert_eq!(lines.next().unwrap(), "1,\"Hello, World\",true");
}
#[test]
fn export_csv_applies_rfc4180_quoting_for_commas() {
let mut out = Vec::new();
export_csv(sample_posts(), &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(
s.contains("\"Hello, World\""),
"comma in title should be quoted: {s}"
);
}
#[test]
fn export_csv_applies_rfc4180_quoting_for_double_quotes() {
let mut out = Vec::new();
export_csv(sample_posts(), &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(
s.contains("\"Goodbye cruel \"\"world\"\"\""),
"embedded quotes should be doubled: {s}"
);
}
#[test]
fn export_csv_empty_iterator_writes_header_only() {
let mut out = Vec::new();
export_csv(Vec::<Post>::new(), &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
let lines: Vec<&str> = s.lines().collect();
assert_eq!(lines, vec!["id,title,published"]);
}
#[test]
fn export_csv_stable_column_ordering() {
let mut out = Vec::new();
export_csv(sample_posts(), &mut out).unwrap();
let s = String::from_utf8(out).unwrap();
let header = s.lines().next().unwrap();
assert_eq!(header, "id,title,published");
}
#[tokio::test]
async fn csv_export_sets_csv_headers_and_body() {
let resp = CsvExport("posts.csv".to_owned(), sample_posts()).into_response();
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.headers()[http::header::CONTENT_TYPE],
"text/csv; charset=utf-8"
);
assert_eq!(
resp.headers()[http::header::CONTENT_DISPOSITION],
"attachment; filename=\"posts.csv\""
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.starts_with("id,title,published\n"), "{body}");
assert!(body.contains("\"Hello, World\""), "{body}");
}
#[test]
fn csv_export_filename_cannot_inject_header_directives() {
let resp = CsvExport(
"../x\"; filename=evil.exe\r\nSet-Cookie: a=b".to_owned(),
sample_posts(),
)
.into_response();
assert_eq!(resp.status(), http::StatusCode::OK);
assert!(resp.headers().get(http::header::SET_COOKIE).is_none());
let disposition = resp.headers()[http::header::CONTENT_DISPOSITION]
.to_str()
.unwrap();
assert_eq!(
disposition,
"attachment; filename=\"x\\\"; filename=evil.exeSet-Cookie: a=b\""
);
}
#[test]
fn import_csv_insert_mode_counts_inserted() {
let csv = b"id,title,published\n1,Hello,true\n2,World,false\n";
let report = import_csv(
csv.as_ref(),
&ImportOptions::default(),
|_line, _row, _mode| ImportRowResult::Inserted,
);
assert_eq!(report.inserted, 2);
assert_eq!(report.updated, 0);
assert_eq!(report.skipped, 0);
assert!(report.errors.is_empty());
}
#[test]
fn import_csv_handler_receives_column_values_as_map() {
let csv = b"title,published\nHello,true\n";
let mut seen: Option<HashMap<String, String>> = None;
import_csv(
csv.as_ref(),
&ImportOptions::default(),
|_line, row, _mode| {
seen = Some(row);
ImportRowResult::Inserted
},
);
let row = seen.unwrap();
assert_eq!(row.get("title").map(String::as_str), Some("Hello"));
assert_eq!(row.get("published").map(String::as_str), Some("true"));
}
#[test]
fn import_csv_row_error_is_captured_with_line_number() {
let csv = b"title\nGood row\nBad row\nAnother good\n";
let report = import_csv(
csv.as_ref(),
&ImportOptions::default(),
|line, row, _mode| {
if row.get("title").map(String::as_str) == Some("Bad row") {
ImportRowResult::RowError("title must not be 'Bad row'".into())
} else {
let _ = line;
ImportRowResult::Inserted
}
},
);
assert_eq!(report.inserted, 2);
assert_eq!(report.errors.len(), 1);
assert_eq!(report.errors[0].message, "title must not be 'Bad row'");
assert_eq!(report.errors[0].line, 3);
}
#[test]
fn import_csv_line_numbers_are_the_same_for_crlf_and_lf() {
let lines = |csv: &[u8]| -> Vec<u64> {
let mut seen = Vec::new();
let report = import_csv(csv, &ImportOptions::default(), |line, row, _mode| {
seen.push(line);
if row.get("title").map(String::as_str) == Some("Bad row") {
ImportRowResult::RowError("bad".into())
} else {
ImportRowResult::Inserted
}
});
assert_eq!(report.errors.len(), 1, "one bad row per fixture");
assert_eq!(
report.errors[0].line, 3,
"the bad row is on line 3 of both fixtures"
);
seen
};
let lf = lines(b"title\nGood row\nBad row\nAnother good\n");
let crlf = lines(b"title\r\nGood row\r\nBad row\r\nAnother good\r\n");
assert_eq!(lf, vec![2, 3, 4]);
assert_eq!(crlf, lf, "line endings must not change the row numbers");
}
#[test]
fn import_csv_parse_error_line_numbers_match_across_dialects() {
let lines = |csv: &[u8]| -> u64 {
let report = import_csv(csv, &ImportOptions::default(), |_line, _row, _mode| {
ImportRowResult::Inserted
});
assert_eq!(report.errors.len(), 1, "one malformed row per fixture");
assert!(
report.errors[0].message.starts_with("CSV parse error"),
"expected a parse error, got {:?}",
report.errors[0].message
);
report.errors[0].line
};
assert_eq!(lines(b"title,note\na,b\nc,d,e\nf,g\n"), 3);
assert_eq!(lines(b"title,note\r\na,b\r\nc,d,e\r\nf,g\r\n"), 3);
}
#[test]
fn import_csv_parse_error_message_carries_no_second_line_number() {
for csv in [
&b"title,note\na,x\nBAD\nc,z\n"[..],
&b"title,note\r\na,x\r\nBAD\r\nc,z\r\n"[..],
] {
let report = import_csv(csv, &ImportOptions::default(), |_line, _row, _mode| {
ImportRowResult::Inserted
});
assert_eq!(report.errors.len(), 1);
assert_eq!(report.errors[0].line, 3);
assert!(
!report.errors[0].message.contains("line:"),
"the parser's own line number must not travel in the message: {:?}",
report.errors[0].message
);
assert!(
report.errors[0].message.contains("fields"),
"{:?}",
report.errors[0].message
);
}
}
#[test]
fn import_csv_header_error_is_reported_against_line_one_and_runs_no_rows() {
let report = import_csv(
&[0xff, 0xfe, b'a', b'\n'][..],
&ImportOptions::default(),
|_line, _row, _mode| unreachable!("no row handler may run for a bad header"),
);
assert_eq!(report.errors.len(), 1);
assert_eq!(report.errors[0].line, 1);
assert!(
report.errors[0].message.starts_with("CSV header error"),
"{:?}",
report.errors[0].message
);
assert_eq!(report.total_rows(), 1);
}
#[test]
fn read_header_returns_the_column_names_in_file_order() {
assert_eq!(
read_header(b"title,note\na,x\n".as_ref()),
vec!["title", "note"]
);
assert_eq!(
read_header(b"title,note\r\na,x\r\n".as_ref()),
vec!["title", "note"]
);
assert_eq!(
read_header(b"\"a,b\",\"c\nd\"\nx,y\n".as_ref()),
vec!["a,b", "c\nd"]
);
assert_eq!(read_header(b"title,note\n".as_ref()), vec!["title", "note"]);
assert!(read_header(b"".as_ref()).is_empty());
}
#[test]
fn count_data_rows_counts_every_data_row_including_malformed_ones() {
assert_eq!(count_data_rows(b"title,note\na,x\nb,y\n".as_ref()), 2);
assert_eq!(count_data_rows(b"title,note\na,x\nBAD\nb,y\n".as_ref()), 3);
assert_eq!(
count_data_rows(b"title,note\n\"a\nz\",x\nb,y\n".as_ref()),
2
);
assert_eq!(count_data_rows(b"title,note\r\na,x\r\nb,y\r\n".as_ref()), 2);
assert_eq!(count_data_rows(b"title,note\n".as_ref()), 0);
assert_eq!(count_data_rows(b"".as_ref()), 0);
}
#[test]
fn count_data_rows_agrees_with_import_csv_total_rows() {
let csv = b"title,note\na,x\nBAD\nb,y\n";
let report = import_csv(csv.as_ref(), &ImportOptions::default(), |_l, _r, _m| {
ImportRowResult::Inserted
});
assert_eq!(count_data_rows(csv.as_ref()), report.total_rows());
}
#[test]
fn import_csv_line_numbers_survive_a_multi_line_header() {
let lines = |csv: &[u8]| -> Vec<u64> {
let mut seen = Vec::new();
import_csv(csv, &ImportOptions::default(), |line, _row, _mode| {
seen.push(line);
ImportRowResult::Inserted
});
seen
};
assert_eq!(lines(b"\"a\nz\",b\nr1,x\nr2,y\n"), vec![3, 4]);
assert_eq!(lines(b"\"a\r\nz\",b\r\nr1,x\r\nr2,y\r\n"), vec![3, 4]);
}
#[test]
fn import_csv_line_numbers_follow_multi_line_quoted_fields() {
let lines = |csv: &[u8]| -> Vec<u64> {
let mut seen = Vec::new();
import_csv(csv, &ImportOptions::default(), |line, _row, _mode| {
seen.push(line);
ImportRowResult::Inserted
});
seen
};
assert_eq!(lines(b"title,note\n\"a\nb\",fine\nsecond,x\n"), vec![2, 4]);
assert_eq!(
lines(b"title,note\r\n\"a\r\nb\",fine\r\nsecond,x\r\n"),
vec![2, 4]
);
}
#[test]
fn import_csv_field_error_records_column_name() {
let csv = b"email\nbad-email\n";
let report = import_csv(
csv.as_ref(),
&ImportOptions::default(),
|_line, row, _mode| {
if row.get("email").map_or("", String::as_str).contains('@') {
ImportRowResult::Inserted
} else {
ImportRowResult::FieldError {
column: "email".into(),
message: "must be a valid email".into(),
}
}
},
);
assert_eq!(report.errors.len(), 1);
assert_eq!(report.errors[0].column.as_deref(), Some("email"));
assert_eq!(report.errors[0].message, "must be a valid email");
}
#[test]
fn import_csv_dry_run_counts_but_does_not_write() {
let csv = b"id,title\n1,Hello\n2,World\n";
let mut write_called = false;
let opts = ImportOptions {
mode: ImportMode::DryRun,
batch_size: 100,
};
let report = import_csv(csv.as_ref(), &opts, |_line, _row, mode| {
if !matches!(mode, ImportMode::DryRun) {
write_called = true;
}
ImportRowResult::Inserted
});
assert!(!write_called, "handler must not write in dry-run mode");
assert_eq!(report.inserted, 2, "dry-run should still count rows");
}
#[test]
fn import_csv_upsert_mode_counts_updated() {
let csv = b"id,title\n1,Hello\n2,World\n";
let opts = ImportOptions {
mode: ImportMode::Upsert {
by: vec!["id".into()],
},
batch_size: 100,
};
let report = import_csv(csv.as_ref(), &opts, |_line, _row, _mode| {
ImportRowResult::Updated
});
assert_eq!(report.updated, 2);
assert_eq!(report.inserted, 0);
}
#[test]
fn import_csv_reports_error_at_exact_row_for_large_file() {
let target_line: usize = 27143;
let mut csv = String::from("value\n");
for i in 1..=target_line {
if i == target_line - 1 {
csv.push_str("BAD\n");
} else {
csv.push_str("good\n");
}
}
let report = import_csv(
csv.as_bytes(),
&ImportOptions::default(),
|_line, row, _mode| {
if row.get("value").map(String::as_str) == Some("BAD") {
ImportRowResult::RowError("value is BAD".into())
} else {
ImportRowResult::Inserted
}
},
);
assert_eq!(report.errors.len(), 1, "exactly one error expected");
assert!(!report.errors.is_empty());
assert_eq!(report.errors[0].message, "value is BAD");
assert_eq!(report.errors[0].line, 27_143);
}
#[test]
fn import_csv_skipped_rows_counted() {
let csv = b"status\nactive\narchived\nactive\n";
let report = import_csv(
csv.as_ref(),
&ImportOptions::default(),
|_line, row, _mode| {
if row.get("status").map(String::as_str) == Some("archived") {
ImportRowResult::Skipped
} else {
ImportRowResult::Inserted
}
},
);
assert_eq!(report.inserted, 2);
assert_eq!(report.skipped, 1);
assert_eq!(report.total_rows(), 3);
}
#[test]
fn import_options_default_is_insert_batch_500() {
let opts = ImportOptions::default();
assert!(matches!(opts.mode, ImportMode::Insert));
assert_eq!(opts.batch_size, 500);
}
#[test]
fn export_then_import_round_trips_data() {
let posts = sample_posts();
let mut exported = Vec::new();
export_csv(posts, &mut exported).unwrap();
let mut titles_imported = Vec::new();
import_csv(
exported.as_slice(),
&ImportOptions::default(),
|_line, row, _mode| {
titles_imported.push(row.get("title").cloned().unwrap_or_default());
ImportRowResult::Inserted
},
);
assert_eq!(
titles_imported,
vec!["Hello, World", "Goodbye cruel \"world\""]
);
}
}