Skip to main content

oxiphysics_io/csv/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use super::functions::*;
6use crate::{Error, Result};
7use std::fs::{File, OpenOptions};
8use std::io::{BufRead, BufReader, BufWriter, Write};
9use std::path::Path;
10
11/// Configuration for [`ConfigurableCsvWriter`].
12#[derive(Debug, Clone)]
13pub struct CsvWriterConfig {
14    /// Field delimiter (default `,`).
15    pub delimiter: char,
16    /// Line ending (default `\n`).
17    pub line_ending: String,
18    /// Whether to quote all fields (default `false`).
19    pub quote_all: bool,
20    /// Float precision (default 6 decimal places).
21    pub precision: usize,
22}
23/// Full RFC-4180-compliant CSV parser.
24///
25/// Handles:
26/// - Quoted fields containing delimiters, newlines and escaped quotes (`""`)
27/// - Backslash escape sequences inside quoted fields (`\"`, `\\`, `\n`, `\t`, `\r`)
28/// - Multi-line records (newlines inside quoted fields)
29/// - Comment lines (optional, configurable prefix)
30/// - Configurable delimiter
31///
32/// # Example
33///
34/// ```no_run
35/// use oxiphysics_io::csv::CsvParser;
36///
37/// let data = "name,notes\nAlice,\"line1\nline2\"\nBob,\"he said \"\"hi\"\"\"\n";
38/// let parser = CsvParser::new(data, ',');
39/// let records = parser.parse_all().unwrap();
40/// assert_eq!(records[0].get(0), "name");
41/// assert_eq!(records[1].get(1), "line1\nline2");
42/// assert_eq!(records[2].get(1), "he said \"hi\"");
43/// ```
44pub struct CsvParser<'a> {
45    pub(super) input: &'a str,
46    pub(super) delimiter: char,
47    pub(super) comment_prefix: Option<char>,
48}
49impl<'a> CsvParser<'a> {
50    /// Create a new parser for `input` with the given `delimiter`.
51    pub fn new(input: &'a str, delimiter: char) -> Self {
52        Self {
53            input,
54            delimiter,
55            comment_prefix: None,
56        }
57    }
58    /// Enable comment-line skipping with the given prefix character (e.g. `'#'`).
59    pub fn with_comment_prefix(mut self, prefix: char) -> Self {
60        self.comment_prefix = Some(prefix);
61        self
62    }
63    /// Parse all records from the input.
64    ///
65    /// Returns a `Vec`CsvRecord` where the first record is usually the header.
66    pub fn parse_all(self) -> std::result::Result<Vec<CsvRecord>, Error> {
67        let mut records = Vec::new();
68        let mut chars = self.input.chars().peekable();
69        'outer: loop {
70            if chars.peek().is_none() {
71                break;
72            }
73            let mut fields: Vec<String> = Vec::new();
74            let mut field = String::new();
75            let mut in_quotes = false;
76            loop {
77                match chars.next() {
78                    None => {
79                        if in_quotes {
80                            return Err(Error::Parse(
81                                "unterminated quoted field at EOF".to_string(),
82                            ));
83                        }
84                        fields.push(field);
85                        break;
86                    }
87                    Some('"') if !in_quotes => {
88                        in_quotes = true;
89                    }
90                    Some('"') if in_quotes => {
91                        if chars.peek() == Some(&'"') {
92                            chars.next();
93                            field.push('"');
94                        } else {
95                            in_quotes = false;
96                        }
97                    }
98                    Some('\\') if in_quotes => match chars.next() {
99                        Some('n') => field.push('\n'),
100                        Some('t') => field.push('\t'),
101                        Some('r') => field.push('\r'),
102                        Some('"') => field.push('"'),
103                        Some('\\') => field.push('\\'),
104                        Some(c) => {
105                            field.push('\\');
106                            field.push(c);
107                        }
108                        None => {
109                            return Err(Error::Parse("trailing backslash at EOF".to_string()));
110                        }
111                    },
112                    Some('\r') if !in_quotes => {
113                        if chars.peek() == Some(&'\n') {
114                            chars.next();
115                        }
116                        fields.push(field);
117                        break;
118                    }
119                    Some('\n') if !in_quotes => {
120                        fields.push(field);
121                        break;
122                    }
123                    Some(c) if c == self.delimiter && !in_quotes => {
124                        fields.push(field.clone());
125                        field = String::new();
126                    }
127                    Some(c) => {
128                        field.push(c);
129                    }
130                }
131            }
132            if let Some(prefix) = self.comment_prefix
133                && fields
134                    .first()
135                    .map(|f| f.trim_start().starts_with(prefix))
136                    .unwrap_or(false)
137            {
138                continue 'outer;
139            }
140            if fields.len() == 1 && fields[0].trim().is_empty() {
141                continue 'outer;
142            }
143            records.push(CsvRecord { fields });
144        }
145        Ok(records)
146    }
147}
148/// A parsed CSV record (one logical row, possibly spanning multiple physical lines).
149#[derive(Debug, Clone, PartialEq)]
150pub struct CsvRecord {
151    /// Raw field strings (unquoted, unescape-handled).
152    pub fields: Vec<String>,
153}
154impl CsvRecord {
155    /// Number of fields in this record.
156    pub fn len(&self) -> usize {
157        self.fields.len()
158    }
159    /// Returns `true` if this record has no fields.
160    pub fn is_empty(&self) -> bool {
161        self.fields.is_empty()
162    }
163    /// Get a field by index, returning an empty string for out-of-range indices.
164    pub fn get(&self, index: usize) -> &str {
165        self.fields.get(index).map(|s| s.as_str()).unwrap_or("")
166    }
167}
168/// Aggregation function for pivot table values.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum PivotAgg {
171    /// Sum of values.
172    Sum,
173    /// Arithmetic mean.
174    Mean,
175    /// Count of non-empty values.
176    Count,
177    /// Minimum value.
178    Min,
179    /// Maximum value.
180    Max,
181}
182/// An in-memory table of string-valued cells.
183///
184/// Columns are named; rows are `Vec`String`. Provides the foundation for
185/// type inference, merge/join, pivot, and diff operations.
186#[derive(Debug, Clone)]
187pub struct CsvTable {
188    /// Column headers.
189    pub headers: Vec<String>,
190    /// Data rows (each row has `headers.len()` fields, padded with `""` if short).
191    pub rows: Vec<Vec<String>>,
192}
193impl CsvTable {
194    /// Create an empty table with the given headers.
195    pub fn new(headers: Vec<String>) -> Self {
196        Self {
197            headers,
198            rows: Vec::new(),
199        }
200    }
201    /// Parse a CSV string into a `CsvTable` using the given delimiter.
202    pub fn parse(data: &str, delimiter: char) -> std::result::Result<Self, Error> {
203        let parser = CsvParser::new(data, delimiter).with_comment_prefix('#');
204        let mut records = parser.parse_all()?;
205        if records.is_empty() {
206            return Err(Error::Parse("CSV table is empty".to_string()));
207        }
208        let header_rec = records.remove(0);
209        let headers: Vec<String> = header_rec
210            .fields
211            .iter()
212            .map(|s| s.trim().to_string())
213            .collect();
214        let ncols = headers.len();
215        let mut rows = Vec::new();
216        for rec in records {
217            let mut row: Vec<String> = rec
218                .fields
219                .into_iter()
220                .map(|s| s.trim().to_string())
221                .collect();
222            while row.len() < ncols {
223                row.push(String::new());
224            }
225            rows.push(row);
226        }
227        Ok(Self { headers, rows })
228    }
229    /// Serialize back to a CSV string with the given delimiter.
230    pub fn to_csv_string(&self, delimiter: char) -> String {
231        let mut out = String::new();
232        out.push_str(&self.headers.join(&delimiter.to_string()));
233        out.push('\n');
234        for row in &self.rows {
235            let line: Vec<String> = row.iter().map(|f| quote_field(f, delimiter)).collect();
236            out.push_str(&line.join(&delimiter.to_string()));
237            out.push('\n');
238        }
239        out
240    }
241    /// Look up a column index by name.
242    pub fn column_index(&self, name: &str) -> std::result::Result<usize, Error> {
243        self.headers
244            .iter()
245            .position(|h| h == name)
246            .ok_or_else(|| Error::Parse(format!("column '{}' not found", name)))
247    }
248    /// Get all values from a named column as strings.
249    pub fn column_values(&self, name: &str) -> std::result::Result<Vec<&str>, Error> {
250        let idx = self.column_index(name)?;
251        Ok(self.rows.iter().map(|r| r[idx].as_str()).collect())
252    }
253    /// Get all values from a named column parsed as `f64`.
254    pub fn column_f64(&self, name: &str) -> std::result::Result<Vec<f64>, Error> {
255        let idx = self.column_index(name)?;
256        self.rows
257            .iter()
258            .enumerate()
259            .map(|(i, r)| {
260                let s = r[idx].trim();
261                if s.is_empty() {
262                    Ok(f64::NAN)
263                } else {
264                    s.parse::<f64>().map_err(|_| {
265                        Error::Parse(format!("row {}: cannot parse '{}' as f64", i, s))
266                    })
267                }
268            })
269            .collect()
270    }
271    /// Number of data rows (excluding header).
272    pub fn row_count(&self) -> usize {
273        self.rows.len()
274    }
275    /// Number of columns.
276    pub fn col_count(&self) -> usize {
277        self.headers.len()
278    }
279}
280/// In-memory CSV writer with configurable delimiter and floating-point precision.
281pub struct InMemoryCsvWriter {
282    pub(super) columns: Vec<String>,
283    pub(super) delimiter: char,
284    pub(super) precision: usize,
285    pub(super) rows: Vec<Vec<f64>>,
286}
287impl InMemoryCsvWriter {
288    /// Create a new `InMemoryCsvWriter` with the given column names and delimiter.
289    pub fn new(columns: &[&str], delimiter: char) -> Self {
290        Self {
291            columns: columns.iter().map(|s| s.to_string()).collect(),
292            delimiter,
293            precision: 6,
294            rows: Vec::new(),
295        }
296    }
297    /// Set the floating-point precision used when formatting values.
298    pub fn with_precision(mut self, precision: usize) -> Self {
299        self.precision = precision;
300        self
301    }
302    /// Format a single row of values as a delimited string (no newline).
303    pub fn write_row(&self, values: &[f64]) -> std::result::Result<String, Error> {
304        if values.len() != self.columns.len() {
305            return Err(Error::Parse(format!(
306                "expected {} values, got {}",
307                self.columns.len(),
308                values.len()
309            )));
310        }
311        let parts: Vec<String> = values
312            .iter()
313            .map(|v| format!("{:.prec$}", v, prec = self.precision))
314            .collect();
315        Ok(parts.join(&self.delimiter.to_string()))
316    }
317    /// Return the header line (no newline).
318    pub fn write_header(&self) -> String {
319        self.columns.join(&self.delimiter.to_string())
320    }
321    /// Accumulate a row.
322    pub fn add_row(&mut self, values: Vec<f64>) {
323        self.rows.push(values);
324    }
325    /// Render the entire accumulated dataset (header + all rows) as a String.
326    pub fn write_all(&self, rows: &[Vec<f64>]) -> String {
327        let mut out = self.write_header();
328        out.push('\n');
329        for row in rows {
330            if let Ok(line) = self.write_row(row) {
331                out.push_str(&line);
332                out.push('\n');
333            }
334        }
335        out
336    }
337}
338/// Reader for CSV files with numeric data.
339pub struct CsvReader;
340impl CsvReader {
341    /// Read a CSV file, returning `(headers, rows)`.
342    ///
343    /// Assumes the first line is a header and all subsequent lines are numeric.
344    pub fn read(path: &str) -> Result<(Vec<String>, Vec<Vec<f64>>)> {
345        let file = File::open(Path::new(path))?;
346        let reader = BufReader::new(file);
347        let mut lines = reader.lines();
348        let header_line = lines
349            .next()
350            .ok_or_else(|| Error::Parse("empty CSV file".to_string()))??;
351        let headers: Vec<String> = header_line
352            .split(',')
353            .map(|s| s.trim().to_string())
354            .collect();
355        let mut rows = Vec::new();
356        for line in lines {
357            let line = line?;
358            let trimmed = line.trim();
359            if trimmed.is_empty() {
360                continue;
361            }
362            let row: std::result::Result<Vec<f64>, _> = trimmed
363                .split(',')
364                .map(|s| s.trim().parse::<f64>())
365                .collect();
366            let row = row.map_err(|e| Error::Parse(e.to_string()))?;
367            rows.push(row);
368        }
369        Ok((headers, rows))
370    }
371}
372/// Inferred column type after scanning all values in a column.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub enum ColumnType {
375    /// All non-empty values parse as integers.
376    Integer,
377    /// All non-empty values parse as floats (but not all as integers).
378    Float,
379    /// All non-empty values are "true"/"false"/"yes"/"no"/"1"/"0".
380    Boolean,
381    /// At least one value cannot be parsed as a number or boolean.
382    Text,
383    /// The column contains only empty values.
384    Empty,
385}
386/// A row-at-a-time streaming CSV parser for large files.
387///
388/// Reads the file line by line with minimal memory allocation. Quoted
389/// multi-line fields are assembled across physical lines before yielding a
390/// logical record.
391///
392/// # Example
393///
394/// ```no_run
395/// use oxiphysics_io::csv::CsvStreamParser;
396///
397/// let mut parser = CsvStreamParser::open("/tmp/big.csv", ',').unwrap();
398/// let headers = parser.headers().to_vec();
399/// while let Some(record) = parser.next_record().unwrap() {
400///     // process record.fields
401///     let _ = record.len();
402/// }
403/// ```
404pub struct CsvStreamParser {
405    pub(super) reader: BufReader<File>,
406    pub(super) delimiter: char,
407    pub(super) headers: Vec<String>,
408    /// Buffer for partial multi-line records across physical lines.
409    pub(super) pending: String,
410    pub(super) open_quotes: bool,
411}
412impl CsvStreamParser {
413    /// Open a CSV file for streaming parsing.
414    pub fn open(path: &str, delimiter: char) -> std::result::Result<Self, Error> {
415        let file = File::open(Path::new(path))?;
416        let mut reader = BufReader::new(file);
417        let mut header_line = String::new();
418        reader.read_line(&mut header_line)?;
419        let headers = split_csv_line(
420            header_line.trim_end_matches('\n').trim_end_matches('\r'),
421            delimiter,
422        )
423        .into_iter()
424        .map(|s| s.trim().to_string())
425        .collect();
426        Ok(Self {
427            reader,
428            delimiter,
429            headers,
430            pending: String::new(),
431            open_quotes: false,
432        })
433    }
434    /// Return the column headers read from the first row.
435    pub fn headers(&self) -> &[String] {
436        &self.headers
437    }
438    /// Read and return the next logical CSV record.
439    ///
440    /// Returns `Ok(None)` at EOF, `Err(_)` on I/O or parse errors.
441    pub fn next_record(&mut self) -> std::result::Result<Option<CsvRecord>, Error> {
442        loop {
443            let mut line = String::new();
444            let bytes_read = self.reader.read_line(&mut line)?;
445            if bytes_read == 0 {
446                if self.pending.is_empty() {
447                    return Ok(None);
448                }
449                if self.open_quotes {
450                    return Err(Error::Parse("unterminated quoted field at EOF".to_string()));
451                }
452                let record = self.flush_pending()?;
453                return Ok(Some(record));
454            }
455            for ch in line.chars() {
456                if ch == '"' {
457                    self.open_quotes = !self.open_quotes;
458                }
459            }
460            self.pending.push_str(&line);
461            if !self.open_quotes {
462                let record = self.flush_pending()?;
463                if record.fields.len() == 1 && record.fields[0].trim().is_empty() {
464                    continue;
465                }
466                return Ok(Some(record));
467            }
468        }
469    }
470    fn flush_pending(&mut self) -> std::result::Result<CsvRecord, Error> {
471        let line = std::mem::take(&mut self.pending);
472        let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
473        let fields = split_csv_line(trimmed, self.delimiter);
474        Ok(CsvRecord {
475            fields: fields.into_iter().map(|s| s.trim().to_string()).collect(),
476        })
477    }
478}
479/// In-memory CSV reader that parses a complete CSV string.
480///
481/// Supports:
482/// - configurable delimiter
483/// - comment lines starting with `#`
484/// - empty fields (treated as `f64::NAN`)
485/// - quoted strings (double-quoted fields with escaped commas)
486pub struct InMemoryCsvReader {
487    pub(super) headers: Vec<String>,
488    pub(super) rows: Vec<Vec<Option<f64>>>,
489}
490impl InMemoryCsvReader {
491    /// Parse a CSV string with a custom delimiter.
492    pub fn parse_with_delimiter(data: &str, delim: char) -> std::result::Result<Self, Error> {
493        let mut non_empty_lines: Vec<&str> = data
494            .lines()
495            .filter(|l| {
496                let t = l.trim();
497                !t.is_empty() && !t.starts_with('#')
498            })
499            .collect();
500        if non_empty_lines.is_empty() {
501            return Err(Error::Parse("CSV input is empty".to_string()));
502        }
503        let header_line = non_empty_lines.remove(0);
504        let headers: Vec<String> = split_csv_line(header_line, delim)
505            .into_iter()
506            .map(|s| s.trim().to_string())
507            .collect();
508        if headers.is_empty() {
509            return Err(Error::Parse("no headers found".to_string()));
510        }
511        let mut rows: Vec<Vec<Option<f64>>> = Vec::new();
512        for line in &non_empty_lines {
513            let fields = split_csv_line(line, delim);
514            let parsed: Vec<Option<f64>> = fields
515                .iter()
516                .map(|f| {
517                    let t = f.trim();
518                    if t.is_empty() {
519                        None
520                    } else {
521                        t.parse::<f64>().ok()
522                    }
523                })
524                .collect();
525            rows.push(parsed);
526        }
527        Ok(Self { headers, rows })
528    }
529    /// Return the values in the named column as `f64`.
530    ///
531    /// Missing (`None`) values are replaced with `f64::NAN`.
532    pub fn get_column_f64(&self, name: &str) -> std::result::Result<Vec<f64>, Error> {
533        let idx = self
534            .headers
535            .iter()
536            .position(|h| h == name)
537            .ok_or_else(|| Error::Parse(format!("column '{}' not found", name)))?;
538        let col: Vec<f64> = self
539            .rows
540            .iter()
541            .map(|row| row.get(idx).copied().flatten().unwrap_or(f64::NAN))
542            .collect();
543        Ok(col)
544    }
545    /// Return the number of data rows (excluding header).
546    pub fn get_row_count(&self) -> usize {
547        self.rows.len()
548    }
549    /// Return the column headers.
550    pub fn headers(&self) -> &[String] {
551        &self.headers
552    }
553    /// Compute (min, max, mean, std) for a named column, ignoring NaN values.
554    pub fn column_stats(&self, name: &str) -> std::result::Result<(f64, f64, f64, f64), Error> {
555        let col = self.get_column_f64(name)?;
556        let valid: Vec<f64> = col.into_iter().filter(|v| !v.is_nan()).collect();
557        if valid.is_empty() {
558            return Err(Error::Parse(format!(
559                "column '{}' has no valid numeric data",
560                name
561            )));
562        }
563        let n = valid.len() as f64;
564        let min = valid.iter().cloned().fold(f64::INFINITY, f64::min);
565        let max = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
566        let mean = valid.iter().sum::<f64>() / n;
567        let variance = valid.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
568        let std = variance.sqrt();
569        Ok((min, max, mean, std))
570    }
571}
572impl std::str::FromStr for InMemoryCsvReader {
573    type Err = Error;
574    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
575        Self::parse_with_delimiter(s, ',')
576    }
577}
578/// A CSV reader that exposes per-column typed access after type inference.
579///
580/// # Example
581///
582/// ```no_run
583/// use oxiphysics_io::csv::TypedCsvReader;
584///
585/// let data = "id,x,active\n1,3.14,true\n2,2.71,false\n";
586/// let reader = data.parse::<TypedCsvReader>().unwrap();
587/// let ids = reader.column_as_i64("id").unwrap();
588/// assert_eq!(ids, vec![1, 2]);
589/// let xs = reader.column_as_f64("x").unwrap();
590/// assert!((xs[0] - 3.14).abs() < 1e-10);
591/// let active = reader.column_as_bool("active").unwrap();
592/// assert_eq!(active, vec![true, false]);
593/// ```
594pub struct TypedCsvReader {
595    pub(super) table: CsvTable,
596}
597impl TypedCsvReader {
598    /// Parse a CSV string with a custom delimiter.
599    pub fn with_delimiter(data: &str, delimiter: char) -> std::result::Result<Self, Error> {
600        let table = CsvTable::parse(data, delimiter)?;
601        Ok(Self { table })
602    }
603    /// Return the inferred type of a named column.
604    pub fn column_type(&self, name: &str) -> std::result::Result<ColumnType, Error> {
605        let idx = self.table.column_index(name)?;
606        let values: Vec<&str> = self.table.rows.iter().map(|r| r[idx].as_str()).collect();
607        Ok(infer_column_type(&values))
608    }
609    /// Return a named column parsed as `i64`.
610    pub fn column_as_i64(&self, name: &str) -> std::result::Result<Vec<i64>, Error> {
611        let idx = self.table.column_index(name)?;
612        self.table
613            .rows
614            .iter()
615            .enumerate()
616            .map(|(i, r)| {
617                let s = r[idx].trim();
618                s.parse::<i64>()
619                    .map_err(|_| Error::Parse(format!("row {}: cannot parse '{}' as i64", i, s)))
620            })
621            .collect()
622    }
623    /// Return a named column parsed as `f64`.
624    pub fn column_as_f64(&self, name: &str) -> std::result::Result<Vec<f64>, Error> {
625        self.table.column_f64(name)
626    }
627    /// Return a named column parsed as `bool`.
628    ///
629    /// Accepts "true"/"1"/"yes" → `true`; "false"/"0"/"no" → `false`.
630    pub fn column_as_bool(&self, name: &str) -> std::result::Result<Vec<bool>, Error> {
631        let idx = self.table.column_index(name)?;
632        self.table
633            .rows
634            .iter()
635            .enumerate()
636            .map(|(i, r)| {
637                let s = r[idx].trim().to_lowercase();
638                match s.as_str() {
639                    "true" | "1" | "yes" => Ok(true),
640                    "false" | "0" | "no" => Ok(false),
641                    other => Err(Error::Parse(format!(
642                        "row {}: cannot parse '{}' as bool",
643                        i, other
644                    ))),
645                }
646            })
647            .collect()
648    }
649    /// Return the underlying table reference.
650    pub fn table(&self) -> &CsvTable {
651        &self.table
652    }
653    /// Return the column headers.
654    pub fn headers(&self) -> &[String] {
655        &self.table.headers
656    }
657    /// Number of data rows.
658    pub fn row_count(&self) -> usize {
659        self.table.row_count()
660    }
661}
662impl std::str::FromStr for TypedCsvReader {
663    type Err = Error;
664    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
665        let table = CsvTable::parse(s, ',')?;
666        Ok(Self { table })
667    }
668}
669/// The result of comparing two CSV datasets.
670#[derive(Debug, Clone)]
671pub struct CsvDiff {
672    /// Rows present in `left` but not in `right` (by key column value).
673    pub removed: Vec<Vec<String>>,
674    /// Rows present in `right` but not in `left`.
675    pub added: Vec<Vec<String>>,
676    /// Rows where the key exists in both but non-key columns differ.
677    pub changed: Vec<CsvChangedRow>,
678}
679/// Append-mode CSV writer for time series data.
680pub struct CsvWriter {
681    pub(super) writer: BufWriter<File>,
682}
683impl CsvWriter {
684    /// Create a new CSV file with the given headers.
685    pub fn new(path: &str, headers: &[&str]) -> Result<Self> {
686        let file = File::create(Path::new(path))?;
687        let mut writer = BufWriter::new(file);
688        writeln!(writer, "{}", headers.join(","))?;
689        writer.flush()?;
690        let file = OpenOptions::new().append(true).open(Path::new(path))?;
691        let writer = BufWriter::new(file);
692        Ok(Self { writer })
693    }
694    /// Append one row of values.
695    pub fn write_row(&mut self, values: &[f64]) -> Result<()> {
696        let row: Vec<String> = values.iter().map(|v| v.to_string()).collect();
697        writeln!(self.writer, "{}", row.join(","))?;
698        self.writer.flush()?;
699        Ok(())
700    }
701}
702/// A single changed row in a [`CsvDiff`].
703#[derive(Debug, Clone)]
704pub struct CsvChangedRow {
705    /// The key value identifying this row.
706    pub key: String,
707    /// The row as it appeared in the `left` table.
708    pub before: Vec<String>,
709    /// The row as it appeared in the `right` table.
710    pub after: Vec<String>,
711}
712/// CSV writer with configurable delimiter, quoting, line endings and precision.
713///
714/// # Example
715///
716/// ```no_run
717/// use oxiphysics_io::csv::{ConfigurableCsvWriter, CsvWriterConfig};
718///
719/// let cfg = CsvWriterConfig { delimiter: ';', precision: 3, ..Default::default() };
720/// let mut w = ConfigurableCsvWriter::new(cfg);
721/// w.write_header(&["x", "y"]);
722/// w.write_f64_row(&[1.0, 2.5]);
723/// let out = w.finish();
724/// assert!(out.starts_with("x;y"));
725/// assert!(out.contains("1.000;2.500"));
726/// ```
727pub struct ConfigurableCsvWriter {
728    pub(super) config: CsvWriterConfig,
729    pub(super) buffer: String,
730}
731impl ConfigurableCsvWriter {
732    /// Create a new writer with the given configuration.
733    pub fn new(config: CsvWriterConfig) -> Self {
734        Self {
735            config,
736            buffer: String::new(),
737        }
738    }
739    /// Write a header row from string slices.
740    pub fn write_header(&mut self, headers: &[&str]) {
741        let line = if self.config.quote_all {
742            headers
743                .iter()
744                .map(|h| format!("\"{}\"", h.replace('"', "\"\"")))
745                .collect::<Vec<_>>()
746                .join(&self.config.delimiter.to_string())
747        } else {
748            headers
749                .iter()
750                .map(|h| quote_field(h, self.config.delimiter))
751                .collect::<Vec<_>>()
752                .join(&self.config.delimiter.to_string())
753        };
754        self.buffer.push_str(&line);
755        self.buffer.push_str(&self.config.line_ending);
756    }
757    /// Write a row of `f64` values.
758    pub fn write_f64_row(&mut self, values: &[f64]) {
759        let prec = self.config.precision;
760        let line: Vec<String> = values
761            .iter()
762            .map(|v| format!("{:.prec$}", v, prec = prec))
763            .collect();
764        self.buffer
765            .push_str(&line.join(&self.config.delimiter.to_string()));
766        self.buffer.push_str(&self.config.line_ending);
767    }
768    /// Write a row of string values.
769    pub fn write_str_row(&mut self, values: &[&str]) {
770        let delim = self.config.delimiter;
771        let line: Vec<String> = values
772            .iter()
773            .map(|v| {
774                if self.config.quote_all {
775                    format!("\"{}\"", v.replace('"', "\"\""))
776                } else {
777                    quote_field(v, delim)
778                }
779            })
780            .collect();
781        self.buffer
782            .push_str(&line.join(&self.config.delimiter.to_string()));
783        self.buffer.push_str(&self.config.line_ending);
784    }
785    /// Consume the writer and return the accumulated CSV string.
786    pub fn finish(self) -> String {
787        self.buffer
788    }
789}