Skip to main content

faucet_source_csv/
stream.rs

1//! CSV file source.
2
3use crate::config::CsvSourceConfig;
4use async_trait::async_trait;
5use faucet_core::{FaucetError, Stream, StreamPage};
6use serde_json::{Map, Value};
7use std::pin::Pin;
8
9/// A source that reads records from a CSV file.
10///
11/// Each row is returned as a JSON object. If the file has headers, the header
12/// names are used as keys. Otherwise, generated names (`column_0`, `column_1`,
13/// etc.) are used.
14pub struct CsvSource {
15    config: CsvSourceConfig,
16}
17
18impl CsvSource {
19    /// Create a new CSV source from the given configuration.
20    pub fn new(config: CsvSourceConfig) -> Self {
21        Self { config }
22    }
23}
24
25/// Build the strict-mode field-count-mismatch error message for a ragged row.
26///
27/// Pure helper (no I/O) so the message wording is unit-testable. `line` is the
28/// 1-based physical file line; `detail` is the underlying `csv-async`
29/// description of the mismatch (which names the field counts).
30fn 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    /// Stream rows from the CSV file without buffering the full result set.
55    ///
56    /// The file is opened via [`tokio::fs::File`] and parsed by `csv-async`,
57    /// a streaming RFC-4180 reader. Because it tracks quote state across
58    /// physical lines, **quoted fields containing embedded newlines are parsed
59    /// correctly** — the file written by `faucet-sink-csv` round-trips back
60    /// losslessly (#78/#40). Each emitted [`StreamPage`] holds up to
61    /// [`CsvSourceConfig::batch_size`] rows.
62    ///
63    /// The trait-level `batch_size` argument is ignored in favour of the
64    /// config field — the config is the user-facing knob the README documents,
65    /// and routing the pipeline-supplied hint through it would silently
66    /// override an explicit config value.
67    ///
68    /// `batch_size = 0` drains the entire file into a single page. The CSV
69    /// source has no incremental-replication mode, so every emitted page
70    /// carries `bookmark: None`.
71    ///
72    /// Field-count leniency is opt-in via [`CsvSourceConfig::flexible`]
73    /// (default `false`): a ragged row aborts the stream with a typed
74    /// [`FaucetError::Source`] naming the offending line rather than emitting an
75    /// incomplete record.
76    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            // `csv-async` tracks quote state across physical lines, so a
106            // record whose field contains an embedded newline is read as one
107            // record. Headers are handled manually below, so the underlying
108            // reader is configured header-less.
109            let mut csv_reader = csv_async::AsyncReaderBuilder::new()
110                .has_headers(false)
111                .delimiter(config.delimiter)
112                .quote(config.quote)
113                // Field-count leniency is opt-in (`flexible`, default false). A
114                // ragged row is a structural defect; silently emitting an
115                // incomplete record would corrupt downstream consumers. The
116                // header row is parsed manually below, so `csv-async` would only
117                // compare *data* rows against each other — it never sees the
118                // header. We therefore also enforce the expected width
119                // explicitly per data row (see `check_field_count`), which
120                // catches a first data row that is short relative to the header.
121                .flexible(config.flexible)
122                .create_reader(reader);
123
124            let mut records = csv_reader.records();
125
126            // The first record is the header row when configured.
127            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                        // Reject duplicate header names up front: each row becomes a
135                        // JSON object keyed by header name, so a repeated header (or
136                        // two blank-named columns) would silently overwrite earlier
137                        // columns last-wins, dropping data for the whole run. Fail
138                        // fast naming the offending header and its 0-based positions.
139                        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                // Physical file line: `row_idx` is the 0-based *data* row, so
169                // +1 for 1-based, and +1 more when a header row was consumed —
170                // otherwise the first data row (file line 2) is misreported as 1.
171                let line = row_idx + 1 + usize::from(config.has_headers);
172                let record = rec.map_err(|e| {
173                    // With `flexible(false)` (the strict default), `csv-async`
174                    // raises `UnequalLengths` for any row whose field count
175                    // differs from the prior record — and because the header is
176                    // the first physical record, this catches a data row that is
177                    // short or long relative to the header too. A ragged row is a
178                    // structural defect, not a config error, so surface it as
179                    // `Source` (with `flexible: true` the reader never raises it).
180                    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        // Regression for #78/#40: a quoted field spanning multiple physical
298        // lines (as the CSV sink emits) must round-trip as one record.
299        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        // Data-loss regression (F7, audit #264): a repeated header name must be
388        // rejected, not silently overwrite the earlier column last-wins.
389        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        // Two empty-string headers must be caught too.
415        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        // With has_headers=false there are no header names to collide; the
438        // duplicate-header guard must not fire (keys are generated column_N).
439        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        // CsvSource::new is sync — construct directly.
456        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        // F22: a data row with fewer fields than the header is a structural
478        // defect and must abort with a typed error, not silently emit a record
479        // missing columns.
480        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(); // short row — missing `age`
484        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        // The *first* data row being short relative to the header must error.
511        // `csv-async` compares against the header (its first physical record),
512        // so this is caught even though the header is mapped to keys manually.
513        let mut tmp = NamedTempFile::new().unwrap();
514        writeln!(tmp, "id,name,age").unwrap();
515        writeln!(tmp, "1,Alice").unwrap(); // first data row, short
516        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        // A row with *more* fields than the header is equally a defect.
535        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        // Opting in to `flexible` preserves the previous lenient behavior: the
553        // short row is accepted and emitted with the columns it does have.
554        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        // The short row is missing `age` — present but incomplete (the opt-in
568        // contract).
569        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        // Header-less: the first data row sets the expected width; a later row
589        // of a different width must error (csv-async itself would catch this,
590        // but verify the path under our config plumbing).
591        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        // The strict default must not regress well-formed files.
608        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        // The buffered path (batch_size = 0) drains via the same stream_pages
624        // loop, so it must enforce strictness too (finding cites two locations).
625        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}