use crate::{Error, Result};
pub(crate) fn format_csv<T: std::fmt::Display>(
data: &[T],
shape: &[usize],
delimiter: char,
) -> Result<String> {
match shape.len() {
1 => {
let n = shape[0];
if data.len() != n {
return Err(Error::Format(format!(
"data length {} does not match shape [{n}]",
data.len()
)));
}
let mut out = String::new();
for item in data {
use std::fmt::Write;
let _ = writeln!(out, "{item}");
}
Ok(out)
}
2 => {
let rows = shape[0];
let cols = shape[1];
if data.len() != rows * cols {
return Err(Error::Format(format!(
"data length {} does not match shape [{rows}, {cols}]",
data.len()
)));
}
let mut out = String::new();
for r in 0..rows {
for c in 0..cols {
if c > 0 {
out.push(delimiter);
}
use std::fmt::Write;
let _ = write!(out, "{}", data[r * cols + c]);
}
out.push('\n');
}
Ok(out)
}
ndim => Err(Error::Format(format!(
"Expected 1D or 2D array, got {ndim}D array instead"
))),
}
}
pub(crate) fn parse_csv<T>(
text: &str,
delimiter: char,
skip_header: usize,
) -> Result<(Vec<T>, Vec<usize>)>
where
T: std::str::FromStr,
<T as std::str::FromStr>::Err: std::fmt::Display,
{
let mut data = Vec::new();
let mut n_rows = 0usize;
let mut expected_cols: Option<usize> = None;
for (raw_line_idx, raw_line) in text.lines().enumerate() {
let line_num = raw_line_idx + 1;
if raw_line_idx < skip_header {
continue;
}
let line = if let Some(comment_pos) = raw_line.find('#') {
&raw_line[..comment_pos]
} else {
raw_line
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let tokens: Vec<&str> = if delimiter == ' ' {
trimmed.split_whitespace().collect()
} else {
trimmed.split(delimiter).map(|s| s.trim()).collect()
};
let cols = tokens.len();
if let Some(exp) = expected_cols {
if cols != exp {
return Err(Error::Parse(format!(
"line {line_num}: expected {exp} columns, got {cols} (ragged CSV)"
)));
}
} else {
expected_cols = Some(cols);
}
for (col_idx, token) in tokens.into_iter().enumerate() {
let val = token.parse::<T>().map_err(|e| {
Error::Parse(format!(
"line {line_num}: could not parse '{token}' at column {}: {e}",
col_idx + 1
))
})?;
data.push(val);
}
n_rows += 1;
}
let shape = match (n_rows, expected_cols) {
(0, _) => vec![0],
(1, Some(cols)) => vec![cols],
(rows, Some(1)) => vec![rows],
(rows, Some(cols)) => vec![rows, cols],
_ => unreachable!(),
};
Ok((data, shape))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_and_parse_1d() {
let data = vec![1.0_f64, 2.5, -3.75, 4.0];
let shape = vec![4];
let csv = format_csv(&data, &shape, ',').unwrap();
assert_eq!(csv, "1\n2.5\n-3.75\n4\n");
let (parsed_data, parsed_shape) = parse_csv::<f64>(&csv, ',', 0).unwrap();
assert_eq!(parsed_data, data);
assert_eq!(parsed_shape, shape);
}
#[test]
fn format_and_parse_2d() {
let data = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0];
let shape = vec![2, 3];
let csv = format_csv(&data, &shape, ',').unwrap();
assert_eq!(csv, "1,2,3\n4,5,6\n");
let (parsed_data, parsed_shape) = parse_csv::<f64>(&csv, ',', 0).unwrap();
assert_eq!(parsed_data, data);
assert_eq!(parsed_shape, shape);
}
#[test]
fn format_and_parse_i64() {
let data = vec![10i64, -20, 30, -40];
let shape = vec![2, 2];
let csv = format_csv(&data, &shape, ';').unwrap();
assert_eq!(csv, "10;-20\n30;-40\n");
let (parsed_data, parsed_shape) = parse_csv::<i64>(&csv, ';', 0).unwrap();
assert_eq!(parsed_data, data);
assert_eq!(parsed_shape, shape);
}
#[test]
fn custom_delimiter_tab_and_space() {
let text_tab = "1.0\t2.0\n3.0\t4.0\n";
let (data, shape) = parse_csv::<f64>(text_tab, '\t', 0).unwrap();
assert_eq!(data, vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(shape, vec![2, 2]);
let text_space = " 1.0 2.0 \n 3.0 4.0 \n";
let (data2, shape2) = parse_csv::<f64>(text_space, ' ', 0).unwrap();
assert_eq!(data2, vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(shape2, vec![2, 2]);
}
#[test]
fn skip_header_option() {
let text = "col1,col2\nunits,units\n1.0,2.0\n3.0,4.0\n";
let (data, shape) = parse_csv::<f64>(text, ',', 2).unwrap();
assert_eq!(data, vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(shape, vec![2, 2]);
}
#[test]
fn comments_and_blank_lines() {
let text = r#"
# Starting comment
# Another comment line
1.0, 2.0 # inline comment 1
# Intermediate comment
3.0, 4.0
# Trailing comment
"#;
let (data, shape) = parse_csv::<f64>(text, ',', 0).unwrap();
assert_eq!(data, vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(shape, vec![2, 2]);
}
#[test]
fn parse_error_has_line_number() {
let text = "1.0, 2.0\n3.0, not_a_number\n5.0, 6.0\n";
let err = parse_csv::<f64>(text, ',', 0).unwrap_err();
match err {
Error::Parse(msg) => {
assert!(
msg.contains("line 2"),
"error message should contain line 2: {msg}"
);
assert!(
msg.contains("not_a_number"),
"error message should contain bad token: {msg}"
);
}
_ => panic!("expected Error::Parse, got {err:?}"),
}
}
#[test]
fn ragged_rows_error_has_line_number() {
let text = "1.0, 2.0, 3.0\n4.0, 5.0\n";
let err = parse_csv::<f64>(text, ',', 0).unwrap_err();
match err {
Error::Parse(msg) => {
assert!(msg.contains("line 2"), "error should specify line 2: {msg}");
assert!(msg.contains("ragged"), "error should mention ragged: {msg}");
}
_ => panic!("expected Error::Parse, got {err:?}"),
}
}
#[test]
fn format_csv_unsupported_ndim() {
let err = format_csv(&[1.0], &[], ',').unwrap_err();
assert!(matches!(err, Error::Format(_)));
let err3d = format_csv(&[1.0; 8], &[2, 2, 2], ',').unwrap_err();
assert!(matches!(err3d, Error::Format(_)));
}
#[test]
fn empty_csv_handling() {
let (data, shape) = parse_csv::<f64>("", ',', 0).unwrap();
assert!(data.is_empty());
assert_eq!(shape, vec![0]);
let (data_comment, shape_comment) = parse_csv::<f64>("# nothing here\n\n", ',', 0).unwrap();
assert!(data_comment.is_empty());
assert_eq!(shape_comment, vec![0]);
let formatted = format_csv::<f64>(&[], &[0], ',').unwrap();
assert_eq!(formatted, "");
}
#[test]
fn python_csv_round_trip() {
use std::process::Command;
let py_available = Command::new("python")
.args(["-c", "import numpy; print(numpy.__version__)"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !py_available {
eprintln!("SKIP: python/numpy not available for CSV round-trip test");
return;
}
let dir = std::env::temp_dir().join("rustnum_csv_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("py_saved.csv");
let py_write = format!(
r#"
import numpy as np
a = np.array([[1.5, 2.5, 3.5], [4.5, 5.5, 6.5]])
np.savetxt(r'{}', a, delimiter=',', fmt='%.1f')
"#,
path.display()
);
let out = Command::new("python")
.args(["-c", &py_write])
.output()
.unwrap();
assert!(out.status.success());
let content = std::fs::read_to_string(&path).unwrap();
let (data, shape) = parse_csv::<f64>(&content, ',', 0).unwrap();
assert_eq!(shape, vec![2, 3]);
assert_eq!(data, vec![1.5, 2.5, 3.5, 4.5, 5.5, 6.5]);
let rust_csv = format_csv(&data, &shape, ',').unwrap();
let rust_path = dir.join("rust_saved.csv");
std::fs::write(&rust_path, rust_csv).unwrap();
let py_read = format!(
r#"
import numpy as np
loaded = np.loadtxt(r'{}', delimiter=',')
expected = np.array([[1.5, 2.5, 3.5], [4.5, 5.5, 6.5]])
assert np.allclose(loaded, expected), f"mismatch: {{loaded}} vs {{expected}}"
"#,
rust_path.display()
);
let out2 = Command::new("python")
.args(["-c", &py_read])
.output()
.unwrap();
assert!(
out2.status.success(),
"Python loadtxt failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
}
}