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
25fn ragged_row_message(line: usize, path: &str, detail: &str) -> String {
31 format!(
32 "ragged CSV row at line {line} in '{path}': {detail} — a short or long \
33 row is a structural defect that would silently corrupt downstream \
34 records; fix the file or set `flexible: true` to accept uneven rows"
35 )
36}
37
38#[async_trait]
39impl faucet_core::Source for CsvSource {
40 async fn fetch_with_context(
41 &self,
42 context: &std::collections::HashMap<String, serde_json::Value>,
43 ) -> Result<Vec<Value>, FaucetError> {
44 use futures::StreamExt;
45 let mut all = Vec::new();
46 let mut s = self.stream_pages(context, self.config.batch_size);
47 while let Some(page) = s.next().await {
48 let page = page?;
49 all.extend(page.records);
50 }
51 Ok(all)
52 }
53
54 fn stream_pages<'a>(
77 &'a self,
78 context: &'a std::collections::HashMap<String, Value>,
79 _batch_size: usize,
80 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
81 let batch_size = self.config.batch_size;
82
83 Box::pin(async_stream::try_stream! {
84 use futures::StreamExt as _;
85
86 let mut config = self.config.clone();
87 if !context.is_empty() {
88 config.path = faucet_core::util::substitute_context(&config.path, context);
89 }
90
91 let file = tokio::fs::File::open(&config.path).await.map_err(|e| {
92 FaucetError::Config(format!(
93 "failed to open CSV file '{}': {e}",
94 config.path
95 ))
96 })?;
97 let reader = tokio::io::BufReader::new(file);
98 #[cfg(feature = "compression")]
99 let reader = {
100 let codec = config.compression.resolve(&config.path);
101 faucet_core::compression::warn_mismatch(&config.path, codec);
102 faucet_core::compression::wrap_async_reader(reader, codec)
103 };
104
105 let mut csv_reader = csv_async::AsyncReaderBuilder::new()
110 .has_headers(false)
111 .delimiter(config.delimiter)
112 .quote(config.quote)
113 .flexible(config.flexible)
122 .create_reader(reader);
123
124 let mut records = csv_reader.records();
125
126 let headers: Vec<String> = if config.has_headers {
128 match records.next().await {
129 Some(rec) => {
130 let rec = rec.map_err(|e| FaucetError::Config(format!(
131 "CSV header parse error in '{}': {e}", config.path
132 )))?;
133 let headers: Vec<String> = rec.iter().map(|f| f.to_string()).collect();
134 let mut seen: std::collections::HashMap<&str, usize> =
140 std::collections::HashMap::with_capacity(headers.len());
141 for (col_idx, name) in headers.iter().enumerate() {
142 if let Some(&first_idx) = seen.get(name.as_str()) {
143 let display = if name.is_empty() { "(empty)" } else { name.as_str() };
144 Err(FaucetError::Config(format!(
145 "duplicate CSV header {display} in '{}' at columns {first_idx} and {col_idx}; \
146 each row is keyed by header name, so a repeated header would silently drop columns — \
147 rename the duplicate or disable headers",
148 config.path
149 )))?;
150 }
151 seen.insert(name.as_str(), col_idx);
152 }
153 headers
154 }
155 None => Vec::new(),
156 }
157 } else {
158 Vec::new()
159 };
160
161 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
162 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
163 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
164 let mut total = 0usize;
165 let mut row_idx = 0usize;
166
167 while let Some(rec) = records.next().await {
168 let line = row_idx + 1 + usize::from(config.has_headers);
172 let record = rec.map_err(|e| {
173 if matches!(e.kind(), csv_async::ErrorKind::UnequalLengths { .. }) {
181 FaucetError::Source(ragged_row_message(line, &config.path, &e.to_string()))
182 } else {
183 FaucetError::Config(format!(
184 "CSV parse error at line {line} in '{}': {e}",
185 config.path
186 ))
187 }
188 })?;
189
190 let mut obj = Map::new();
191 for (col_idx, field) in record.iter().enumerate() {
192 let key = if col_idx < headers.len() {
193 headers[col_idx].clone()
194 } else {
195 format!("column_{col_idx}")
196 };
197 obj.insert(key, Value::String(field.to_string()));
198 }
199 buffer.push(Value::Object(obj));
200 row_idx += 1;
201
202 if buffer.len() >= chunk {
203 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
204 total += page.len();
205 yield StreamPage { records: page, bookmark: None };
206 }
207 }
208
209 if !buffer.is_empty() {
210 total += buffer.len();
211 yield StreamPage { records: buffer, bookmark: None };
212 }
213
214 tracing::info!(
215 rows = total,
216 batch_size,
217 path = %config.path,
218 "CSV source stream complete",
219 );
220 })
221 }
222
223 fn config_schema(&self) -> serde_json::Value {
224 serde_json::to_value(faucet_core::schema_for!(CsvSourceConfig))
225 .expect("schema serialization")
226 }
227
228 fn dataset_uri(&self) -> String {
229 format!("file://{}", self.config.path)
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use faucet_core::Source;
237 use std::io::Write;
238 use tempfile::NamedTempFile;
239
240 #[tokio::test]
241 async fn reads_csv_with_headers() {
242 let mut tmp = NamedTempFile::new().unwrap();
243 writeln!(tmp, "id,name,age").unwrap();
244 writeln!(tmp, "1,Alice,30").unwrap();
245 writeln!(tmp, "2,Bob,25").unwrap();
246 tmp.flush().unwrap();
247
248 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
249 let source = CsvSource::new(config);
250 let records = source.fetch_all().await.unwrap();
251
252 assert_eq!(records.len(), 2);
253 assert_eq!(records[0]["id"], "1");
254 assert_eq!(records[0]["name"], "Alice");
255 assert_eq!(records[0]["age"], "30");
256 assert_eq!(records[1]["name"], "Bob");
257 }
258
259 #[tokio::test]
260 async fn reads_csv_without_headers() {
261 let mut tmp = NamedTempFile::new().unwrap();
262 writeln!(tmp, "Alice,30").unwrap();
263 writeln!(tmp, "Bob,25").unwrap();
264 tmp.flush().unwrap();
265
266 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).has_headers(false);
267 let source = CsvSource::new(config);
268 let records = source.fetch_all().await.unwrap();
269
270 assert_eq!(records.len(), 2);
271 assert_eq!(records[0]["column_0"], "Alice");
272 assert_eq!(records[0]["column_1"], "30");
273 }
274
275 #[tokio::test]
276 async fn reads_tsv_with_custom_delimiter() {
277 let mut tmp = NamedTempFile::new().unwrap();
278 writeln!(tmp, "id\tname").unwrap();
279 writeln!(tmp, "1\tAlice").unwrap();
280 tmp.flush().unwrap();
281
282 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).delimiter(b'\t');
283 let source = CsvSource::new(config);
284 let records = source.fetch_all().await.unwrap();
285
286 assert_eq!(records.len(), 1);
287 assert_eq!(records[0]["id"], "1");
288 assert_eq!(records[0]["name"], "Alice");
289 }
290
291 #[tokio::test]
292 async fn reads_quoted_field_with_embedded_newline() {
293 let mut tmp = NamedTempFile::new().unwrap();
296 write!(tmp, "id,note\n1,\"line one\nline two\"\n2,\"plain\"\n").unwrap();
297 tmp.flush().unwrap();
298
299 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
300 let source = CsvSource::new(config);
301 let records = source.fetch_all().await.unwrap();
302
303 assert_eq!(records.len(), 2);
304 assert_eq!(records[0]["id"], "1");
305 assert_eq!(records[0]["note"], "line one\nline two");
306 assert_eq!(records[1]["note"], "plain");
307 }
308
309 #[tokio::test]
310 async fn reads_quoted_field_with_embedded_delimiter() {
311 let mut tmp = NamedTempFile::new().unwrap();
312 write!(tmp, "id,name\n1,\"Doe, John\"\n").unwrap();
313 tmp.flush().unwrap();
314
315 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
316 let source = CsvSource::new(config);
317 let records = source.fetch_all().await.unwrap();
318 assert_eq!(records.len(), 1);
319 assert_eq!(records[0]["name"], "Doe, John");
320 }
321
322 #[tokio::test]
323 async fn empty_csv_returns_empty_vec() {
324 let mut tmp = NamedTempFile::new().unwrap();
325 writeln!(tmp, "id,name").unwrap();
326 tmp.flush().unwrap();
327
328 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
329 let source = CsvSource::new(config);
330 let records = source.fetch_all().await.unwrap();
331
332 assert!(records.is_empty());
333 }
334
335 #[tokio::test]
336 async fn missing_file_returns_error() {
337 let config = CsvSourceConfig::new("/nonexistent/path/data.csv");
338 let source = CsvSource::new(config);
339 let result = source.fetch_all().await;
340
341 assert!(result.is_err());
342 }
343
344 #[cfg(feature = "compression")]
345 #[tokio::test]
346 async fn roundtrip_gzip_via_stream_pages() {
347 use faucet_core::CompressionConfig;
348 let tmp = NamedTempFile::with_suffix(".csv.gz").unwrap();
349 let path = tmp.path().to_str().unwrap().to_string();
350 let plain = b"id,name\n1,Alice\n2,Bob\n";
351 let compressed =
352 faucet_core::compression::compress_buf(plain, faucet_core::Compression::Gzip).unwrap();
353 tokio::fs::write(&path, &compressed).await.unwrap();
354
355 let config = CsvSourceConfig::new(&path).compression(CompressionConfig::Auto);
356 let source = CsvSource::new(config);
357 let records = source.fetch_all().await.unwrap();
358 assert_eq!(records.len(), 2);
359 assert_eq!(records[0]["name"], "Alice");
360 assert_eq!(records[1]["name"], "Bob");
361 }
362
363 #[cfg(feature = "compression")]
364 #[tokio::test]
365 async fn roundtrip_zstd_via_stream_pages() {
366 use faucet_core::CompressionConfig;
367 let tmp = NamedTempFile::with_suffix(".csv.zst").unwrap();
368 let path = tmp.path().to_str().unwrap().to_string();
369 let plain = b"id,name\n1,Carol\n";
370 let compressed =
371 faucet_core::compression::compress_buf(plain, faucet_core::Compression::Zstd).unwrap();
372 tokio::fs::write(&path, &compressed).await.unwrap();
373
374 let config = CsvSourceConfig::new(&path).compression(CompressionConfig::Auto);
375 let source = CsvSource::new(config);
376 let records = source.fetch_all().await.unwrap();
377 assert_eq!(records.len(), 1);
378 assert_eq!(records[0]["name"], "Carol");
379 }
380
381 #[tokio::test]
382 async fn duplicate_header_names_fail_fast() {
383 let mut tmp = NamedTempFile::new().unwrap();
386 writeln!(tmp, "id,name,id").unwrap();
387 writeln!(tmp, "1,a,2").unwrap();
388 tmp.flush().unwrap();
389
390 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
391 let source = CsvSource::new(config);
392 let result = source.fetch_all().await;
393
394 let err = result.expect_err("duplicate header must error, not drop a column");
395 assert!(
396 matches!(err, FaucetError::Config(_)),
397 "expected FaucetError::Config, got {err:?}"
398 );
399 let msg = err.to_string();
400 assert!(msg.contains("duplicate CSV header"), "message was: {msg}");
401 assert!(msg.contains("id"), "message should name the header: {msg}");
402 assert!(
403 msg.contains("columns 0 and 2"),
404 "message should name positions: {msg}"
405 );
406 }
407
408 #[tokio::test]
409 async fn duplicate_blank_header_names_fail_fast() {
410 let mut tmp = NamedTempFile::new().unwrap();
412 writeln!(tmp, "id,,").unwrap();
413 writeln!(tmp, "1,a,b").unwrap();
414 tmp.flush().unwrap();
415
416 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
417 let source = CsvSource::new(config);
418 let result = source.fetch_all().await;
419
420 let err = result.expect_err("duplicate blank header must error");
421 assert!(matches!(err, FaucetError::Config(_)), "got {err:?}");
422 let msg = err.to_string();
423 assert!(msg.contains("duplicate CSV header"), "message was: {msg}");
424 assert!(
425 msg.contains("(empty)"),
426 "blank header should render as (empty): {msg}"
427 );
428 assert!(msg.contains("columns 1 and 2"), "positions: {msg}");
429 }
430
431 #[tokio::test]
432 async fn headerless_csv_allows_repeated_values_without_error() {
433 let mut tmp = NamedTempFile::new().unwrap();
436 writeln!(tmp, "1,1,1").unwrap();
437 tmp.flush().unwrap();
438
439 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).has_headers(false);
440 let source = CsvSource::new(config);
441 let records = source.fetch_all().await.unwrap();
442
443 assert_eq!(records.len(), 1);
444 assert_eq!(records[0]["column_0"], "1");
445 assert_eq!(records[0]["column_1"], "1");
446 assert_eq!(records[0]["column_2"], "1");
447 }
448
449 #[test]
450 fn dataset_uri_reflects_path() {
451 let source = CsvSource::new(CsvSourceConfig::new("/data/input.csv"));
453 assert_eq!(source.dataset_uri(), "file:///data/input.csv");
454 }
455
456 #[test]
457 fn ragged_row_message_names_line_path_and_detail_and_hints_optin() {
458 let msg = ragged_row_message(
459 3,
460 "/data/in.csv",
461 "found record with 2 fields, but the previous record has 4 fields",
462 );
463 assert!(msg.contains("line 3"), "msg: {msg}");
464 assert!(msg.contains("/data/in.csv"), "msg: {msg}");
465 assert!(msg.contains("2 fields"), "msg: {msg}");
466 assert!(msg.contains("4 fields"), "msg: {msg}");
467 assert!(msg.contains("structural defect"), "msg: {msg}");
468 assert!(msg.contains("flexible: true"), "msg: {msg}");
469 }
470
471 #[tokio::test]
472 async fn ragged_short_row_errors_by_default() {
473 let mut tmp = NamedTempFile::new().unwrap();
477 writeln!(tmp, "id,name,age").unwrap();
478 writeln!(tmp, "1,Alice,30").unwrap();
479 writeln!(tmp, "2,Bob").unwrap(); tmp.flush().unwrap();
481
482 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
483 let source = CsvSource::new(config);
484 let result = source.fetch_all().await;
485
486 let err = result.expect_err("ragged row must error under default strict mode");
487 assert!(
488 matches!(err, FaucetError::Source(_)),
489 "expected FaucetError::Source, got {err:?}"
490 );
491 let msg = err.to_string();
492 assert!(msg.contains("ragged CSV row"), "message was: {msg}");
493 assert!(
494 msg.contains("line 3"),
495 "should name the offending line: {msg}"
496 );
497 assert!(msg.contains("2 fields"), "should name the count: {msg}");
498 assert!(
499 msg.contains("flexible: true"),
500 "should hint the opt-in: {msg}"
501 );
502 }
503
504 #[tokio::test]
505 async fn first_data_row_short_vs_header_errors_by_default() {
506 let mut tmp = NamedTempFile::new().unwrap();
510 writeln!(tmp, "id,name,age").unwrap();
511 writeln!(tmp, "1,Alice").unwrap(); tmp.flush().unwrap();
513
514 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
515 let source = CsvSource::new(config);
516 let err = source
517 .fetch_all()
518 .await
519 .expect_err("short first data row must error vs header");
520 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
521 let msg = err.to_string();
522 assert!(msg.contains("ragged CSV row"), "msg: {msg}");
523 assert!(msg.contains("2 fields"), "msg: {msg}");
524 assert!(msg.contains("3 fields"), "msg: {msg}");
525 assert!(msg.contains("line 2"), "msg: {msg}");
526 }
527
528 #[tokio::test]
529 async fn ragged_long_row_errors_by_default() {
530 let mut tmp = NamedTempFile::new().unwrap();
532 writeln!(tmp, "id,name").unwrap();
533 writeln!(tmp, "1,Alice,extra").unwrap();
534 tmp.flush().unwrap();
535
536 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
537 let source = CsvSource::new(config);
538 let err = source
539 .fetch_all()
540 .await
541 .expect_err("long row must error under strict mode");
542 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
543 assert!(err.to_string().contains("line 2"), "{err:?}");
544 }
545
546 #[tokio::test]
547 async fn ragged_row_accepted_when_flexible() {
548 let mut tmp = NamedTempFile::new().unwrap();
551 writeln!(tmp, "id,name,age").unwrap();
552 writeln!(tmp, "1,Alice,30").unwrap();
553 writeln!(tmp, "2,Bob").unwrap();
554 tmp.flush().unwrap();
555
556 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).flexible(true);
557 let source = CsvSource::new(config);
558 let records = source.fetch_all().await.unwrap();
559
560 assert_eq!(records.len(), 2);
561 assert_eq!(records[1]["id"], "2");
562 assert_eq!(records[1]["name"], "Bob");
563 assert!(records[1].get("age").is_none());
566 }
567
568 #[tokio::test]
569 async fn long_row_accepted_when_flexible_gains_generated_key() {
570 let mut tmp = NamedTempFile::new().unwrap();
571 writeln!(tmp, "id,name").unwrap();
572 writeln!(tmp, "1,Alice,extra").unwrap();
573 tmp.flush().unwrap();
574
575 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).flexible(true);
576 let source = CsvSource::new(config);
577 let records = source.fetch_all().await.unwrap();
578 assert_eq!(records.len(), 1);
579 assert_eq!(records[0]["column_2"], "extra");
580 }
581
582 #[tokio::test]
583 async fn headerless_ragged_row_errors_by_default() {
584 let mut tmp = NamedTempFile::new().unwrap();
588 writeln!(tmp, "a,b,c").unwrap();
589 writeln!(tmp, "d,e").unwrap();
590 tmp.flush().unwrap();
591
592 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).has_headers(false);
593 let source = CsvSource::new(config);
594 let err = source
595 .fetch_all()
596 .await
597 .expect_err("headerless ragged row must error");
598 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
599 }
600
601 #[tokio::test]
602 async fn well_formed_csv_still_parses_under_strict_default() {
603 let mut tmp = NamedTempFile::new().unwrap();
605 writeln!(tmp, "id,name,age").unwrap();
606 writeln!(tmp, "1,Alice,30").unwrap();
607 writeln!(tmp, "2,Bob,25").unwrap();
608 tmp.flush().unwrap();
609
610 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
611 let source = CsvSource::new(config);
612 let records = source.fetch_all().await.unwrap();
613 assert_eq!(records.len(), 2);
614 assert_eq!(records[1]["age"], "25");
615 }
616
617 #[tokio::test]
618 async fn strict_mode_honored_when_buffered_into_single_page() {
619 let mut tmp = NamedTempFile::new().unwrap();
622 writeln!(tmp, "id,name").unwrap();
623 writeln!(tmp, "1,Alice").unwrap();
624 writeln!(tmp, "2").unwrap();
625 tmp.flush().unwrap();
626
627 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).with_batch_size(0);
628 let source = CsvSource::new(config);
629 let err = source
630 .fetch_all()
631 .await
632 .expect_err("buffered drain must also enforce strict mode");
633 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
634 }
635}