use std::fmt::Write as _;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub enum HeaderStyle {
#[default]
Plain,
Commented,
None,
}
#[derive(Debug, Clone)]
pub struct CsvWriter<'a> {
columns: Vec<(String, &'a [f64])>,
comments: Vec<String>,
delimiter: String,
comment_prefix: String,
header: HeaderStyle,
}
impl Default for CsvWriter<'_> {
fn default() -> Self {
Self::new()
}
}
impl<'a> CsvWriter<'a> {
pub fn new() -> Self {
CsvWriter {
columns: Vec::new(),
comments: Vec::new(),
delimiter: ",".into(),
comment_prefix: "%".into(),
header: HeaderStyle::Plain,
}
}
pub fn column(mut self, name: impl Into<String>, values: &'a [f64]) -> Self {
self.columns.push((name.into(), values));
self
}
pub fn comment(mut self, text: impl Into<String>) -> Self {
self.comments.push(text.into());
self
}
pub fn delimiter(mut self, delimiter: impl Into<String>) -> Self {
self.delimiter = delimiter.into();
self
}
pub fn comment_prefix(mut self, prefix: impl Into<String>) -> Self {
self.comment_prefix = prefix.into();
self
}
pub fn header(mut self, style: HeaderStyle) -> Self {
self.header = style;
self
}
fn validate(&self) -> Result<()> {
if self.delimiter.is_empty() {
return Err(Error::Invalid("CSV delimiter is empty".into()));
}
if (!self.comments.is_empty() || self.header == HeaderStyle::Commented)
&& self.comment_prefix.is_empty()
{
return Err(Error::Invalid("CSV comment prefix is empty".into()));
}
if let Some((first, rest)) = self.columns.split_first() {
let n = first.1.len();
if let Some((name, v)) = rest.iter().find(|(_, v)| v.len() != n) {
return Err(Error::Invalid(format!(
"CSV column {name:?} has {} rows, column {:?} has {n}",
v.len(),
first.0
)));
}
}
Ok(())
}
pub fn write_to<W: Write>(&self, mut w: W) -> Result<()> {
self.validate()?;
let mut line = String::new();
for text in &self.comments {
for l in text.lines() {
line.clear();
line.push_str(&self.comment_prefix);
if !l.is_empty() {
line.push(' ');
line.push_str(l);
}
line.push('\n');
w.write_all(line.as_bytes())?;
}
}
if self.header != HeaderStyle::None && !self.columns.is_empty() {
line.clear();
if self.header == HeaderStyle::Commented {
line.push_str(&self.comment_prefix);
line.push(' ');
}
for (n, (name, _)) in self.columns.iter().enumerate() {
if n > 0 {
line.push_str(&self.delimiter);
}
line.push_str(name);
}
line.push('\n');
w.write_all(line.as_bytes())?;
}
let rows = self.columns.first().map_or(0, |c| c.1.len());
for r in 0..rows {
line.clear();
for (n, (_, v)) in self.columns.iter().enumerate() {
if n > 0 {
line.push_str(&self.delimiter);
}
write!(line, "{:e}", v[r]).ok();
}
line.push('\n');
w.write_all(line.as_bytes())?;
}
Ok(())
}
pub fn write(&self, path: impl AsRef<Path>) -> Result<()> {
self.validate()?;
let mut w = BufWriter::new(File::create(path)?);
self.write_to(&mut w)?;
w.flush()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(w: &CsvWriter<'_>) -> String {
let mut out = Vec::new();
w.write_to(&mut out).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn basic_layout() {
let f = [1e9, 2e9];
let re = [0.5, -1.0];
let im = [0.0, 3.25e-7];
let s = render(
&CsvWriter::new()
.comment_prefix("#")
.comment("S11\nZ0 = 50")
.delimiter("\t")
.column("f", &f)
.column("re", &re)
.column("im", &im),
);
assert_eq!(
s,
"# S11\n# Z0 = 50\nf\tre\tim\n1e9\t5e-1\t0e0\n2e9\t-1e0\t3.25e-7\n"
);
}
#[test]
fn header_styles() {
let t = [1.0];
let base = CsvWriter::new().column("t", &t);
assert_eq!(render(&base.clone()), "t\n1e0\n");
assert_eq!(
render(&base.clone().header(HeaderStyle::Commented)),
"% t\n1e0\n"
);
assert_eq!(render(&base.header(HeaderStyle::None)), "1e0\n");
}
#[test]
fn float_round_trip() {
let vals = [
0.0,
-0.0,
1.0,
0.1,
1.0 / 3.0,
std::f64::consts::PI,
f64::MIN_POSITIVE,
f64::MAX,
-f64::EPSILON,
5e-324,
1.234_567_890_123_456_7e-300,
299_792_458.0,
];
let s = render(
&CsvWriter::new()
.header(HeaderStyle::None)
.column("v", &vals),
);
let back: Vec<f64> = s.lines().map(|l| l.parse().unwrap()).collect();
assert_eq!(back.len(), vals.len());
for (a, b) in vals.iter().zip(&back) {
assert_eq!(a.to_bits(), b.to_bits(), "{a:e} vs {b:e}");
}
}
#[test]
fn non_finite() {
let v = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
let s = render(&CsvWriter::new().header(HeaderStyle::None).column("v", &v));
assert_eq!(s, "NaN\ninf\n-inf\n");
let back: Vec<f64> = s.lines().map(|l| l.parse().unwrap()).collect();
assert!(back[0].is_nan() && back[1] == f64::INFINITY && back[2] == f64::NEG_INFINITY);
}
#[test]
fn errors() {
let a = [1.0, 2.0];
let b = [1.0];
let mut out = Vec::new();
let e = CsvWriter::new()
.column("a", &a)
.column("b", &b)
.write_to(&mut out);
assert!(matches!(e, Err(Error::Invalid(_))));
assert!(out.is_empty());
let e = CsvWriter::new()
.delimiter("")
.column("a", &a)
.write_to(&mut out);
assert!(matches!(e, Err(Error::Invalid(_))));
let e = CsvWriter::new()
.comment_prefix("")
.comment("x")
.write_to(&mut out);
assert!(matches!(e, Err(Error::Invalid(_))));
}
#[test]
fn write_to_path() {
let dir = std::env::temp_dir().join(format!("ems-csv-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("probe.csv");
let t = [0.0, 1.0];
CsvWriter::new().column("t", &t).write(&path).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "t\n0e0\n1e0\n");
std::fs::remove_dir_all(&dir).unwrap();
}
}