Skip to main content

readstat/
rs_write.rs

1//! Output writers for converting Arrow [`RecordBatch`] data to CSV, Feather (Arrow IPC),
2//! NDJSON, or Parquet format.
3//!
4//! [`ReadStatWriter`] manages the lifecycle of format-specific writers, handling
5//! streaming writes across multiple batches. It also supports metadata output
6//! (pretty-printed or JSON), parallel CSV/NDJSON batch encoding, and native
7//! parallel Parquet column encoding.
8
9use arrow_array::RecordBatch;
10#[cfg(feature = "csv")]
11use arrow_csv::WriterBuilder as CsvWriterBuilder;
12#[cfg(feature = "feather")]
13use arrow_ipc::writer::FileWriter as IpcFileWriter;
14#[cfg(feature = "ndjson")]
15use arrow_json::LineDelimitedWriter as JsonLineDelimitedWriter;
16#[cfg(all(
17    test,
18    feature = "csv",
19    feature = "feather",
20    feature = "ndjson",
21    feature = "parquet"
22))]
23use arrow_schema::Schema;
24use arrow_schema::SchemaRef;
25#[cfg(feature = "parquet")]
26use parquet::{
27    arrow::{
28        ArrowWriter as ParquetArrowWriter,
29        arrow_writer::{
30            ArrowColumnChunk, ArrowLeafColumn, ArrowRowGroupWriterFactory, compute_leaves,
31        },
32    },
33    basic::Compression as ParquetCompressionCodec,
34    file::{properties::WriterProperties, writer::SerializedFileWriter},
35};
36#[cfg(all(
37    not(target_arch = "wasm32"),
38    any(feature = "parquet", feature = "csv", feature = "ndjson")
39))]
40use rayon::prelude::*;
41#[cfg(any(
42    feature = "csv",
43    feature = "feather",
44    feature = "ndjson",
45    feature = "parquet"
46))]
47use std::fs::File;
48#[cfg(any(
49    feature = "csv",
50    feature = "feather",
51    feature = "ndjson",
52    feature = "parquet"
53))]
54use std::io::BufWriter;
55#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
56use std::io::Write as _;
57#[cfg(feature = "csv")]
58use std::io::stdout;
59#[cfg(any(
60    feature = "csv",
61    feature = "feather",
62    feature = "ndjson",
63    feature = "parquet"
64))]
65use std::path::PathBuf;
66#[cfg(all(
67    test,
68    feature = "csv",
69    feature = "feather",
70    feature = "ndjson",
71    feature = "parquet"
72))]
73use std::sync::Arc;
74
75use crate::err::ReadStatError;
76#[cfg(any(
77    feature = "csv",
78    feature = "feather",
79    feature = "ndjson",
80    feature = "parquet"
81))]
82use crate::rs_write_config::OutFormat;
83#[cfg(feature = "parquet")]
84use crate::rs_write_config::ParquetCompression;
85use crate::rs_write_config::WriteConfig;
86
87#[cfg(any(
88    feature = "parquet",
89    all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32"))
90))]
91struct StagingGuard(Option<PathBuf>);
92
93#[cfg(any(
94    feature = "parquet",
95    all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32"))
96))]
97impl Drop for StagingGuard {
98    fn drop(&mut self) {
99        if let Some(path) = self.0.take() {
100            let _ = std::fs::remove_file(path);
101        }
102    }
103}
104
105/// Internal wrapper around the Parquet Arrow writer, allowing ownership transfer on close.
106#[cfg(feature = "parquet")]
107pub(crate) struct ReadStatParquetWriter {
108    wtr: Option<ParquetArrowWriter<BufWriter<std::fs::File>>>,
109}
110
111#[cfg(feature = "parquet")]
112impl ReadStatParquetWriter {
113    fn new(wtr: ParquetArrowWriter<BufWriter<std::fs::File>>) -> Self {
114        Self { wtr: Some(wtr) }
115    }
116}
117
118/// CSV/NDJSON writer that encodes independent batches concurrently and commits
119/// their bytes in input order.
120///
121/// Each call to [`write`](Self::write) is one bounded parallel work group. The
122/// caller controls memory by limiting the number and size of batches in that
123/// group. CSV emits exactly one header; NDJSON batches require no shared format
124/// state.
125#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
126pub struct ParallelTextWriter {
127    writer: Option<BufWriter<File>>,
128    schema: SchemaRef,
129    format: OutFormat,
130    wrote_batch: bool,
131    rows_written: usize,
132    staging_path: Option<PathBuf>,
133    destination: PathBuf,
134    overwrite: bool,
135}
136
137#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
138impl ParallelTextWriter {
139    /// Creates a transactional parallel CSV or NDJSON writer.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error for invalid output configuration or staging-file
144    /// creation failure.
145    pub fn new(config: WriteConfig, schema: SchemaRef) -> Result<Self, ReadStatError> {
146        config.validate()?;
147        let supported = match config.format {
148            #[cfg(feature = "csv")]
149            OutFormat::Csv => true,
150            #[cfg(feature = "ndjson")]
151            OutFormat::Ndjson => true,
152            _ => false,
153        };
154        if !supported {
155            return Err(ReadStatError::InvalidOutputConfig(
156                "parallel text writer requires CSV or NDJSON output".into(),
157            ));
158        }
159
160        let destination = config.out_path.clone().ok_or_else(|| {
161            ReadStatError::InvalidOutputConfig(
162                "parallel text writer requires an output file".into(),
163            )
164        })?;
165        let (file, staging_path) = crate::rs_write_config::open_output(&config)?;
166        let mut staging = StagingGuard(Some(staging_path));
167
168        Ok(Self {
169            writer: Some(BufWriter::new(file)),
170            schema,
171            format: config.format,
172            wrote_batch: false,
173            rows_written: 0,
174            staging_path: staging.0.take(),
175            destination,
176            overwrite: config.overwrite,
177        })
178    }
179
180    /// Encodes a bounded group of batches concurrently and writes the encoded
181    /// buffers in the same order as `batches`.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error for a schema mismatch, row-count overflow, text
186    /// encoding failure, or output I/O failure.
187    pub fn write(&mut self, batches: &[RecordBatch]) -> Result<(), ReadStatError> {
188        if batches.is_empty() {
189            return Ok(());
190        }
191        if batches.iter().any(|batch| batch.schema() != self.schema) {
192            return Err(ReadStatError::SchemaMismatch);
193        }
194        let next_rows = batches.iter().try_fold(self.rows_written, |rows, batch| {
195            rows.checked_add(batch.num_rows())
196                .ok_or_else(|| ReadStatError::Other("writer row count overflow".into()))
197        })?;
198
199        let include_header = !self.wrote_batch && matches!(self.format, OutFormat::Csv);
200        let format = self.format;
201        let encoded = batches
202            .par_iter()
203            .enumerate()
204            .map(|(index, batch)| encode_text_batch(format, batch, include_header && index == 0))
205            .collect::<Result<Vec<_>, _>>()?;
206
207        let write_result = {
208            let writer = self
209                .writer
210                .as_mut()
211                .ok_or_else(|| ReadStatError::Other("text writer is already closed".into()))?;
212            encoded
213                .into_iter()
214                .try_for_each(|bytes| writer.write_all(&bytes))
215        };
216        if let Err(error) = write_result {
217            // A failed write may already have modified the staging file. Poison
218            // the writer so finish() cannot publish truncated output; Drop
219            // removes the staging path.
220            self.writer = None;
221            return Err(error.into());
222        }
223        self.wrote_batch = true;
224        self.rows_written = next_rows;
225        Ok(())
226    }
227
228    /// Flushes and atomically publishes the output file. Returns the number of
229    /// accepted rows.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if empty-output encoding, flushing, or publication
234    /// fails.
235    pub fn finish(mut self) -> Result<usize, ReadStatError> {
236        if !self.wrote_batch && matches!(self.format, OutFormat::Csv) {
237            let empty = RecordBatch::new_empty(self.schema.clone());
238            self.write(std::slice::from_ref(&empty))?;
239        }
240        self.writer
241            .take()
242            .ok_or_else(|| ReadStatError::Other("text writer is already closed".into()))?
243            .flush()?;
244        let staging = self
245            .staging_path
246            .as_ref()
247            .expect("parallel text staging path is armed");
248        crate::rs_write_config::publish_staging(staging, &self.destination, self.overwrite)?;
249        self.staging_path = None;
250        Ok(self.rows_written)
251    }
252}
253
254#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
255impl Drop for ParallelTextWriter {
256    fn drop(&mut self) {
257        if let Some(path) = self.staging_path.take() {
258            self.writer = None;
259            let _ = std::fs::remove_file(path);
260        }
261    }
262}
263
264#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
265fn encode_text_batch(
266    format: OutFormat,
267    batch: &RecordBatch,
268    include_header: bool,
269) -> Result<Vec<u8>, ReadStatError> {
270    #[cfg(not(feature = "csv"))]
271    let _ = include_header;
272    let mut bytes = Vec::new();
273    match format {
274        #[cfg(feature = "csv")]
275        OutFormat::Csv => {
276            let mut writer = CsvWriterBuilder::new()
277                .with_header(include_header)
278                .build(&mut bytes);
279            writer.write(batch)?;
280        }
281        #[cfg(feature = "ndjson")]
282        OutFormat::Ndjson => {
283            let mut writer = JsonLineDelimitedWriter::new(&mut bytes);
284            writer.write(batch)?;
285            writer.finish()?;
286        }
287        _ => {
288            return Err(ReadStatError::InvalidOutputConfig(
289                "parallel text writer requires CSV or NDJSON output".into(),
290            ));
291        }
292    }
293    Ok(bytes)
294}
295
296/// Parquet writer that encodes columns concurrently and commits each row group
297/// once, in order, to a single output file.
298///
299/// Input batches remain ordered and memory is bounded by `row_group_rows` plus
300/// upstream buffering. Unlike temporary-file fan-out, encoded pages are copied
301/// directly into the final Parquet row group without decoding or re-encoding.
302#[cfg(feature = "parquet")]
303pub struct ParallelParquetWriter {
304    writer: Option<SerializedFileWriter<BufWriter<File>>>,
305    factory: ArrowRowGroupWriterFactory,
306    schema: SchemaRef,
307    pending: Vec<RecordBatch>,
308    pending_rows: usize,
309    row_group_rows: usize,
310    row_group_index: usize,
311    rows_written: usize,
312    staging_path: Option<PathBuf>,
313    destination: PathBuf,
314    overwrite: bool,
315}
316
317#[cfg(feature = "parquet")]
318impl ParallelParquetWriter {
319    /// Creates a native parallel Parquet writer.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error for invalid output configuration, a zero row-group
324    /// target, staging-file failures, or invalid Parquet properties.
325    pub fn new(
326        config: WriteConfig,
327        schema: SchemaRef,
328        row_group_rows: usize,
329    ) -> Result<Self, ReadStatError> {
330        config.validate()?;
331        if !matches!(config.format, OutFormat::Parquet) {
332            return Err(ReadStatError::InvalidOutputConfig(
333                "parallel Parquet writer requires Parquet output".into(),
334            ));
335        }
336        if row_group_rows == 0 {
337            return Err(ReadStatError::Other(
338                "Parquet row-group rows must be greater than zero".into(),
339            ));
340        }
341
342        let destination = config
343            .out_path
344            .clone()
345            .ok_or_else(|| ReadStatError::InvalidOutputConfig("Parquet requires output".into()))?;
346        let compression = crate::rs_write_config::resolve_parquet_compression(
347            config.compression,
348            config.compression_level,
349        )?;
350        let (file, staging_path) = crate::rs_write_config::open_output(&config)?;
351        let mut staging = StagingGuard(Some(staging_path));
352        let properties = WriterProperties::builder()
353            .set_compression(compression)
354            .set_statistics_enabled(parquet::file::properties::EnabledStatistics::Page)
355            .set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
356            .build();
357        let (writer, factory) =
358            ParquetArrowWriter::try_new(BufWriter::new(file), schema.clone(), Some(properties))?
359                .into_serialized_writer()?;
360
361        Ok(Self {
362            writer: Some(writer),
363            factory,
364            schema,
365            pending: Vec::new(),
366            pending_rows: 0,
367            row_group_rows,
368            row_group_index: 0,
369            rows_written: 0,
370            staging_path: staging.0.take(),
371            destination,
372            overwrite: config.overwrite,
373        })
374    }
375
376    /// Queues a batch and flushes complete row groups with parallel column
377    /// encoding. Batches crossing a row-group boundary are sliced without
378    /// copying their Arrow buffers.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error for a schema mismatch, row-count overflow, or Parquet
383    /// encoding/write failure.
384    pub fn write(&mut self, batch: &RecordBatch) -> Result<(), ReadStatError> {
385        if batch.schema() != self.schema {
386            return Err(ReadStatError::SchemaMismatch);
387        }
388        if self.schema.fields().is_empty() && batch.num_rows() != 0 {
389            return Err(ReadStatError::Other(
390                "Parquet cannot represent rows without columns".into(),
391            ));
392        }
393
394        let mut offset = 0;
395        while offset < batch.num_rows() {
396            let available = self.row_group_rows - self.pending_rows;
397            let rows = available.min(batch.num_rows() - offset);
398            self.pending.push(batch.slice(offset, rows));
399            self.pending_rows += rows;
400            offset += rows;
401            if self.pending_rows == self.row_group_rows {
402                self.flush_row_group()?;
403            }
404        }
405        self.rows_written = self
406            .rows_written
407            .checked_add(batch.num_rows())
408            .ok_or_else(|| ReadStatError::Other("writer row count overflow".into()))?;
409        Ok(())
410    }
411
412    fn flush_row_group(&mut self) -> Result<(), ReadStatError> {
413        if self.pending_rows == 0 {
414            return Ok(());
415        }
416
417        let column_writers = self.factory.create_column_writers(self.row_group_index)?;
418        let mut inputs: Vec<Vec<ArrowLeafColumn>> = (0..column_writers.len())
419            .map(|_| Vec::with_capacity(self.pending.len()))
420            .collect();
421
422        for batch in &self.pending {
423            let mut leaf_index = 0;
424            for (field, array) in self.schema.fields().iter().zip(batch.columns()) {
425                for leaf in compute_leaves(field.as_ref(), array)? {
426                    inputs[leaf_index].push(leaf);
427                    leaf_index += 1;
428                }
429            }
430            if leaf_index != column_writers.len() {
431                return Err(ReadStatError::Other(
432                    "computed Parquet leaf count does not match column writers".into(),
433                ));
434            }
435        }
436
437        #[cfg(not(target_arch = "wasm32"))]
438        let column_writers = column_writers.into_par_iter();
439        #[cfg(target_arch = "wasm32")]
440        let column_writers = column_writers.into_iter();
441        #[cfg(not(target_arch = "wasm32"))]
442        let inputs = inputs.into_par_iter();
443        #[cfg(target_arch = "wasm32")]
444        let inputs = inputs.into_iter();
445
446        let chunks: Vec<ArrowColumnChunk> = column_writers
447            .zip(inputs)
448            .map(|(mut writer, leaves)| {
449                for leaf in &leaves {
450                    writer.write(leaf)?;
451                }
452                writer.close()
453            })
454            .collect::<Result<Vec<_>, _>>()?;
455
456        let writer = self
457            .writer
458            .as_mut()
459            .ok_or_else(|| ReadStatError::Other("Parquet writer is already closed".into()))?;
460        let mut row_group = writer.next_row_group()?;
461        for chunk in chunks {
462            chunk.append_to_row_group(&mut row_group)?;
463        }
464        row_group.close()?;
465
466        self.pending.clear();
467        self.pending_rows = 0;
468        self.row_group_index += 1;
469        Ok(())
470    }
471
472    /// Flushes the final row group, writes the footer, and atomically publishes
473    /// the output file. Returns the number of accepted rows.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if encoding, finalization, or publication fails.
478    pub fn finish(mut self) -> Result<usize, ReadStatError> {
479        self.flush_row_group()?;
480        self.writer
481            .take()
482            .ok_or_else(|| ReadStatError::Other("Parquet writer is already closed".into()))?
483            .close()?;
484        let staging = self
485            .staging_path
486            .as_ref()
487            .expect("parallel Parquet staging path is armed");
488        crate::rs_write_config::publish_staging(staging, &self.destination, self.overwrite)?;
489        self.staging_path = None;
490        Ok(self.rows_written)
491    }
492}
493
494#[cfg(feature = "parquet")]
495impl Drop for ParallelParquetWriter {
496    fn drop(&mut self) {
497        if let Some(path) = self.staging_path.take() {
498            self.writer = None;
499            let _ = std::fs::remove_file(path);
500        }
501    }
502}
503
504/// Format-specific writer variant, created lazily on first write.
505pub(crate) enum ReadStatWriterFormat {
506    /// CSV writer to a file.
507    #[cfg(feature = "csv")]
508    Csv(BufWriter<std::fs::File>),
509    /// CSV writer to stdout (used for preview mode without an output file).
510    #[cfg(feature = "csv")]
511    CsvStdout(std::io::Stdout),
512    /// Feather (Arrow IPC) writer.
513    #[cfg(feature = "feather")]
514    Feather(IpcFileWriter<BufWriter<std::fs::File>>),
515    /// Newline-delimited JSON writer.
516    #[cfg(feature = "ndjson")]
517    Ndjson(BufWriter<std::fs::File>),
518    /// Parquet writer.
519    #[cfg(feature = "parquet")]
520    Parquet(ReadStatParquetWriter),
521}
522
523/// Manages writing Arrow [`RecordBatch`] data to the configured output format.
524///
525/// Supports streaming writes across multiple batches. The writer is created lazily
526/// on the first call to [`write`](ReadStatWriter::write) and finalized via
527/// [`finish`](ReadStatWriter::finish).
528// With no format features enabled the fields are written but never read.
529#[cfg_attr(
530    not(any(
531        feature = "csv",
532        feature = "parquet",
533        feature = "feather",
534        feature = "ndjson"
535    )),
536    allow(dead_code)
537)]
538pub struct ReadStatWriter {
539    /// The format-specific writer, created on first write.
540    pub(crate) wtr: Option<ReadStatWriterFormat>,
541    /// Whether the CSV header row has been written.
542    #[cfg(feature = "csv")]
543    pub(crate) wrote_header: bool,
544    /// Whether any data has been written (controls file creation vs. append).
545    pub(crate) wrote_start: bool,
546    config: WriteConfig,
547    schema: SchemaRef,
548    rows_written: usize,
549    #[cfg(any(
550        feature = "csv",
551        feature = "feather",
552        feature = "ndjson",
553        feature = "parquet"
554    ))]
555    staging_path: Option<PathBuf>,
556}
557
558impl ReadStatWriter {
559    /// Creates a new `ReadStatWriter` with no active writer.
560    pub fn new(config: WriteConfig, schema: SchemaRef) -> Result<Self, ReadStatError> {
561        config.validate()?;
562        Ok(Self {
563            wtr: None,
564            #[cfg(feature = "csv")]
565            wrote_header: false,
566            wrote_start: false,
567            config,
568            schema,
569            rows_written: 0,
570            #[cfg(any(
571                feature = "csv",
572                feature = "feather",
573                feature = "ndjson",
574                feature = "parquet"
575            ))]
576            staging_path: None,
577        })
578    }
579
580    /// Opens a sibling staging file. Called exactly once per output; successful
581    /// finalization publishes it to the configured destination.
582    #[cfg(any(
583        feature = "csv",
584        feature = "feather",
585        feature = "ndjson",
586        feature = "parquet"
587    ))]
588    fn open_output(&mut self, wc: &WriteConfig) -> Result<File, ReadStatError> {
589        debug_assert!(!self.wrote_start, "output file opened twice");
590        let (file, staging_path) = crate::rs_write_config::open_output(wc)?;
591        self.staging_path = Some(staging_path);
592        Ok(file)
593    }
594
595    #[cfg(feature = "parquet")]
596    fn resolve_compression(
597        compression: Option<ParquetCompression>,
598        compression_level: Option<u32>,
599    ) -> Result<ParquetCompressionCodec, ReadStatError> {
600        crate::rs_write_config::resolve_parquet_compression(compression, compression_level)
601    }
602
603    /// Finalizes the writer, flushing and closing the underlying format writer.
604    ///
605    /// Returns the total number of successfully written rows. Consuming the
606    /// writer makes finalization a one-shot operation. The library does not
607    /// print anything; callers own user-facing summary output.
608    ///
609    /// # Errors
610    ///
611    /// Returns an error if the underlying writer fails to flush or close,
612    /// or if the output format is not enabled.
613    #[allow(unused_variables)]
614    pub fn finish(mut self) -> Result<usize, ReadStatError> {
615        // Initialize even for zero rows, producing a schema-carrying output.
616        if !self.wrote_start {
617            self.write(&RecordBatch::new_empty(self.schema.clone()))?;
618        }
619        match self.config.format {
620            #[cfg(feature = "csv")]
621            OutFormat::Csv => {
622                // Explicitly flush: relying on BufWriter's Drop would silently
623                // discard I/O errors (e.g. disk full), reporting success over
624                // a truncated file.
625                self.flush_buffered()?;
626                self.publish()
627            }
628            #[cfg(feature = "feather")]
629            OutFormat::Feather => {
630                self.finish_feather()?;
631                self.publish()
632            }
633            #[cfg(feature = "ndjson")]
634            OutFormat::Ndjson => {
635                self.flush_buffered()?;
636                self.publish()
637            }
638            #[cfg(feature = "parquet")]
639            OutFormat::Parquet => {
640                self.finish_parquet()?;
641                self.publish()
642            }
643            #[allow(unreachable_patterns)]
644            _ => Err(ReadStatError::Other(format!(
645                "Output format {:?} is not enabled. Enable the corresponding feature flag.",
646                self.config.format
647            ))),
648        }
649    }
650
651    #[cfg(any(
652        feature = "csv",
653        feature = "feather",
654        feature = "ndjson",
655        feature = "parquet"
656    ))]
657    fn publish(&mut self) -> Result<usize, ReadStatError> {
658        let Some(staging) = self.staging_path.take() else {
659            return Ok(self.rows_written);
660        };
661        // Close every handle before publication (required by Windows too).
662        self.wtr = None;
663        let destination = self
664            .config
665            .out_path
666            .as_ref()
667            .expect("staging has destination");
668        let result =
669            crate::rs_write_config::publish_staging(&staging, destination, self.config.overwrite);
670        if result.is_err() {
671            let _ = std::fs::remove_file(&staging);
672        }
673        result?;
674        Ok(self.rows_written)
675    }
676
677    /// Flushes the buffered file writer for formats (CSV, NDJSON) whose
678    /// underlying [`BufWriter`] would otherwise flush silently in `Drop`.
679    #[cfg(any(feature = "csv", feature = "ndjson"))]
680    fn flush_buffered(&mut self) -> Result<(), ReadStatError> {
681        use std::io::Write;
682        match &mut self.wtr {
683            #[cfg(feature = "csv")]
684            Some(ReadStatWriterFormat::Csv(f)) => f.flush()?,
685            #[cfg(feature = "csv")]
686            Some(ReadStatWriterFormat::CsvStdout(f)) => f.flush()?,
687            #[cfg(feature = "ndjson")]
688            Some(ReadStatWriterFormat::Ndjson(f)) => f.flush()?,
689            _ => {}
690        }
691        Ok(())
692    }
693
694    #[cfg(feature = "feather")]
695    fn finish_feather(&mut self) -> Result<(), ReadStatError> {
696        if let Some(ReadStatWriterFormat::Feather(wtr)) = &mut self.wtr {
697            wtr.finish()?;
698            Ok(())
699        } else {
700            Err(ReadStatError::Other(
701                "Error writing feather as associated writer is not for the feather format"
702                    .to_string(),
703            ))
704        }
705    }
706
707    #[cfg(feature = "parquet")]
708    fn finish_parquet(&mut self) -> Result<(), ReadStatError> {
709        if let Some(ReadStatWriterFormat::Parquet(pwtr)) = &mut self.wtr {
710            if let Some(wtr) = pwtr.wtr.take() {
711                wtr.close()?;
712            }
713            Ok(())
714        } else {
715            Err(ReadStatError::Other(
716                "Error writing parquet as associated writer is not for the parquet format"
717                    .to_string(),
718            ))
719        }
720    }
721
722    /// Writes a single batch of data in the format determined by `wc`.
723    ///
724    /// Handles writer initialization on first call and CSV header writing.
725    ///
726    /// # Errors
727    ///
728    /// Returns an error if the output file cannot be opened, writing fails,
729    /// or the output format is not enabled.
730    #[allow(unused_variables)]
731    pub fn write(&mut self, batch: &RecordBatch) -> Result<(), ReadStatError> {
732        if batch.schema() != self.schema {
733            return Err(ReadStatError::SchemaMismatch);
734        }
735        let wc = self.config.clone();
736        match wc.format {
737            #[cfg(feature = "csv")]
738            OutFormat::Csv => {
739                if wc.out_path.is_none() {
740                    if self.wrote_header {
741                        self.write_data_to_stdout(batch)
742                    } else {
743                        self.write_header_to_stdout()?;
744                        self.write_data_to_stdout(batch)
745                    }
746                } else {
747                    self.write_data_to_csv(batch, &wc)
748                }
749            }
750            #[cfg(feature = "feather")]
751            OutFormat::Feather => self.write_data_to_feather(batch, &wc),
752            #[cfg(feature = "ndjson")]
753            OutFormat::Ndjson => self.write_data_to_ndjson(batch, &wc),
754            #[cfg(feature = "parquet")]
755            OutFormat::Parquet => self.write_data_to_parquet(batch, &wc),
756            #[allow(unreachable_patterns)]
757            _ => Err(ReadStatError::Other(format!(
758                "Output format {:?} is not enabled. Enable the corresponding feature flag.",
759                wc.format
760            ))),
761        }?;
762        self.rows_written = self
763            .rows_written
764            .checked_add(batch.num_rows())
765            .ok_or_else(|| ReadStatError::Other("writer row count overflow".into()))?;
766        Ok(())
767    }
768
769    #[cfg(feature = "csv")]
770    fn write_data_to_csv(
771        &mut self,
772        batch: &RecordBatch,
773        wc: &WriteConfig,
774    ) -> Result<(), ReadStatError> {
775        if wc.out_path.is_some() {
776            // Open the file only on the first batch; later batches reuse the
777            // open writer. Opening (and immediately dropping) the handle on
778            // every batch was wasted syscalls.
779            if !self.wrote_start {
780                let f = self.open_output(wc)?;
781                self.wtr = Some(ReadStatWriterFormat::Csv(BufWriter::new(f)));
782            }
783
784            // write
785            if let Some(ReadStatWriterFormat::Csv(f)) = &mut self.wtr {
786                let include_header = !self.wrote_header;
787                let mut writer = CsvWriterBuilder::new().with_header(include_header).build(f);
788                writer.write(batch)?;
789                self.wrote_header = true;
790
791                self.wrote_start = true;
792                Ok(())
793            } else {
794                Err(ReadStatError::Other(
795                    "Error writing csv as associated writer is not for the csv format".to_string(),
796                ))
797            }
798        } else {
799            Err(ReadStatError::Other(
800                "Error writing csv as output path is set to None".to_string(),
801            ))
802        }
803    }
804
805    #[cfg(feature = "feather")]
806    fn write_data_to_feather(
807        &mut self,
808        batch: &RecordBatch,
809        wc: &WriteConfig,
810    ) -> Result<(), ReadStatError> {
811        if wc.out_path.is_some() {
812            // Open the file only on the first batch (see write_data_to_csv).
813            if !self.wrote_start {
814                let f = self.open_output(wc)?;
815                let wtr = IpcFileWriter::try_new(BufWriter::new(f), &self.schema)?;
816                self.wtr = Some(ReadStatWriterFormat::Feather(wtr));
817            }
818
819            // write
820            if let Some(ReadStatWriterFormat::Feather(wtr)) = &mut self.wtr {
821                wtr.write(batch)?;
822
823                self.wrote_start = true;
824
825                Ok(())
826            } else {
827                Err(ReadStatError::Other(
828                    "Error writing feather as associated writer is not for the feather format"
829                        .to_string(),
830                ))
831            }
832        } else {
833            Err(ReadStatError::Other(
834                "Error writing feather file as output path is set to None".to_string(),
835            ))
836        }
837    }
838
839    #[cfg(feature = "ndjson")]
840    fn write_data_to_ndjson(
841        &mut self,
842        batch: &RecordBatch,
843        wc: &WriteConfig,
844    ) -> Result<(), ReadStatError> {
845        if wc.out_path.is_some() {
846            // Open the file only on the first batch (see write_data_to_csv).
847            if !self.wrote_start {
848                let f = self.open_output(wc)?;
849                self.wtr = Some(ReadStatWriterFormat::Ndjson(BufWriter::new(f)));
850            }
851
852            // write
853            if let Some(ReadStatWriterFormat::Ndjson(f)) = &mut self.wtr {
854                let mut writer = JsonLineDelimitedWriter::new(f);
855                writer.write(batch)?;
856                writer.finish()?;
857
858                self.wrote_start = true;
859
860                Ok(())
861            } else {
862                Err(ReadStatError::Other(
863                    "Error writing ndjson as associated writer is not for the ndjson format"
864                        .to_string(),
865                ))
866            }
867        } else {
868            Err(ReadStatError::Other(
869                "Error writing ndjson file as output path is set to None".to_string(),
870            ))
871        }
872    }
873
874    #[cfg(feature = "parquet")]
875    fn write_data_to_parquet(
876        &mut self,
877        batch: &RecordBatch,
878        wc: &WriteConfig,
879    ) -> Result<(), ReadStatError> {
880        if self.schema.fields().is_empty() && batch.num_rows() != 0 {
881            return Err(ReadStatError::Other(
882                "Parquet cannot represent rows without columns".into(),
883            ));
884        }
885        if wc.out_path.is_some() {
886            // setup writer — open the file only on the first batch (see
887            // write_data_to_csv).
888            if !self.wrote_start {
889                let f = self.open_output(wc)?;
890                let compression_codec =
891                    Self::resolve_compression(wc.compression, wc.compression_level)?;
892
893                let props = WriterProperties::builder()
894                    .set_compression(compression_codec)
895                    .set_statistics_enabled(parquet::file::properties::EnabledStatistics::Page)
896                    .set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
897                    .build();
898
899                let wtr = ParquetArrowWriter::try_new(
900                    BufWriter::new(f),
901                    self.schema.clone(),
902                    Some(props),
903                )?;
904
905                self.wtr = Some(ReadStatWriterFormat::Parquet(ReadStatParquetWriter::new(
906                    wtr,
907                )));
908            }
909
910            // write
911            if let Some(ReadStatWriterFormat::Parquet(pwtr)) = &mut self.wtr {
912                if let Some(ref mut wtr) = pwtr.wtr {
913                    wtr.write(batch)?;
914                }
915
916                self.wrote_start = true;
917
918                Ok(())
919            } else {
920                Err(ReadStatError::Other(
921                    "Error writing parquet as associated writer is not for the parquet format"
922                        .to_string(),
923                ))
924            }
925        } else {
926            Err(ReadStatError::Other(
927                "Error writing parquet file as output path is set to None".to_string(),
928            ))
929        }
930    }
931
932    #[cfg(feature = "csv")]
933    fn write_data_to_stdout(&mut self, batch: &RecordBatch) -> Result<(), ReadStatError> {
934        // writer setup
935        if !self.wrote_start {
936            self.wtr = Some(ReadStatWriterFormat::CsvStdout(stdout()));
937        }
938
939        // write
940        if let Some(ReadStatWriterFormat::CsvStdout(f)) = &mut self.wtr {
941            let mut writer = CsvWriterBuilder::new().with_header(false).build(f);
942            writer.write(batch)?;
943
944            self.wrote_start = true;
945
946            Ok(())
947        } else {
948            Err(ReadStatError::Other(
949                "Error writing to csv as associated writer is not for the csv format".to_string(),
950            ))
951        }
952    }
953
954    #[cfg(feature = "csv")]
955    fn write_header_to_stdout(&mut self) -> Result<(), ReadStatError> {
956        use std::io::Write;
957
958        // CSV-escape each name so the header stays well-formed and column-aligned
959        // with the (already-escaped) data rows. Variable names may legally contain
960        // commas or quotes under SAS `VALIDVARNAME=ANY`.
961        let header = self
962            .schema
963            .fields()
964            .iter()
965            .map(|field| csv_escape_field(field.name()))
966            .collect::<Vec<_>>()
967            .join(",");
968
969        // writeln! (not println!): a closed pipe (e.g. `... | head`) must
970        // surface as an I/O error, not a panic.
971        writeln!(stdout(), "{header}")?;
972
973        self.wrote_header = true;
974
975        Ok(())
976    }
977}
978
979impl Drop for ReadStatWriter {
980    fn drop(&mut self) {
981        #[cfg(any(
982            feature = "csv",
983            feature = "feather",
984            feature = "ndjson",
985            feature = "parquet"
986        ))]
987        if let Some(path) = self.staging_path.take() {
988            // Drop format writers/file handles before attempting cleanup.
989            self.wtr = None;
990            let _ = std::fs::remove_file(path);
991        }
992    }
993}
994
995/// Escapes a single CSV field per RFC 4180: if it contains a comma, double
996/// quote, CR, or LF, wrap it in double quotes and double any interior quotes.
997#[cfg(feature = "csv")]
998fn csv_escape_field(field: &str) -> String {
999    if field.contains([',', '"', '\n', '\r']) {
1000        format!("\"{}\"", field.replace('"', "\"\""))
1001    } else {
1002        field.to_string()
1003    }
1004}
1005
1006/// Serialize a [`RecordBatch`] to CSV bytes (with header).
1007///
1008/// # Errors
1009///
1010/// Returns an error if CSV writing fails.
1011#[cfg(feature = "csv")]
1012pub fn write_batch_to_csv_bytes(
1013    batch: &arrow_array::RecordBatch,
1014) -> Result<Vec<u8>, ReadStatError> {
1015    let mut buf = Vec::new();
1016    let mut writer = CsvWriterBuilder::new().with_header(true).build(&mut buf);
1017    writer.write(batch)?;
1018    drop(writer);
1019    Ok(buf)
1020}
1021
1022/// Serialize a [`RecordBatch`] to NDJSON bytes.
1023///
1024/// # Errors
1025///
1026/// Returns an error if JSON writing fails.
1027#[cfg(feature = "ndjson")]
1028pub fn write_batch_to_ndjson_bytes(
1029    batch: &arrow_array::RecordBatch,
1030) -> Result<Vec<u8>, ReadStatError> {
1031    let mut buf = Vec::new();
1032    let mut writer = JsonLineDelimitedWriter::new(&mut buf);
1033    writer.write(batch)?;
1034    writer.finish()?;
1035    Ok(buf)
1036}
1037
1038/// Serialize a [`RecordBatch`] to Parquet bytes with Snappy compression.
1039///
1040/// # Errors
1041///
1042/// Returns an error if Parquet writing fails.
1043#[cfg(feature = "parquet")]
1044pub fn write_batch_to_parquet_bytes(batch: &RecordBatch) -> Result<Vec<u8>, ReadStatError> {
1045    let mut buf = Vec::new();
1046    let props = WriterProperties::builder()
1047        .set_compression(ParquetCompressionCodec::SNAPPY)
1048        .build();
1049    let mut writer = ParquetArrowWriter::try_new(&mut buf, batch.schema(), Some(props))?;
1050    writer.write(batch)?;
1051    writer.close()?;
1052    Ok(buf)
1053}
1054
1055/// Serialize a [`RecordBatch`] to Feather (Arrow IPC) bytes.
1056///
1057/// # Errors
1058///
1059/// Returns an error if Feather/IPC writing fails.
1060#[cfg(feature = "feather")]
1061pub fn write_batch_to_feather_bytes(
1062    batch: &arrow_array::RecordBatch,
1063) -> Result<Vec<u8>, ReadStatError> {
1064    let mut buf = Vec::new();
1065    let mut writer = IpcFileWriter::try_new(&mut buf, &batch.schema())?;
1066    writer.write(batch)?;
1067    writer.finish()?;
1068    Ok(buf)
1069}
1070
1071// These lifecycle tests exercise every writer backend together. Keep the
1072// module aligned with that contract so minimal-feature test builds remain a
1073// valid supported configuration.
1074#[cfg(all(
1075    test,
1076    feature = "csv",
1077    feature = "feather",
1078    feature = "ndjson",
1079    feature = "parquet"
1080))]
1081mod tests {
1082    use super::*;
1083
1084    // --- resolve_compression ---
1085
1086    #[test]
1087    fn resolve_compression_none_defaults_to_snappy() {
1088        let codec = ReadStatWriter::resolve_compression(None, None).unwrap();
1089        assert!(matches!(codec, ParquetCompressionCodec::SNAPPY));
1090    }
1091
1092    #[test]
1093    fn resolve_compression_uncompressed() {
1094        let codec =
1095            ReadStatWriter::resolve_compression(Some(ParquetCompression::Uncompressed), None)
1096                .unwrap();
1097        assert!(matches!(codec, ParquetCompressionCodec::UNCOMPRESSED));
1098    }
1099
1100    #[test]
1101    fn resolve_compression_snappy() {
1102        let codec =
1103            ReadStatWriter::resolve_compression(Some(ParquetCompression::Snappy), None).unwrap();
1104        assert!(matches!(codec, ParquetCompressionCodec::SNAPPY));
1105    }
1106
1107    #[test]
1108    fn resolve_compression_lz4raw() {
1109        let codec =
1110            ReadStatWriter::resolve_compression(Some(ParquetCompression::Lz4Raw), None).unwrap();
1111        assert!(matches!(codec, ParquetCompressionCodec::LZ4_RAW));
1112    }
1113
1114    #[test]
1115    fn resolve_compression_gzip_default() {
1116        let codec =
1117            ReadStatWriter::resolve_compression(Some(ParquetCompression::Gzip), None).unwrap();
1118        assert!(matches!(codec, ParquetCompressionCodec::GZIP(_)));
1119    }
1120
1121    #[test]
1122    fn resolve_compression_gzip_with_level() {
1123        let codec =
1124            ReadStatWriter::resolve_compression(Some(ParquetCompression::Gzip), Some(5)).unwrap();
1125        assert!(matches!(codec, ParquetCompressionCodec::GZIP(_)));
1126    }
1127
1128    #[test]
1129    fn resolve_compression_brotli_default() {
1130        let codec =
1131            ReadStatWriter::resolve_compression(Some(ParquetCompression::Brotli), None).unwrap();
1132        assert!(matches!(codec, ParquetCompressionCodec::BROTLI(_)));
1133    }
1134
1135    #[test]
1136    fn resolve_compression_brotli_with_level() {
1137        let codec =
1138            ReadStatWriter::resolve_compression(Some(ParquetCompression::Brotli), Some(8)).unwrap();
1139        assert!(matches!(codec, ParquetCompressionCodec::BROTLI(_)));
1140    }
1141
1142    #[test]
1143    fn resolve_compression_zstd_default() {
1144        let codec =
1145            ReadStatWriter::resolve_compression(Some(ParquetCompression::Zstd), None).unwrap();
1146        assert!(matches!(codec, ParquetCompressionCodec::ZSTD(_)));
1147    }
1148
1149    #[test]
1150    fn resolve_compression_zstd_with_level() {
1151        let codec =
1152            ReadStatWriter::resolve_compression(Some(ParquetCompression::Zstd), Some(15)).unwrap();
1153        assert!(matches!(codec, ParquetCompressionCodec::ZSTD(_)));
1154    }
1155
1156    // --- ReadStatWriter::new ---
1157
1158    #[test]
1159    fn new_writer_defaults() {
1160        let wtr = ReadStatWriter::new(WriteConfig::new(OutFormat::Csv), Arc::new(Schema::empty()))
1161            .unwrap();
1162        assert!(wtr.wtr.is_none());
1163        assert!(!wtr.wrote_header);
1164        assert!(!wtr.wrote_start);
1165    }
1166
1167    fn test_batch(schema: SchemaRef, values: &[&str]) -> RecordBatch {
1168        RecordBatch::try_new(
1169            schema,
1170            vec![Arc::new(arrow_array::StringArray::from(values.to_vec()))],
1171        )
1172        .unwrap()
1173    }
1174
1175    #[test]
1176    fn multi_batch_rows_and_consuming_finish() {
1177        let dir = tempfile::tempdir().unwrap();
1178        let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1179            "x",
1180            arrow_schema::DataType::Utf8,
1181            false,
1182        )]));
1183        let config = WriteConfig::new(OutFormat::Csv)
1184            .output(dir.path().join("rows.csv"))
1185            .unwrap();
1186        let mut writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1187        writer
1188            .write(&test_batch(schema.clone(), &["a", "b"]))
1189            .unwrap();
1190        writer.write(&test_batch(schema.clone(), &["c"])).unwrap();
1191        assert_eq!(writer.finish().unwrap(), 3);
1192    }
1193
1194    #[test]
1195    fn parallel_csv_matches_serial_bytes_and_row_count() {
1196        let dir = tempfile::tempdir().unwrap();
1197        let serial_path = dir.path().join("serial.csv");
1198        let parallel_path = dir.path().join("parallel.csv");
1199        let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1200            "x",
1201            arrow_schema::DataType::Utf8,
1202            false,
1203        )]));
1204        let empty = test_batch(schema.clone(), &[]);
1205        let first = test_batch(schema.clone(), &["a", "b"]);
1206        let second = test_batch(schema.clone(), &["c"]);
1207
1208        let mut serial = ReadStatWriter::new(
1209            WriteConfig::new(OutFormat::Csv)
1210                .output(&serial_path)
1211                .unwrap(),
1212            schema.clone(),
1213        )
1214        .unwrap();
1215        for batch in [&empty, &first, &second] {
1216            serial.write(batch).unwrap();
1217        }
1218        assert_eq!(serial.finish().unwrap(), 3);
1219
1220        let mut parallel = ParallelTextWriter::new(
1221            WriteConfig::new(OutFormat::Csv)
1222                .output(&parallel_path)
1223                .unwrap(),
1224            schema,
1225        )
1226        .unwrap();
1227        parallel.write(std::slice::from_ref(&empty)).unwrap();
1228        parallel.write(std::slice::from_ref(&first)).unwrap();
1229        parallel.write(std::slice::from_ref(&second)).unwrap();
1230        assert_eq!(parallel.finish().unwrap(), 3);
1231
1232        assert_eq!(
1233            std::fs::read(serial_path).unwrap(),
1234            std::fs::read(parallel_path).unwrap()
1235        );
1236    }
1237
1238    #[test]
1239    fn parallel_csv_schema_error_preserves_destination_and_cleans_staging() {
1240        let dir = tempfile::tempdir().unwrap();
1241        let path = dir.path().join("preserve.csv");
1242        std::fs::write(&path, "sentinel").unwrap();
1243        let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1244            "x",
1245            arrow_schema::DataType::Utf8,
1246            false,
1247        )]));
1248        let other = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1249            "y",
1250            arrow_schema::DataType::Utf8,
1251            false,
1252        )]));
1253        let config = WriteConfig::new(OutFormat::Csv)
1254            .output(&path)
1255            .unwrap()
1256            .overwrite(true);
1257        let mut writer = ParallelTextWriter::new(config, schema.clone()).unwrap();
1258        writer.write(&[test_batch(schema, &["staged"])]).unwrap();
1259        assert!(matches!(
1260            writer.write(&[test_batch(other, &["new"])]),
1261            Err(ReadStatError::SchemaMismatch)
1262        ));
1263        drop(writer);
1264
1265        assert_eq!(std::fs::read_to_string(&path).unwrap(), "sentinel");
1266        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1267    }
1268
1269    #[test]
1270    fn parallel_text_empty_outputs_match_serial() {
1271        let dir = tempfile::tempdir().unwrap();
1272        let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1273            "x",
1274            arrow_schema::DataType::Utf8,
1275            false,
1276        )]));
1277        for format in [OutFormat::Csv, OutFormat::Ndjson] {
1278            let serial_path = dir.path().join(format!("serial.{format}"));
1279            let parallel_path = dir.path().join(format!("parallel.{format}"));
1280            let serial = ReadStatWriter::new(
1281                WriteConfig::new(format).output(&serial_path).unwrap(),
1282                schema.clone(),
1283            )
1284            .unwrap();
1285            assert_eq!(serial.finish().unwrap(), 0);
1286
1287            let parallel = ParallelTextWriter::new(
1288                WriteConfig::new(format).output(&parallel_path).unwrap(),
1289                schema.clone(),
1290            )
1291            .unwrap();
1292            assert_eq!(parallel.finish().unwrap(), 0);
1293
1294            assert_eq!(
1295                std::fs::read(serial_path).unwrap(),
1296                std::fs::read(parallel_path).unwrap()
1297            );
1298        }
1299    }
1300
1301    #[test]
1302    fn rejects_schema_mismatch() {
1303        let schema = Arc::new(Schema::empty());
1304        let mut writer = ReadStatWriter::new(WriteConfig::new(OutFormat::Csv), schema).unwrap();
1305        let other = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1306            "x",
1307            arrow_schema::DataType::Utf8,
1308            true,
1309        )]));
1310        assert!(matches!(
1311            writer.write(&RecordBatch::new_empty(other)),
1312            Err(ReadStatError::SchemaMismatch)
1313        ));
1314    }
1315
1316    #[test]
1317    fn empty_output_for_each_enabled_format() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let schema = Arc::new(Schema::empty());
1320        let formats = [
1321            OutFormat::Csv,
1322            OutFormat::Feather,
1323            OutFormat::Ndjson,
1324            OutFormat::Parquet,
1325        ];
1326        for format in formats {
1327            let path = dir.path().join(format!("empty.{format}"));
1328            let config = WriteConfig::new(format).output(&path).unwrap();
1329            let writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1330            assert_eq!(writer.finish().unwrap(), 0);
1331            assert!(path.exists());
1332        }
1333    }
1334
1335    #[test]
1336    fn output_open_race_and_overwrite() {
1337        let dir = tempfile::tempdir().unwrap();
1338        let path = dir.path().join("race.csv");
1339        let config = WriteConfig::new(OutFormat::Csv).output(&path).unwrap();
1340        std::fs::write(&path, "sentinel").unwrap();
1341        let schema = Arc::new(Schema::empty());
1342        let writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1343        assert!(matches!(
1344            writer.finish(),
1345            Err(ReadStatError::OutputFileExists(_))
1346        ));
1347        assert_eq!(std::fs::read_to_string(&path).unwrap(), "sentinel");
1348
1349        let config = WriteConfig::new(OutFormat::Csv)
1350            .output(&path)
1351            .unwrap()
1352            .overwrite(true);
1353        let writer = ReadStatWriter::new(config, schema).unwrap();
1354        writer.finish().unwrap();
1355        assert_ne!(std::fs::read_to_string(path).unwrap(), "sentinel");
1356    }
1357
1358    #[test]
1359    fn drop_before_finish_preserves_destination_and_cleans_staging() {
1360        let dir = tempfile::tempdir().unwrap();
1361        let path = dir.path().join("preserve.csv");
1362        std::fs::write(&path, "sentinel").unwrap();
1363        let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1364            "x",
1365            arrow_schema::DataType::Utf8,
1366            false,
1367        )]));
1368        let config = WriteConfig::new(OutFormat::Csv)
1369            .output(&path)
1370            .unwrap()
1371            .overwrite(true);
1372        let mut writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1373        writer.write(&test_batch(schema, &["new"])).unwrap();
1374        drop(writer);
1375        assert_eq!(std::fs::read_to_string(&path).unwrap(), "sentinel");
1376        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1377    }
1378
1379    #[test]
1380    fn no_overwrite_destination_raced_before_publication() {
1381        let dir = tempfile::tempdir().unwrap();
1382        let path = dir.path().join("raced.csv");
1383        let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1384            "x",
1385            arrow_schema::DataType::Utf8,
1386            false,
1387        )]));
1388        let config = WriteConfig::new(OutFormat::Csv).output(&path).unwrap();
1389        let mut writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1390        writer.write(&test_batch(schema, &["new"])).unwrap();
1391        std::fs::write(&path, "racer").unwrap();
1392        assert!(matches!(
1393            writer.finish(),
1394            Err(ReadStatError::OutputFileExists(_))
1395        ));
1396        assert_eq!(std::fs::read_to_string(&path).unwrap(), "racer");
1397        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1398    }
1399
1400    // --- csv_escape_field ---
1401
1402    #[cfg(feature = "csv")]
1403    #[test]
1404    fn csv_escape_field_cases() {
1405        // Plain names pass through untouched.
1406        assert_eq!(csv_escape_field("Brand"), "Brand");
1407        // A comma forces quoting.
1408        assert_eq!(csv_escape_field("a,b"), "\"a,b\"");
1409        // Interior quotes are doubled and the field is wrapped.
1410        assert_eq!(csv_escape_field("a\"b"), "\"a\"\"b\"");
1411        // Newlines/CR force quoting too.
1412        assert_eq!(csv_escape_field("a\nb"), "\"a\nb\"");
1413        assert_eq!(csv_escape_field("a\rb"), "\"a\rb\"");
1414    }
1415}