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 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        // Regression for #78/#40: a quoted field spanning multiple physical
294        // lines (as the CSV sink emits) must round-trip as one record.
295        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        // Data-loss regression (F7, audit #264): a repeated header name must be
384        // rejected, not silently overwrite the earlier column last-wins.
385        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        // Two empty-string headers must be caught too.
411        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        // With has_headers=false there are no header names to collide; the
434        // duplicate-header guard must not fire (keys are generated column_N).
435        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        // CsvSource::new is sync — construct directly.
452        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        // F22: a data row with fewer fields than the header is a structural
474        // defect and must abort with a typed error, not silently emit a record
475        // missing columns.
476        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(); // short row — missing `age`
480        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        // The *first* data row being short relative to the header must error.
507        // `csv-async` compares against the header (its first physical record),
508        // so this is caught even though the header is mapped to keys manually.
509        let mut tmp = NamedTempFile::new().unwrap();
510        writeln!(tmp, "id,name,age").unwrap();
511        writeln!(tmp, "1,Alice").unwrap(); // first data row, short
512        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        // A row with *more* fields than the header is equally a defect.
531        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        // Opting in to `flexible` preserves the previous lenient behavior: the
549        // short row is accepted and emitted with the columns it does have.
550        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        // The short row is missing `age` — present but incomplete (the opt-in
564        // contract).
565        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        // Header-less: the first data row sets the expected width; a later row
585        // of a different width must error (csv-async itself would catch this,
586        // but verify the path under our config plumbing).
587        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        // The strict default must not regress well-formed files.
604        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        // The buffered path (batch_size = 0) drains via the same stream_pages
620        // loop, so it must enforce strictness too (finding cites two locations).
621        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}