1use crate::config::CsvSourceConfig;
4use async_trait::async_trait;
5use faucet_core::{FaucetError, Stream, StreamPage};
6use serde_json::{Map, Value};
7use std::pin::Pin;
8
9pub struct CsvSource {
15 config: CsvSourceConfig,
16}
17
18impl CsvSource {
19 pub fn new(config: CsvSourceConfig) -> Self {
21 Self { config }
22 }
23}
24
25#[async_trait]
26impl faucet_core::Source for CsvSource {
27 async fn fetch_with_context(
28 &self,
29 context: &std::collections::HashMap<String, serde_json::Value>,
30 ) -> Result<Vec<Value>, FaucetError> {
31 use futures::StreamExt;
32 let mut all = Vec::new();
33 let mut s = self.stream_pages(context, self.config.batch_size);
34 while let Some(page) = s.next().await {
35 let page = page?;
36 all.extend(page.records);
37 }
38 Ok(all)
39 }
40
41 fn stream_pages<'a>(
59 &'a self,
60 context: &'a std::collections::HashMap<String, Value>,
61 _batch_size: usize,
62 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
63 let batch_size = self.config.batch_size;
64
65 Box::pin(async_stream::try_stream! {
66 use futures::StreamExt as _;
67
68 let mut config = self.config.clone();
69 if !context.is_empty() {
70 config.path = faucet_core::util::substitute_context(&config.path, context);
71 }
72
73 let file = tokio::fs::File::open(&config.path).await.map_err(|e| {
74 FaucetError::Config(format!(
75 "failed to open CSV file '{}': {e}",
76 config.path
77 ))
78 })?;
79 let reader = tokio::io::BufReader::new(file);
80 #[cfg(feature = "compression")]
81 let reader = {
82 let codec = config.compression.resolve(&config.path);
83 faucet_core::compression::warn_mismatch(&config.path, codec);
84 faucet_core::compression::wrap_async_reader(reader, codec)
85 };
86
87 let mut csv_reader = csv_async::AsyncReaderBuilder::new()
92 .has_headers(false)
93 .delimiter(config.delimiter)
94 .quote(config.quote)
95 .flexible(true)
98 .create_reader(reader);
99
100 let mut records = csv_reader.records();
101
102 let headers: Vec<String> = if config.has_headers {
104 match records.next().await {
105 Some(rec) => {
106 let rec = rec.map_err(|e| FaucetError::Config(format!(
107 "CSV header parse error in '{}': {e}", config.path
108 )))?;
109 rec.iter().map(|f| f.to_string()).collect()
110 }
111 None => Vec::new(),
112 }
113 } else {
114 Vec::new()
115 };
116
117 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
118 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
119 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
120 let mut total = 0usize;
121 let mut row_idx = 0usize;
122
123 while let Some(rec) = records.next().await {
124 let record = rec.map_err(|e| FaucetError::Config(format!(
125 "CSV parse error at line {} in '{}': {e}",
126 row_idx + 1 + usize::from(config.has_headers),
130 config.path
131 )))?;
132
133 let mut obj = Map::new();
134 for (col_idx, field) in record.iter().enumerate() {
135 let key = if col_idx < headers.len() {
136 headers[col_idx].clone()
137 } else {
138 format!("column_{col_idx}")
139 };
140 obj.insert(key, Value::String(field.to_string()));
141 }
142 buffer.push(Value::Object(obj));
143 row_idx += 1;
144
145 if buffer.len() >= chunk {
146 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
147 total += page.len();
148 yield StreamPage { records: page, bookmark: None };
149 }
150 }
151
152 if !buffer.is_empty() {
153 total += buffer.len();
154 yield StreamPage { records: buffer, bookmark: None };
155 }
156
157 tracing::info!(
158 rows = total,
159 batch_size,
160 path = %config.path,
161 "CSV source stream complete",
162 );
163 })
164 }
165
166 fn config_schema(&self) -> serde_json::Value {
167 serde_json::to_value(faucet_core::schema_for!(CsvSourceConfig))
168 .expect("schema serialization")
169 }
170
171 fn dataset_uri(&self) -> String {
172 format!("file://{}", self.config.path)
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use faucet_core::Source;
180 use std::io::Write;
181 use tempfile::NamedTempFile;
182
183 #[tokio::test]
184 async fn reads_csv_with_headers() {
185 let mut tmp = NamedTempFile::new().unwrap();
186 writeln!(tmp, "id,name,age").unwrap();
187 writeln!(tmp, "1,Alice,30").unwrap();
188 writeln!(tmp, "2,Bob,25").unwrap();
189 tmp.flush().unwrap();
190
191 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
192 let source = CsvSource::new(config);
193 let records = source.fetch_all().await.unwrap();
194
195 assert_eq!(records.len(), 2);
196 assert_eq!(records[0]["id"], "1");
197 assert_eq!(records[0]["name"], "Alice");
198 assert_eq!(records[0]["age"], "30");
199 assert_eq!(records[1]["name"], "Bob");
200 }
201
202 #[tokio::test]
203 async fn reads_csv_without_headers() {
204 let mut tmp = NamedTempFile::new().unwrap();
205 writeln!(tmp, "Alice,30").unwrap();
206 writeln!(tmp, "Bob,25").unwrap();
207 tmp.flush().unwrap();
208
209 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).has_headers(false);
210 let source = CsvSource::new(config);
211 let records = source.fetch_all().await.unwrap();
212
213 assert_eq!(records.len(), 2);
214 assert_eq!(records[0]["column_0"], "Alice");
215 assert_eq!(records[0]["column_1"], "30");
216 }
217
218 #[tokio::test]
219 async fn reads_tsv_with_custom_delimiter() {
220 let mut tmp = NamedTempFile::new().unwrap();
221 writeln!(tmp, "id\tname").unwrap();
222 writeln!(tmp, "1\tAlice").unwrap();
223 tmp.flush().unwrap();
224
225 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).delimiter(b'\t');
226 let source = CsvSource::new(config);
227 let records = source.fetch_all().await.unwrap();
228
229 assert_eq!(records.len(), 1);
230 assert_eq!(records[0]["id"], "1");
231 assert_eq!(records[0]["name"], "Alice");
232 }
233
234 #[tokio::test]
235 async fn reads_quoted_field_with_embedded_newline() {
236 let mut tmp = NamedTempFile::new().unwrap();
239 write!(tmp, "id,note\n1,\"line one\nline two\"\n2,\"plain\"\n").unwrap();
240 tmp.flush().unwrap();
241
242 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
243 let source = CsvSource::new(config);
244 let records = source.fetch_all().await.unwrap();
245
246 assert_eq!(records.len(), 2);
247 assert_eq!(records[0]["id"], "1");
248 assert_eq!(records[0]["note"], "line one\nline two");
249 assert_eq!(records[1]["note"], "plain");
250 }
251
252 #[tokio::test]
253 async fn reads_quoted_field_with_embedded_delimiter() {
254 let mut tmp = NamedTempFile::new().unwrap();
255 write!(tmp, "id,name\n1,\"Doe, John\"\n").unwrap();
256 tmp.flush().unwrap();
257
258 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
259 let source = CsvSource::new(config);
260 let records = source.fetch_all().await.unwrap();
261 assert_eq!(records.len(), 1);
262 assert_eq!(records[0]["name"], "Doe, John");
263 }
264
265 #[tokio::test]
266 async fn empty_csv_returns_empty_vec() {
267 let mut tmp = NamedTempFile::new().unwrap();
268 writeln!(tmp, "id,name").unwrap();
269 tmp.flush().unwrap();
270
271 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
272 let source = CsvSource::new(config);
273 let records = source.fetch_all().await.unwrap();
274
275 assert!(records.is_empty());
276 }
277
278 #[tokio::test]
279 async fn missing_file_returns_error() {
280 let config = CsvSourceConfig::new("/nonexistent/path/data.csv");
281 let source = CsvSource::new(config);
282 let result = source.fetch_all().await;
283
284 assert!(result.is_err());
285 }
286
287 #[cfg(feature = "compression")]
288 #[tokio::test]
289 async fn roundtrip_gzip_via_stream_pages() {
290 use faucet_core::CompressionConfig;
291 let tmp = NamedTempFile::with_suffix(".csv.gz").unwrap();
292 let path = tmp.path().to_str().unwrap().to_string();
293 let plain = b"id,name\n1,Alice\n2,Bob\n";
294 let compressed =
295 faucet_core::compression::compress_buf(plain, faucet_core::Compression::Gzip).unwrap();
296 tokio::fs::write(&path, &compressed).await.unwrap();
297
298 let config = CsvSourceConfig::new(&path).compression(CompressionConfig::Auto);
299 let source = CsvSource::new(config);
300 let records = source.fetch_all().await.unwrap();
301 assert_eq!(records.len(), 2);
302 assert_eq!(records[0]["name"], "Alice");
303 assert_eq!(records[1]["name"], "Bob");
304 }
305
306 #[cfg(feature = "compression")]
307 #[tokio::test]
308 async fn roundtrip_zstd_via_stream_pages() {
309 use faucet_core::CompressionConfig;
310 let tmp = NamedTempFile::with_suffix(".csv.zst").unwrap();
311 let path = tmp.path().to_str().unwrap().to_string();
312 let plain = b"id,name\n1,Carol\n";
313 let compressed =
314 faucet_core::compression::compress_buf(plain, faucet_core::Compression::Zstd).unwrap();
315 tokio::fs::write(&path, &compressed).await.unwrap();
316
317 let config = CsvSourceConfig::new(&path).compression(CompressionConfig::Auto);
318 let source = CsvSource::new(config);
319 let records = source.fetch_all().await.unwrap();
320 assert_eq!(records.len(), 1);
321 assert_eq!(records[0]["name"], "Carol");
322 }
323
324 #[test]
325 fn dataset_uri_reflects_path() {
326 let source = CsvSource::new(CsvSourceConfig::new("/data/input.csv"));
328 assert_eq!(source.dataset_uri(), "file:///data/input.csv");
329 }
330}