Skip to content

Instantly share code, notes, and snippets.

@skull-squadron
Last active October 5, 2025 09:44
Show Gist options
  • Save skull-squadron/71be2f027172b5b0594c363c50c950d6 to your computer and use it in GitHub Desktop.
Save skull-squadron/71be2f027172b5b0594c363c50c950d6 to your computer and use it in GitHub Desktop.
Easy Rust CSV serialization with serde::Serialize
use anyhow::{Result, bail};
use csv::{Writer, WriterBuilder};
use serde::Serialize;
pub trait ToCsv {
fn to_csv(self) -> Result<String>;
fn to_csv_without_header(self) -> Result<String>;
fn to_csv_with_header<I, T>(self, header: I) -> Result<String>
where
I: IntoIterator<Item = T> + Clone,
T: AsRef<[u8]>;
}
impl<C: Iterator + Clone> ToCsv for C
where
C::Item: Serialize,
{
fn to_csv(self) -> Result<String> {
let mut wr = Writer::from_writer(vec![]);
for rec in self {
wr.serialize(rec)?;
}
let bytes = wr.into_inner()?;
Ok(String::from_utf8(bytes)?)
}
fn to_csv_without_header(self) -> Result<String> {
let mut wr = WriterBuilder::new().has_headers(false).from_writer(vec![]);
for rec in self {
wr.serialize(rec)?;
}
let bytes = wr.into_inner()?;
Ok(String::from_utf8(bytes)?)
}
fn to_csv_with_header<I, T>(self, header: I) -> Result<String>
where
I: IntoIterator<Item = T> + Clone,
T: AsRef<[u8]>,
{
if header.clone().into_iter().count() != self.clone().count() {
bail!("header length must equal number of records");
}
let mut wr = WriterBuilder::new().has_headers(false).from_writer(vec![]);
wr.write_record(header)?;
for rec in self {
wr.serialize(rec)?;
}
let bytes = wr.into_inner()?;
Ok(String::from_utf8(bytes)?)
}
}
#[cfg(test)]
mod test {
use super::*;
#[derive(Serialize)]
pub struct Record {
bar: String,
baz: f32,
fizz: i64,
}
impl Record {
pub fn new(bar: impl AsRef<str>, baz: f32, fizz: i64) -> Self {
Self {
bar: bar.as_ref().into(),
baz,
fizz,
}
}
}
fn data() -> Vec<Record> {
vec![
Record::new("a", 1.0, -9),
Record::new("b", -3.0, 5),
Record::new("c", 13.5, -7),
]
}
const EXPECTED: &str = "\
Bar,Baz,Fizz\n\
a,1.0,-9\n\
b,-3.0,5\n\
c,13.5,-7\n";
#[test]
fn test_to_csv_with_headers() {
let actual = data()
.iter()
.to_csv_with_header(&["Bar", "Baz", "Fizz"])
.unwrap();
assert_eq!(actual, EXPECTED);
}
#[test]
fn test_to_csv_without_headers() {
let actual = data().iter().to_csv_without_header().unwrap();
assert_eq!(
actual,
EXPECTED.lines().skip(1).collect::<Vec<_>>().join("\n") + "\n"
);
}
#[test]
#[should_panic(expected = "header length must equal number of records")]
fn test_to_csv_with_bad_headers() {
data().iter().to_csv_with_header(&["wrong"]).unwrap();
}
#[test]
fn test_to_csv_no_header() {
let actual = data().iter().to_csv().unwrap();
assert_eq!(actual, EXPECTED.to_lowercase());
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment