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 connector_name(&self) -> &'static str {
224 "csv"
225 }
226
227 fn config_schema(&self) -> serde_json::Value {
228 serde_json::to_value(faucet_core::schema_for!(CsvSourceConfig))
229 .expect("schema serialization")
230 }
231
232 fn dataset_uri(&self) -> String {
233 format!("file://{}", self.config.path)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use faucet_core::Source;
241 use std::io::Write;
242 use tempfile::NamedTempFile;
243
244 #[tokio::test]
245 async fn reads_csv_with_headers() {
246 let mut tmp = NamedTempFile::new().unwrap();
247 writeln!(tmp, "id,name,age").unwrap();
248 writeln!(tmp, "1,Alice,30").unwrap();
249 writeln!(tmp, "2,Bob,25").unwrap();
250 tmp.flush().unwrap();
251
252 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
253 let source = CsvSource::new(config);
254 let records = source.fetch_all().await.unwrap();
255
256 assert_eq!(records.len(), 2);
257 assert_eq!(records[0]["id"], "1");
258 assert_eq!(records[0]["name"], "Alice");
259 assert_eq!(records[0]["age"], "30");
260 assert_eq!(records[1]["name"], "Bob");
261 }
262
263 #[tokio::test]
264 async fn reads_csv_without_headers() {
265 let mut tmp = NamedTempFile::new().unwrap();
266 writeln!(tmp, "Alice,30").unwrap();
267 writeln!(tmp, "Bob,25").unwrap();
268 tmp.flush().unwrap();
269
270 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).has_headers(false);
271 let source = CsvSource::new(config);
272 let records = source.fetch_all().await.unwrap();
273
274 assert_eq!(records.len(), 2);
275 assert_eq!(records[0]["column_0"], "Alice");
276 assert_eq!(records[0]["column_1"], "30");
277 }
278
279 #[tokio::test]
280 async fn reads_tsv_with_custom_delimiter() {
281 let mut tmp = NamedTempFile::new().unwrap();
282 writeln!(tmp, "id\tname").unwrap();
283 writeln!(tmp, "1\tAlice").unwrap();
284 tmp.flush().unwrap();
285
286 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).delimiter(b'\t');
287 let source = CsvSource::new(config);
288 let records = source.fetch_all().await.unwrap();
289
290 assert_eq!(records.len(), 1);
291 assert_eq!(records[0]["id"], "1");
292 assert_eq!(records[0]["name"], "Alice");
293 }
294
295 #[tokio::test]
296 async fn reads_quoted_field_with_embedded_newline() {
297 let mut tmp = NamedTempFile::new().unwrap();
300 write!(tmp, "id,note\n1,\"line one\nline two\"\n2,\"plain\"\n").unwrap();
301 tmp.flush().unwrap();
302
303 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
304 let source = CsvSource::new(config);
305 let records = source.fetch_all().await.unwrap();
306
307 assert_eq!(records.len(), 2);
308 assert_eq!(records[0]["id"], "1");
309 assert_eq!(records[0]["note"], "line one\nline two");
310 assert_eq!(records[1]["note"], "plain");
311 }
312
313 #[tokio::test]
314 async fn reads_quoted_field_with_embedded_delimiter() {
315 let mut tmp = NamedTempFile::new().unwrap();
316 write!(tmp, "id,name\n1,\"Doe, John\"\n").unwrap();
317 tmp.flush().unwrap();
318
319 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
320 let source = CsvSource::new(config);
321 let records = source.fetch_all().await.unwrap();
322 assert_eq!(records.len(), 1);
323 assert_eq!(records[0]["name"], "Doe, John");
324 }
325
326 #[tokio::test]
327 async fn empty_csv_returns_empty_vec() {
328 let mut tmp = NamedTempFile::new().unwrap();
329 writeln!(tmp, "id,name").unwrap();
330 tmp.flush().unwrap();
331
332 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
333 let source = CsvSource::new(config);
334 let records = source.fetch_all().await.unwrap();
335
336 assert!(records.is_empty());
337 }
338
339 #[tokio::test]
340 async fn missing_file_returns_error() {
341 let config = CsvSourceConfig::new("/nonexistent/path/data.csv");
342 let source = CsvSource::new(config);
343 let result = source.fetch_all().await;
344
345 assert!(result.is_err());
346 }
347
348 #[cfg(feature = "compression")]
349 #[tokio::test]
350 async fn roundtrip_gzip_via_stream_pages() {
351 use faucet_core::CompressionConfig;
352 let tmp = NamedTempFile::with_suffix(".csv.gz").unwrap();
353 let path = tmp.path().to_str().unwrap().to_string();
354 let plain = b"id,name\n1,Alice\n2,Bob\n";
355 let compressed =
356 faucet_core::compression::compress_buf(plain, faucet_core::Compression::Gzip).unwrap();
357 tokio::fs::write(&path, &compressed).await.unwrap();
358
359 let config = CsvSourceConfig::new(&path).compression(CompressionConfig::Auto);
360 let source = CsvSource::new(config);
361 let records = source.fetch_all().await.unwrap();
362 assert_eq!(records.len(), 2);
363 assert_eq!(records[0]["name"], "Alice");
364 assert_eq!(records[1]["name"], "Bob");
365 }
366
367 #[cfg(feature = "compression")]
368 #[tokio::test]
369 async fn roundtrip_zstd_via_stream_pages() {
370 use faucet_core::CompressionConfig;
371 let tmp = NamedTempFile::with_suffix(".csv.zst").unwrap();
372 let path = tmp.path().to_str().unwrap().to_string();
373 let plain = b"id,name\n1,Carol\n";
374 let compressed =
375 faucet_core::compression::compress_buf(plain, faucet_core::Compression::Zstd).unwrap();
376 tokio::fs::write(&path, &compressed).await.unwrap();
377
378 let config = CsvSourceConfig::new(&path).compression(CompressionConfig::Auto);
379 let source = CsvSource::new(config);
380 let records = source.fetch_all().await.unwrap();
381 assert_eq!(records.len(), 1);
382 assert_eq!(records[0]["name"], "Carol");
383 }
384
385 #[tokio::test]
386 async fn duplicate_header_names_fail_fast() {
387 let mut tmp = NamedTempFile::new().unwrap();
390 writeln!(tmp, "id,name,id").unwrap();
391 writeln!(tmp, "1,a,2").unwrap();
392 tmp.flush().unwrap();
393
394 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
395 let source = CsvSource::new(config);
396 let result = source.fetch_all().await;
397
398 let err = result.expect_err("duplicate header must error, not drop a column");
399 assert!(
400 matches!(err, FaucetError::Config(_)),
401 "expected FaucetError::Config, got {err:?}"
402 );
403 let msg = err.to_string();
404 assert!(msg.contains("duplicate CSV header"), "message was: {msg}");
405 assert!(msg.contains("id"), "message should name the header: {msg}");
406 assert!(
407 msg.contains("columns 0 and 2"),
408 "message should name positions: {msg}"
409 );
410 }
411
412 #[tokio::test]
413 async fn duplicate_blank_header_names_fail_fast() {
414 let mut tmp = NamedTempFile::new().unwrap();
416 writeln!(tmp, "id,,").unwrap();
417 writeln!(tmp, "1,a,b").unwrap();
418 tmp.flush().unwrap();
419
420 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
421 let source = CsvSource::new(config);
422 let result = source.fetch_all().await;
423
424 let err = result.expect_err("duplicate blank header must error");
425 assert!(matches!(err, FaucetError::Config(_)), "got {err:?}");
426 let msg = err.to_string();
427 assert!(msg.contains("duplicate CSV header"), "message was: {msg}");
428 assert!(
429 msg.contains("(empty)"),
430 "blank header should render as (empty): {msg}"
431 );
432 assert!(msg.contains("columns 1 and 2"), "positions: {msg}");
433 }
434
435 #[tokio::test]
436 async fn headerless_csv_allows_repeated_values_without_error() {
437 let mut tmp = NamedTempFile::new().unwrap();
440 writeln!(tmp, "1,1,1").unwrap();
441 tmp.flush().unwrap();
442
443 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).has_headers(false);
444 let source = CsvSource::new(config);
445 let records = source.fetch_all().await.unwrap();
446
447 assert_eq!(records.len(), 1);
448 assert_eq!(records[0]["column_0"], "1");
449 assert_eq!(records[0]["column_1"], "1");
450 assert_eq!(records[0]["column_2"], "1");
451 }
452
453 #[test]
454 fn dataset_uri_reflects_path() {
455 let source = CsvSource::new(CsvSourceConfig::new("/data/input.csv"));
457 assert_eq!(source.dataset_uri(), "file:///data/input.csv");
458 }
459
460 #[test]
461 fn ragged_row_message_names_line_path_and_detail_and_hints_optin() {
462 let msg = ragged_row_message(
463 3,
464 "/data/in.csv",
465 "found record with 2 fields, but the previous record has 4 fields",
466 );
467 assert!(msg.contains("line 3"), "msg: {msg}");
468 assert!(msg.contains("/data/in.csv"), "msg: {msg}");
469 assert!(msg.contains("2 fields"), "msg: {msg}");
470 assert!(msg.contains("4 fields"), "msg: {msg}");
471 assert!(msg.contains("structural defect"), "msg: {msg}");
472 assert!(msg.contains("flexible: true"), "msg: {msg}");
473 }
474
475 #[tokio::test]
476 async fn ragged_short_row_errors_by_default() {
477 let mut tmp = NamedTempFile::new().unwrap();
481 writeln!(tmp, "id,name,age").unwrap();
482 writeln!(tmp, "1,Alice,30").unwrap();
483 writeln!(tmp, "2,Bob").unwrap(); tmp.flush().unwrap();
485
486 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
487 let source = CsvSource::new(config);
488 let result = source.fetch_all().await;
489
490 let err = result.expect_err("ragged row must error under default strict mode");
491 assert!(
492 matches!(err, FaucetError::Source(_)),
493 "expected FaucetError::Source, got {err:?}"
494 );
495 let msg = err.to_string();
496 assert!(msg.contains("ragged CSV row"), "message was: {msg}");
497 assert!(
498 msg.contains("line 3"),
499 "should name the offending line: {msg}"
500 );
501 assert!(msg.contains("2 fields"), "should name the count: {msg}");
502 assert!(
503 msg.contains("flexible: true"),
504 "should hint the opt-in: {msg}"
505 );
506 }
507
508 #[tokio::test]
509 async fn first_data_row_short_vs_header_errors_by_default() {
510 let mut tmp = NamedTempFile::new().unwrap();
514 writeln!(tmp, "id,name,age").unwrap();
515 writeln!(tmp, "1,Alice").unwrap(); tmp.flush().unwrap();
517
518 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
519 let source = CsvSource::new(config);
520 let err = source
521 .fetch_all()
522 .await
523 .expect_err("short first data row must error vs header");
524 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
525 let msg = err.to_string();
526 assert!(msg.contains("ragged CSV row"), "msg: {msg}");
527 assert!(msg.contains("2 fields"), "msg: {msg}");
528 assert!(msg.contains("3 fields"), "msg: {msg}");
529 assert!(msg.contains("line 2"), "msg: {msg}");
530 }
531
532 #[tokio::test]
533 async fn ragged_long_row_errors_by_default() {
534 let mut tmp = NamedTempFile::new().unwrap();
536 writeln!(tmp, "id,name").unwrap();
537 writeln!(tmp, "1,Alice,extra").unwrap();
538 tmp.flush().unwrap();
539
540 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
541 let source = CsvSource::new(config);
542 let err = source
543 .fetch_all()
544 .await
545 .expect_err("long row must error under strict mode");
546 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
547 assert!(err.to_string().contains("line 2"), "{err:?}");
548 }
549
550 #[tokio::test]
551 async fn ragged_row_accepted_when_flexible() {
552 let mut tmp = NamedTempFile::new().unwrap();
555 writeln!(tmp, "id,name,age").unwrap();
556 writeln!(tmp, "1,Alice,30").unwrap();
557 writeln!(tmp, "2,Bob").unwrap();
558 tmp.flush().unwrap();
559
560 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).flexible(true);
561 let source = CsvSource::new(config);
562 let records = source.fetch_all().await.unwrap();
563
564 assert_eq!(records.len(), 2);
565 assert_eq!(records[1]["id"], "2");
566 assert_eq!(records[1]["name"], "Bob");
567 assert!(records[1].get("age").is_none());
570 }
571
572 #[tokio::test]
573 async fn long_row_accepted_when_flexible_gains_generated_key() {
574 let mut tmp = NamedTempFile::new().unwrap();
575 writeln!(tmp, "id,name").unwrap();
576 writeln!(tmp, "1,Alice,extra").unwrap();
577 tmp.flush().unwrap();
578
579 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).flexible(true);
580 let source = CsvSource::new(config);
581 let records = source.fetch_all().await.unwrap();
582 assert_eq!(records.len(), 1);
583 assert_eq!(records[0]["column_2"], "extra");
584 }
585
586 #[tokio::test]
587 async fn headerless_ragged_row_errors_by_default() {
588 let mut tmp = NamedTempFile::new().unwrap();
592 writeln!(tmp, "a,b,c").unwrap();
593 writeln!(tmp, "d,e").unwrap();
594 tmp.flush().unwrap();
595
596 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).has_headers(false);
597 let source = CsvSource::new(config);
598 let err = source
599 .fetch_all()
600 .await
601 .expect_err("headerless ragged row must error");
602 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
603 }
604
605 #[tokio::test]
606 async fn well_formed_csv_still_parses_under_strict_default() {
607 let mut tmp = NamedTempFile::new().unwrap();
609 writeln!(tmp, "id,name,age").unwrap();
610 writeln!(tmp, "1,Alice,30").unwrap();
611 writeln!(tmp, "2,Bob,25").unwrap();
612 tmp.flush().unwrap();
613
614 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap());
615 let source = CsvSource::new(config);
616 let records = source.fetch_all().await.unwrap();
617 assert_eq!(records.len(), 2);
618 assert_eq!(records[1]["age"], "25");
619 }
620
621 #[tokio::test]
622 async fn strict_mode_honored_when_buffered_into_single_page() {
623 let mut tmp = NamedTempFile::new().unwrap();
626 writeln!(tmp, "id,name").unwrap();
627 writeln!(tmp, "1,Alice").unwrap();
628 writeln!(tmp, "2").unwrap();
629 tmp.flush().unwrap();
630
631 let config = CsvSourceConfig::new(tmp.path().to_str().unwrap()).with_batch_size(0);
632 let source = CsvSource::new(config);
633 let err = source
634 .fetch_all()
635 .await
636 .expect_err("buffered drain must also enforce strict mode");
637 assert!(matches!(err, FaucetError::Source(_)), "got {err:?}");
638 }
639}