Skip to main content

spring_batch_rs/item/csv/
csv_writer.rs

1use std::{cell::RefCell, fs::File, io::Write, marker::PhantomData, path::Path};
2
3use csv::{Writer, WriterBuilder};
4use serde::Serialize;
5
6use crate::{
7    BatchError,
8    core::item::{ItemWriter, ItemWriterResult},
9};
10
11/// A CSV writer that implements the `ItemWriter` trait.
12///
13/// This writer serializes Rust structs to CSV format and writes them to
14/// the underlying destination (file, memory buffer, etc.)
15///
16/// # Type Parameters
17///
18/// - `T`: The type of writer destination, must implement `Write` trait
19///
20/// # Implementation Details
21///
22/// - Uses `RefCell` for interior mutability of the CSV writer
23/// - Integrates with serde for serialization of custom types
24/// - Handles serialization of batch items one by one
25/// - Converts CSV errors to Spring Batch errors
26///
27/// # Ownership Considerations
28///
29/// The writer borrows its destination mutably. When writing to a buffer:
30/// - The buffer will be borrowed for the lifetime of the writer
31/// - To read from the buffer after writing, ensure the writer is dropped first
32/// - One approach is to use a separate scope for the writer operations
33///
34/// # Examples
35///
36/// ```
37/// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
38/// use spring_batch_rs::core::item::ItemWriter;
39/// use serde::Serialize;
40///
41/// #[derive(Serialize)]
42/// struct Record {
43///     id: u32,
44///     name: String,
45/// }
46///
47/// // Create records to write
48/// let records = vec![
49///     Record { id: 1, name: "Alice".to_string() },
50///     Record { id: 2, name: "Bob".to_string() },
51/// ];
52///
53/// // Write records to a CSV string
54/// let mut buffer = Vec::new();
55/// {
56///     // Create a new scope for the writer to ensure it's dropped before we read the buffer
57///     let writer = CsvItemWriterBuilder::new()
58///         .has_headers(true)
59///         .from_writer(&mut buffer);
60///
61///     writer.write(&records).unwrap();
62///     ItemWriter::<Record>::flush(&writer).unwrap();
63/// } // writer is dropped here, releasing the borrow on buffer
64///
65/// // Now we can safely read from the buffer
66/// let csv_content = String::from_utf8(buffer).unwrap();
67/// assert!(csv_content.contains("id,name"));
68/// assert!(csv_content.contains("1,Alice"));
69/// assert!(csv_content.contains("2,Bob"));
70/// ```
71pub struct CsvItemWriter<O, W: Write> {
72    /// The underlying CSV writer
73    ///
74    /// Uses `RefCell` to allow interior mutability while conforming to the
75    /// `ItemWriter` trait's immutable self reference in its methods.
76    writer: RefCell<Writer<W>>,
77    _phantom: PhantomData<O>,
78}
79
80impl<O: Serialize, W: Write> ItemWriter<O> for CsvItemWriter<O, W> {
81    /// Writes a batch of items to CSV.
82    ///
83    /// This method serializes each item in the provided slice to CSV format
84    /// and writes it to the underlying destination.
85    ///
86    /// # Serialization Process
87    ///
88    /// 1. For each item in the batch:
89    ///    - Serialize the item to CSV format using serde
90    ///    - Write the serialized row to the underlying destination
91    /// 2. If any item fails to serialize, return an error immediately
92    ///
93    /// Note: This method doesn't flush the writer. You need to call `flush()`
94    /// explicitly when you're done writing.
95    ///
96    /// # Parameters
97    /// - `items`: A slice of items to be serialized and written
98    ///
99    /// # Returns
100    /// - `Ok(())` if successful
101    /// - `Err(BatchError)` if writing fails
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
107    /// use spring_batch_rs::core::item::ItemWriter;
108    /// use serde::Serialize;
109    ///
110    /// #[derive(Serialize)]
111    /// struct Person {
112    ///     name: String,
113    ///     age: u8,
114    /// }
115    ///
116    /// // Create people to write
117    /// let people = vec![
118    ///     Person { name: "Alice".to_string(), age: 28 },
119    ///     Person { name: "Bob".to_string(), age: 35 },
120    /// ];
121    ///
122    /// // Write to a buffer in a separate scope
123    /// let mut buffer = Vec::new();
124    /// {
125    ///     let writer = CsvItemWriterBuilder::new()
126    ///         .from_writer(&mut buffer);
127    ///
128    ///     // Write the batch of people
129    ///     writer.write(&people).unwrap();
130    ///     ItemWriter::<Person>::flush(&writer).unwrap();
131    /// }
132    /// ```
133    fn write(&self, items: &[O]) -> ItemWriterResult {
134        for item in items.iter() {
135            // Try to serialize each item to CSV format
136            let result = self.writer.borrow_mut().serialize(item);
137
138            // If serialization fails, return the error immediately
139            if result.is_err() {
140                let error = result.err().unwrap();
141                return Err(BatchError::ItemWriter(error.to_string()));
142            }
143        }
144        Ok(())
145    }
146
147    /// Flush the contents of the internal buffer to the underlying writer.
148    ///
149    /// If there was a problem writing to the underlying writer, then an error
150    /// is returned.
151    ///
152    /// Note that this also flushes the underlying writer.
153    ///
154    /// # Important
155    ///
156    /// You must call this method when you're done writing to ensure all data
157    /// is written to the destination. The `write` method buffers data internally
158    /// for efficiency, and `flush` ensures it's all written out.
159    ///
160    /// # When to Call
161    ///
162    /// - After writing all items in a batch
163    /// - Before dropping the writer if you need the data immediately
164    /// - When closing a file to ensure all data is written
165    ///
166    /// # Returns
167    /// - `Ok(())` if successful
168    /// - `Err(BatchError)` if flushing fails
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
174    /// use spring_batch_rs::core::item::ItemWriter;
175    /// use serde::Serialize;
176    ///
177    /// #[derive(Serialize)]
178    /// struct Record {
179    ///     id: u32,
180    ///     value: String,
181    /// }
182    ///
183    /// // Write to a buffer in a separate scope
184    /// let mut buffer = Vec::new();
185    /// {
186    ///     let writer = CsvItemWriterBuilder::new()
187    ///         .from_writer(&mut buffer);
188    ///
189    ///     // Write some records
190    ///     let records = vec![Record { id: 1, value: "test".to_string() }];
191    ///     writer.write(&records).unwrap();
192    ///
193    ///     // Ensure all data is written - specify type explicitly
194    ///     ItemWriter::<Record>::flush(&writer).unwrap();
195    /// }
196    /// ```
197    fn flush(&self) -> ItemWriterResult {
198        // Flush the underlying CSV writer
199        let result = self.writer.borrow_mut().flush();
200        match result {
201            Ok(()) => Ok(()),
202            Err(error) => Err(BatchError::ItemWriter(error.to_string())),
203        }
204    }
205}
206
207/// A builder for creating CSV item writers.
208///
209/// This builder allows you to customize the CSV writing behavior,
210/// including delimiter and header handling.
211///
212/// # Design Pattern
213///
214/// This struct implements the Builder pattern, which allows for fluent, chainable
215/// configuration of a `CsvItemWriter` before creation. Each method returns `self`
216/// to allow method chaining.
217///
218/// # Default Configuration
219///
220/// - Delimiter: comma (,)
221/// - Headers: disabled (no header row)
222///
223/// # Examples
224///
225/// ```
226/// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
227/// use spring_batch_rs::core::item::ItemWriter;
228/// use serde::Serialize;
229///
230/// #[derive(Serialize)]
231/// struct Record {
232///     id: u32,
233///     name: String,
234/// }
235///
236/// // Create a CSV writer with custom settings
237/// let mut buffer = Vec::new();
238/// let writer = CsvItemWriterBuilder::<Record>::new()
239///     .delimiter(b';')  // Use semicolon as delimiter
240///     .has_headers(true)  // Include headers in output
241///     .from_writer(&mut buffer);
242/// ```
243#[derive(Default)]
244pub struct CsvItemWriterBuilder<O> {
245    /// The delimiter character (default: comma ',')
246    delimiter: u8,
247    /// Whether to include headers in the output (default: false)
248    has_headers: bool,
249    _pd: PhantomData<O>,
250}
251
252impl<O> CsvItemWriterBuilder<O> {
253    /// Creates a new `CsvItemWriterBuilder` with default configuration.
254    ///
255    /// Default settings:
256    /// - Delimiter: comma (,)
257    /// - Headers: disabled
258    ///
259    /// # Examples
260    ///
261    /// ```
262    /// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
263    /// use serde::Serialize;
264    ///
265    /// #[derive(Serialize)]
266    /// struct Record {
267    ///     field: String,
268    /// }
269    ///
270    /// let builder = CsvItemWriterBuilder::<Record>::new();
271    /// ```
272    pub fn new() -> Self {
273        Self {
274            delimiter: b',',
275            has_headers: false,
276            _pd: PhantomData,
277        }
278    }
279
280    /// Sets the delimiter character for the CSV output.
281    ///
282    /// # Parameters
283    /// - `delimiter`: The character to use as field delimiter
284    ///
285    /// # Common Delimiters
286    ///
287    /// - `b','` - Comma (default in US/UK)
288    /// - `b';'` - Semicolon (common in Europe)
289    /// - `b'\t'` - Tab (for TSV format)
290    /// - `b'|'` - Pipe (less common)
291    ///
292    /// # Examples
293    ///
294    /// ```
295    /// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
296    /// use serde::Serialize;
297    ///
298    /// #[derive(Serialize)]
299    /// struct Record {
300    ///     field: String,
301    /// }
302    ///
303    /// // Use tab as delimiter
304    /// let builder = CsvItemWriterBuilder::<Record>::new()
305    ///     .delimiter(b'\t');
306    ///
307    /// // Use semicolon as delimiter
308    /// let builder = CsvItemWriterBuilder::<Record>::new()
309    ///     .delimiter(b';');
310    /// ```
311    pub fn delimiter(mut self, delimiter: u8) -> Self {
312        self.delimiter = delimiter;
313        self
314    }
315
316    /// Sets whether to include headers in the CSV output.
317    ///
318    /// When enabled, the writer will include a header row with field names
319    /// derived from the struct field names or serde annotations.
320    ///
321    /// # Parameters
322    /// - `yes`: Whether to include headers
323    ///
324    /// # Header Generation
325    ///
326    /// Headers are generated from:
327    /// - Struct field names by default
328    /// - Custom names specified by `#[serde(rename = "...")]` attributes
329    /// - For nested fields, the serde flattening mechanism is used
330    ///
331    /// # Examples
332    ///
333    /// ```
334    /// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
335    /// use serde::Serialize;
336    ///
337    /// #[derive(Serialize)]
338    /// struct Record {
339    ///     field: String,
340    /// }
341    ///
342    /// // Include headers (field names as first row)
343    /// let builder = CsvItemWriterBuilder::<Record>::new()
344    ///     .has_headers(true);
345    ///
346    /// // Exclude headers (data only)
347    /// let builder = CsvItemWriterBuilder::<Record>::new()
348    ///     .has_headers(false);
349    /// ```
350    pub fn has_headers(mut self, yes: bool) -> Self {
351        self.has_headers = yes;
352        self
353    }
354
355    /// Creates a CSV item writer that writes to a file.
356    ///
357    /// # Parameters
358    /// - `path`: The path where the output file will be created
359    ///
360    /// # Returns
361    /// A configured `CsvItemWriter` instance
362    ///
363    /// # Panics
364    /// Panics if the file cannot be created
365    ///
366    /// # File Handling
367    ///
368    /// This method will:
369    /// - Create the file if it doesn't exist
370    /// - Truncate the file if it exists
371    /// - Return a writer that writes to the file
372    ///
373    /// # Examples
374    ///
375    /// ```no_run
376    /// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
377    /// use spring_batch_rs::core::item::ItemWriter;
378    /// use serde::Serialize;
379    ///
380    /// #[derive(Serialize)]
381    /// struct Record {
382    ///     id: u32,
383    ///     value: String,
384    /// }
385    ///
386    /// // Create a writer to a file
387    /// let writer = CsvItemWriterBuilder::<Record>::new()
388    ///     .has_headers(true)
389    ///     .from_path("output.csv");
390    ///
391    /// // Write some data
392    /// let records = vec![
393    ///     Record { id: 1, value: "data1".to_string() },
394    ///     Record { id: 2, value: "data2".to_string() },
395    /// ];
396    ///
397    /// writer.write(&records).unwrap();
398    /// ItemWriter::<Record>::flush(&writer).unwrap();
399    /// ```
400    pub fn from_path<W: AsRef<Path>>(self, path: W) -> CsvItemWriter<O, File> {
401        // Configure and create the CSV writer
402        let writer = WriterBuilder::new()
403            .flexible(false) // Use strict formatting to detect serialization issues
404            .has_headers(self.has_headers)
405            .delimiter(self.delimiter)
406            .from_path(path);
407
408        // Unwrap here is appropriate since file opening is an initialization step
409        // If it fails, we want to fail fast
410        CsvItemWriter {
411            writer: RefCell::new(writer.unwrap()),
412            _phantom: PhantomData,
413        }
414    }
415
416    /// Creates a CSV item writer that writes to any destination implementing the `Write` trait.
417    ///
418    /// This allows writing to in-memory buffers, network connections, or other custom destinations.
419    ///
420    /// # Parameters
421    /// - `wtr`: The writer instance to use for output
422    ///
423    /// # Returns
424    /// A configured `CsvItemWriter` instance
425    ///
426    /// # Common Writer Types
427    ///
428    /// - `&mut Vec<u8>` - In-memory buffer (most common for tests)
429    /// - `File` - File writer for permanent storage
430    /// - `Cursor<Vec<u8>>` - In-memory cursor for testing
431    /// - `TcpStream` - Network connection for remote writing
432    ///
433    /// # Examples
434    ///
435    /// ```
436    /// use spring_batch_rs::item::csv::csv_writer::CsvItemWriterBuilder;
437    /// use spring_batch_rs::core::item::ItemWriter;
438    /// use serde::Serialize;
439    ///
440    /// #[derive(Serialize)]
441    /// struct Row<'a> {
442    ///     city: &'a str,
443    ///     country: &'a str,
444    ///     #[serde(rename = "popcount")]
445    ///     population: u64,
446    /// }
447    ///
448    /// // Prepare some data
449    /// let rows = vec![
450    ///     Row {
451    ///         city: "Boston",
452    ///         country: "United States",
453    ///         population: 4628910,
454    ///     },
455    ///     Row {
456    ///         city: "Concord",
457    ///         country: "United States",
458    ///         population: 42695,
459    ///     }
460    /// ];
461    ///
462    /// // Write to a vector buffer in a separate scope
463    /// let mut buffer = Vec::new();
464    /// {
465    ///     let writer = CsvItemWriterBuilder::<Row>::new()
466    ///         .has_headers(true)
467    ///         .from_writer(&mut buffer);
468    ///
469    ///     // Write the data
470    ///     writer.write(&rows).unwrap();
471    ///     ItemWriter::<Row>::flush(&writer).unwrap();
472    /// } // writer is dropped here, releasing the borrow
473    ///
474    /// // Check the output (with headers)
475    /// let output = String::from_utf8(buffer).unwrap();
476    /// assert!(output.contains("city,country,popcount"));
477    /// assert!(output.contains("Boston,United States,4628910"));
478    /// ```
479    pub fn from_writer<W: Write>(self, wtr: W) -> CsvItemWriter<O, W> {
480        // Configure and create the CSV writer
481        let wtr = WriterBuilder::new()
482            .flexible(false) // Use strict formatting to detect serialization issues
483            .has_headers(self.has_headers)
484            .delimiter(self.delimiter)
485            .from_writer(wtr);
486
487        CsvItemWriter {
488            writer: RefCell::new(wtr),
489            _phantom: PhantomData,
490        }
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::core::item::ItemWriter;
498    use serde::Serialize;
499
500    #[derive(Serialize, Clone)]
501    struct Row {
502        name: String,
503        value: u32,
504    }
505
506    fn sample_rows() -> Vec<Row> {
507        vec![
508            Row {
509                name: "alpha".into(),
510                value: 1,
511            },
512            Row {
513                name: "beta".into(),
514                value: 2,
515            },
516        ]
517    }
518
519    #[test]
520    fn should_write_records_with_headers() {
521        let mut buf = Vec::new();
522        {
523            let writer = CsvItemWriterBuilder::<Row>::new()
524                .has_headers(true)
525                .from_writer(&mut buf);
526            writer.write(&sample_rows()).unwrap();
527            ItemWriter::<Row>::flush(&writer).unwrap();
528        }
529        let out = String::from_utf8(buf).unwrap();
530        assert!(out.contains("name,value"), "header row missing: {out}");
531        assert!(out.contains("alpha,1"), "first data row missing: {out}");
532        assert!(out.contains("beta,2"), "second data row missing: {out}");
533    }
534
535    #[test]
536    fn should_write_records_without_headers() {
537        let mut buf = Vec::new();
538        {
539            let writer = CsvItemWriterBuilder::<Row>::new()
540                .has_headers(false)
541                .from_writer(&mut buf);
542            writer.write(&sample_rows()).unwrap();
543            ItemWriter::<Row>::flush(&writer).unwrap();
544        }
545        let out = String::from_utf8(buf).unwrap();
546        assert!(!out.contains("name"), "header row should be absent: {out}");
547        assert!(
548            out.contains("alpha,1"),
549            "data row missing from headerless output: {out}"
550        );
551    }
552
553    #[test]
554    fn should_write_with_custom_delimiter() {
555        let mut buf = Vec::new();
556        {
557            let writer = CsvItemWriterBuilder::<Row>::new()
558                .has_headers(true)
559                .delimiter(b';')
560                .from_writer(&mut buf);
561            writer.write(&sample_rows()).unwrap();
562            ItemWriter::<Row>::flush(&writer).unwrap();
563        }
564        let out = String::from_utf8(buf).unwrap();
565        assert!(
566            out.contains("name;value"),
567            "semicolon header missing: {out}"
568        );
569        assert!(out.contains("alpha;1"), "semicolon data missing: {out}");
570    }
571
572    #[test]
573    fn should_write_empty_chunk_without_error() {
574        let mut buf = Vec::new();
575        {
576            let writer = CsvItemWriterBuilder::<Row>::new()
577                .has_headers(true)
578                .from_writer(&mut buf);
579            writer.write(&[]).unwrap();
580            ItemWriter::<Row>::flush(&writer).unwrap();
581        }
582        let out = String::from_utf8(buf).unwrap();
583        assert!(
584            out.is_empty(),
585            "writing an empty chunk should produce no output, got: {out:?}"
586        );
587    }
588
589    #[test]
590    fn should_write_single_record() {
591        let mut buf = Vec::new();
592        {
593            let writer = CsvItemWriterBuilder::<Row>::new()
594                .has_headers(false)
595                .from_writer(&mut buf);
596            writer
597                .write(&[Row {
598                    name: "only".into(),
599                    value: 99,
600                }])
601                .unwrap();
602            ItemWriter::<Row>::flush(&writer).unwrap();
603        }
604        let out = String::from_utf8(buf).unwrap();
605        assert!(out.contains("only,99"), "single record missing: {out}");
606    }
607
608    #[test]
609    fn should_return_error_when_serialization_fails() {
610        use serde::ser;
611
612        #[derive(Clone)]
613        struct FailSerialize;
614        impl Serialize for FailSerialize {
615            fn serialize<S: serde::Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
616                Err(ser::Error::custom("intentional failure"))
617            }
618        }
619
620        let mut buf = Vec::new();
621        let writer = CsvItemWriterBuilder::<FailSerialize>::new().from_writer(&mut buf);
622        let result = writer.write(&[FailSerialize]);
623        assert!(result.is_err(), "should fail when serialization fails");
624        match result {
625            Err(BatchError::ItemWriter(_)) => {}
626            other => panic!("expected ItemWriter error, got {other:?}"),
627        }
628    }
629
630    #[test]
631    fn should_return_error_when_flush_fails_on_io() {
632        use std::io;
633
634        struct FailFlushWriter;
635        impl Write for FailFlushWriter {
636            fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
637                Ok(buf.len())
638            }
639            fn flush(&mut self) -> io::Result<()> {
640                Err(io::Error::new(io::ErrorKind::Other, "flush failed"))
641            }
642        }
643
644        let csv_writer = CsvItemWriter::<Row, FailFlushWriter> {
645            writer: RefCell::new(WriterBuilder::new().from_writer(FailFlushWriter)),
646            _phantom: PhantomData,
647        };
648        let result = ItemWriter::<Row>::flush(&csv_writer);
649        assert!(
650            result.is_err(),
651            "flush should fail when underlying writer fails"
652        );
653        match result {
654            Err(BatchError::ItemWriter(_)) => {}
655            other => panic!("expected ItemWriter error, got {other:?}"),
656        }
657    }
658
659    #[test]
660    fn should_write_to_file() {
661        use std::fs;
662        use tempfile::NamedTempFile;
663
664        let tmp = NamedTempFile::new().unwrap();
665        let path = tmp.path().to_path_buf();
666
667        let writer = CsvItemWriterBuilder::<Row>::new()
668            .has_headers(true)
669            .from_path(&path);
670        writer.write(&sample_rows()).unwrap();
671        ItemWriter::<Row>::flush(&writer).unwrap();
672        drop(writer);
673
674        let content = fs::read_to_string(&path).unwrap();
675        assert!(content.contains("name,value"), "file header missing");
676        assert!(content.contains("alpha,1"), "file data missing");
677    }
678}