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}