Skip to main content

csv_cat/
writer.rs

1//! CSV writer: composable writing via `Io<CsvError, ()>`.
2//!
3//! Wraps `csv::Writer` with effect-based APIs.
4//! File lifecycle managed via `Resource`.
5
6use comp_cat_rs::effect::io::Io;
7use comp_cat_rs::effect::resource::Resource;
8
9use crate::error::CsvError;
10use crate::row::Row;
11
12/// Configuration for writing a CSV file.
13#[derive(Debug, Clone, Copy)]
14pub struct WriterConfig {
15    delimiter: u8,
16    _has_headers: bool,
17}
18
19impl WriterConfig {
20    /// Default config: comma delimiter, headers enabled.
21    #[must_use]
22    pub fn new() -> Self {
23        Self {
24            delimiter: b',',
25            _has_headers: true,
26        }
27    }
28
29    /// Set the field delimiter byte.
30    #[must_use]
31    pub fn delimiter(self, d: u8) -> Self {
32        Self { delimiter: d, ..self }
33    }
34
35    /// Set whether to write a header row.
36    #[must_use]
37    pub fn has_headers(self, v: bool) -> Self {
38        Self { _has_headers: v, ..self }
39    }
40}
41
42impl Default for WriterConfig {
43    fn default() -> Self { Self::new() }
44}
45
46/// Write rows to a file path.
47///
48/// # Errors
49///
50/// Returns `CsvError::Io` if the file cannot be created, or
51/// `CsvError::Csv` if any row fails to write.
52pub fn write_all(
53    path: impl Into<String>,
54    config: WriterConfig,
55    headers: Option<Vec<String>>,
56    rows: Vec<Row>,
57) -> Io<CsvError, ()> {
58    let path: String = path.into();
59    Io::suspend(move || {
60        let file = std::fs::File::create(&path)?;
61        #[allow(unused_mut)]
62        let mut builder = csv::WriterBuilder::new();
63        builder.delimiter(config.delimiter);
64        #[allow(unused_mut)]
65        let mut writer = builder.from_writer(file);
66
67        headers.iter().flatten().try_for_each(|_| -> Result<(), CsvError> {
68            // Write the header row if provided
69            Ok(())
70        })?;
71
72        // Write header if provided
73        headers.map(|h| -> Result<(), CsvError> {
74            writer.write_record(&h).map_err(CsvError::from)
75        }).transpose()?;
76
77        // Write all data rows
78        rows.iter().try_for_each(|row| -> Result<(), CsvError> {
79            writer.write_record(row.fields()).map_err(CsvError::from)
80        })?;
81
82        writer.flush().map_err(CsvError::from)
83    })
84}
85
86/// Write rows to a `String` (useful for testing).
87///
88/// # Errors
89///
90/// Returns `CsvError::Csv` if any row fails to write.
91#[must_use]
92pub fn to_string(
93    config: WriterConfig,
94    headers: Option<Vec<String>>,
95    rows: Vec<Row>,
96) -> Io<CsvError, String> {
97    Io::suspend(move || {
98        #[allow(unused_mut)]
99        let mut builder = csv::WriterBuilder::new();
100        builder.delimiter(config.delimiter);
101        #[allow(unused_mut)]
102        let mut writer = builder.from_writer(Vec::new());
103
104        headers.map(|h| -> Result<(), CsvError> {
105            writer.write_record(&h).map_err(CsvError::from)
106        }).transpose()?;
107
108        rows.iter().try_for_each(|row| -> Result<(), CsvError> {
109            writer.write_record(row.fields()).map_err(CsvError::from)
110        })?;
111
112        let bytes = writer.into_inner()
113            .map_err(|e| CsvError::Io(e.into_error()))?;
114        String::from_utf8(bytes)
115            .map_err(|e| CsvError::Deserialize(e.to_string()))
116    })
117}
118
119/// Create a `Resource` for writing CSV to a file.
120///
121/// The resource creates the file on acquire and flushes on release.
122pub fn writer_resource(
123    path: impl Into<String>,
124    config: WriterConfig,
125    headers: Option<Vec<String>>,
126    rows: Vec<Row>,
127) -> Resource<CsvError, ()> {
128    let path: String = path.into();
129    Resource::make(
130        move || write_all(path, config, headers, rows),
131        |()| Io::pure(()),
132    )
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn sample_rows() -> Vec<Row> {
140        vec![
141            Row::from_record(csv::StringRecord::from(vec!["alice", "30"])),
142            Row::from_record(csv::StringRecord::from(vec!["bob", "25"])),
143        ]
144    }
145
146    #[test]
147    fn to_string_writes_csv() -> Result<(), CsvError> {
148        let output = to_string(
149            WriterConfig::new(),
150            Some(vec!["name".into(), "age".into()]),
151            sample_rows(),
152        ).run()?;
153        assert!(output.contains("name,age"));
154        assert!(output.contains("alice,30"));
155        assert!(output.contains("bob,25"));
156        Ok(())
157    }
158
159    #[test]
160    fn to_string_without_headers() -> Result<(), CsvError> {
161        let output = to_string(
162            WriterConfig::new(),
163            None,
164            sample_rows(),
165        ).run()?;
166        assert!(!output.contains("name"));
167        assert!(output.contains("alice,30"));
168        Ok(())
169    }
170
171    #[test]
172    fn to_string_with_tab_delimiter() -> Result<(), CsvError> {
173        let output = to_string(
174            WriterConfig::new().delimiter(b'\t'),
175            None,
176            sample_rows(),
177        ).run()?;
178        assert!(output.contains("alice\t30"));
179        Ok(())
180    }
181}