Skip to main content

faucet_sink_csv/
sink.rs

1//! CSV file sink.
2
3use crate::config::CsvSinkConfig;
4use async_trait::async_trait;
5use faucet_core::FaucetError;
6use serde_json::Value;
7use std::fs::OpenOptions;
8use std::sync::Mutex;
9
10/// The inner writer the CSV serializer writes into. With compression enabled
11/// it is a [`SyncCompressWriter`](faucet_core::compression::SyncCompressWriter)
12/// that retains the concrete encoder so `finish()` errors surface on flush
13/// (#78/#41); otherwise it is the raw file.
14#[cfg(feature = "compression")]
15type SinkWriter = faucet_core::compression::SyncCompressWriter<std::fs::File>;
16#[cfg(not(feature = "compression"))]
17type SinkWriter = std::fs::File;
18
19/// State for the CSV writer, including the determined column order.
20struct WriterState {
21    writer: csv::Writer<SinkWriter>,
22    columns: Vec<String>,
23}
24
25/// A sink that writes JSON records to a CSV file.
26///
27/// Column order is the union of keys across the records of the first
28/// `write_batch` call, in first-seen order (so a field present only in a later
29/// record of that batch is still captured). Subsequent records use the same
30/// column order; missing fields are written as empty strings.
31///
32/// [`Sink::flush`](faucet_core::Sink::flush) finalises the encoder (writes the trailer) and clears the
33/// writer slot — a subsequent `write_batch` reopens the file in append mode
34/// (independent of `config.append`) and starts a fresh encoder. This makes
35/// the per-page `flush` the pipeline emits for bookmarked pages safe for CDC
36/// sources — every transaction appends rather than truncates.
37pub struct CsvSink {
38    config: CsvSinkConfig,
39    state: Mutex<Option<WriterState>>,
40    /// Tracks whether the file has been opened at least once.
41    /// On re-opens (after `flush()` clears the writer), we always use
42    /// append mode regardless of `config.append` so the new gzip / zstd
43    /// member appends instead of truncating the file. Without this, the
44    /// pipeline's per-bookmark flush would silently lose data when
45    /// `config.append = false` (the default).
46    opened_once: std::sync::atomic::AtomicBool,
47    /// One-shot guard for the "dropping unknown field" warning. The CSV header
48    /// is frozen from the first batch, so a field that first appears later
49    /// cannot be added to the header and its value is dropped. We warn at most
50    /// once per run (like the parquet sink's `warn_on_unknown_fields`) so the
51    /// loss is visible rather than silent, without flooding the log.
52    warned_unknown: std::sync::atomic::AtomicBool,
53}
54
55impl CsvSink {
56    /// Create a new CSV sink. The file is opened on the first `write_batch` call.
57    pub fn new(config: CsvSinkConfig) -> Self {
58        Self {
59            config,
60            state: Mutex::new(None),
61            opened_once: std::sync::atomic::AtomicBool::new(false),
62            warned_unknown: std::sync::atomic::AtomicBool::new(false),
63        }
64    }
65
66    /// Convert a JSON value to a string suitable for a CSV field.
67    fn value_to_csv_field(value: &Value) -> String {
68        match value {
69            Value::Null => String::new(),
70            Value::String(s) => s.clone(),
71            Value::Bool(b) => b.to_string(),
72            Value::Number(n) => n.to_string(),
73            // For nested objects/arrays, serialize as JSON.
74            other => other.to_string(),
75        }
76    }
77}
78
79#[async_trait]
80impl faucet_core::Sink for CsvSink {
81    fn config_schema(&self) -> serde_json::Value {
82        serde_json::to_value(faucet_core::schema_for!(CsvSinkConfig)).expect("schema serialization")
83    }
84
85    fn dataset_uri(&self) -> String {
86        format!("file://{}", self.config.path)
87    }
88
89    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
90        if records.is_empty() {
91            return Ok(0);
92        }
93
94        let config = self.config.clone();
95        let records: Vec<Value> = records.to_vec();
96
97        // Extract state from the mutex before entering the blocking task.
98        // This avoids holding the MutexGuard across an await point.
99        let current_state = {
100            let mut guard = self
101                .state
102                .lock()
103                .map_err(|e| FaucetError::Sink(format!("CSV sink lock poisoned: {e}")))?;
104            guard.take()
105        };
106
107        let opened_before = self.opened_once.load(std::sync::atomic::Ordering::Relaxed);
108        let already_warned = self
109            .warned_unknown
110            .load(std::sync::atomic::Ordering::Relaxed);
111
112        let result = tokio::task::spawn_blocking(move || {
113            write_csv_blocking(
114                config,
115                current_state,
116                &records,
117                opened_before,
118                already_warned,
119            )
120        })
121        .await
122        .map_err(|e| FaucetError::Sink(format!("CSV write task failed: {e}")))?;
123
124        let WriteOutcome {
125            state: new_state,
126            count,
127            warned_unknown,
128        } = result?;
129
130        // Mark opened. From now on, re-opens (after flush) use append mode.
131        self.opened_once
132            .store(true, std::sync::atomic::Ordering::Relaxed);
133
134        // Latch the one-shot unknown-field warning so it fires once per run.
135        if warned_unknown {
136            self.warned_unknown
137                .store(true, std::sync::atomic::Ordering::Relaxed);
138        }
139
140        // Put the state back.
141        {
142            let mut guard = self
143                .state
144                .lock()
145                .map_err(|e| FaucetError::Sink(format!("CSV sink lock poisoned: {e}")))?;
146            *guard = Some(new_state);
147        }
148
149        Ok(count)
150    }
151
152    async fn flush(&self) -> Result<(), FaucetError> {
153        // Take the state out of the mutex so we can move it into a blocking
154        // task. Replacing it with None means the next write_batch reopens
155        // the file in append mode — for compressed output this starts a
156        // fresh gzip/zstd member, which decoders read back transparently.
157        let state = {
158            let mut guard = self
159                .state
160                .lock()
161                .map_err(|e| FaucetError::Sink(format!("CSV sink lock poisoned: {e}")))?;
162            guard.take()
163        };
164        if let Some(state) = state {
165            tokio::task::spawn_blocking(move || -> Result<(), FaucetError> {
166                let WriterState { writer, .. } = state;
167                // Flush the csv serializer's buffer and recover the inner
168                // writer so the compression encoder can be finalised with its
169                // error captured, rather than swallowed on drop (#78/#41).
170                let inner = writer
171                    .into_inner()
172                    .map_err(|e| FaucetError::Sink(format!("CSV flush failed: {e}")))?;
173                #[cfg(feature = "compression")]
174                {
175                    // Writes the gzip/zstd trailer and surfaces any I/O error.
176                    inner.finish().map_err(|e| {
177                        FaucetError::Sink(format!("CSV compression finalise failed: {e}"))
178                    })?;
179                }
180                #[cfg(not(feature = "compression"))]
181                {
182                    let mut f = inner;
183                    std::io::Write::flush(&mut f)
184                        .map_err(|e| FaucetError::Sink(format!("CSV flush failed: {e}")))?;
185                }
186                Ok(())
187            })
188            .await
189            .map_err(|e| FaucetError::Sink(format!("CSV flush task failed: {e}")))??;
190        }
191        Ok(())
192    }
193
194    /// Preflight probe for `faucet doctor`. Verifies the configured output
195    /// path's parent directory exists and is writable by creating, then
196    /// immediately removing, a uniquely-named temp file there. Never touches
197    /// the user's actual output file, so it is fully idempotent.
198    async fn check(
199        &self,
200        _ctx: &faucet_core::check::CheckContext,
201    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
202        use faucet_core::check::CheckReport;
203        let path = self.config.path.clone();
204        // The filesystem probe is synchronous; run it on a blocking thread to
205        // stay off the async runtime, matching how the sink does its I/O.
206        let probe = tokio::task::spawn_blocking(move || {
207            crate::probe::probe_parent_writable(&path, std::time::Instant::now())
208        })
209        .await
210        .map_err(|e| FaucetError::Sink(format!("CSV check task failed: {e}")))?;
211        Ok(CheckReport::single(probe))
212    }
213}
214
215/// Result of one blocking CSV write: the (returned) writer state, the number
216/// of records written, and whether this batch emitted the one-shot
217/// "dropping unknown field" warning (so the caller can latch its atomic flag).
218struct WriteOutcome {
219    state: WriterState,
220    count: usize,
221    warned_unknown: bool,
222}
223
224/// Identify record keys that are absent from the frozen `columns` set, in
225/// first-seen order across `records`, deduplicated. These fields cannot be
226/// written (the header is already fixed) and would otherwise be silently
227/// dropped. Pure — no I/O — so it is unit-testable.
228fn unknown_fields(columns: &[String], records: &[Value]) -> Vec<String> {
229    let known: std::collections::HashSet<&str> = columns.iter().map(String::as_str).collect();
230    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
231    let mut out: Vec<String> = Vec::new();
232    for record in records {
233        if let Value::Object(map) = record {
234            for k in map.keys() {
235                if !known.contains(k.as_str()) && seen.insert(k.as_str()) {
236                    out.push(k.clone());
237                }
238            }
239        }
240    }
241    out
242}
243
244/// Synchronous CSV writing logic, run inside `spawn_blocking`.
245fn write_csv_blocking(
246    config: CsvSinkConfig,
247    existing_state: Option<WriterState>,
248    records: &[Value],
249    opened_before: bool,
250    already_warned: bool,
251) -> Result<WriteOutcome, FaucetError> {
252    let mut state = match existing_state {
253        Some(s) => s,
254        None => {
255            // Determine columns from the UNION of keys across the first batch's
256            // records, in first-seen order — not just `records[0]`. Otherwise a
257            // field present only in a later record of the first batch would be
258            // absent from the header and silently dropped from every row (audit
259            // #146 H2). (A later flush-segment cannot change the already-written
260            // header — that is a separate, documented limitation.)
261            let mut columns: Vec<String> = Vec::new();
262            let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
263            for record in records {
264                match record {
265                    Value::Object(map) => {
266                        for k in map.keys() {
267                            if seen.insert(k.as_str()) {
268                                columns.push(k.clone());
269                            }
270                        }
271                    }
272                    _ => {
273                        return Err(FaucetError::Sink(
274                            "CSV sink expects JSON objects, got non-object record".into(),
275                        ));
276                    }
277                }
278            }
279
280            // First open obeys `config.append`. Re-opens (after flush()
281            // cleared the writer) always append, so flush-then-write
282            // sequences do not truncate previously-written data.
283            let (append, truncate) = if opened_before {
284                (true, false)
285            } else {
286                (config.append, !config.append)
287            };
288
289            if let Some(parent) = std::path::Path::new(&config.path).parent()
290                && !parent.as_os_str().is_empty()
291            {
292                std::fs::create_dir_all(parent).map_err(|e| {
293                    FaucetError::Sink(format!(
294                        "failed to create parent directory '{}': {e}",
295                        parent.display()
296                    ))
297                })?;
298            }
299            let file = OpenOptions::new()
300                .create(true)
301                .write(true)
302                .append(append)
303                .truncate(truncate)
304                .open(&config.path)
305                .map_err(|e| {
306                    FaucetError::Sink(format!("failed to open CSV file '{}': {e}", config.path))
307                })?;
308
309            #[cfg(feature = "compression")]
310            let inner: SinkWriter = {
311                let codec = config.compression.resolve(&config.path);
312                faucet_core::compression::warn_mismatch(&config.path, codec);
313                faucet_core::compression::sync_compress_writer(file, codec)
314            };
315            #[cfg(not(feature = "compression"))]
316            let inner: SinkWriter = file;
317
318            let mut writer = csv::WriterBuilder::new()
319                .delimiter(config.delimiter)
320                .from_writer(inner);
321
322            // Write header row if configured and this is the first open.
323            if config.write_headers && !append {
324                writer
325                    .write_record(&columns)
326                    .map_err(|e| FaucetError::Sink(format!("failed to write CSV headers: {e}")))?;
327            }
328
329            WriterState { writer, columns }
330        }
331    };
332
333    // The header is now frozen (either just written, or carried over from a
334    // prior batch). Detect any record key that is not a known column — it
335    // cannot be added to the header and would be dropped from the output.
336    // Under `error` this aborts the write before any row is written; under
337    // `warn` (default) it emits a single warning per run and continues.
338    let unknown = unknown_fields(&state.columns, records);
339    let mut warned_unknown = false;
340    if !unknown.is_empty() {
341        match config.on_unknown_field {
342            crate::config::OnUnknownField::Error => {
343                return Err(FaucetError::Sink(format!(
344                    "CSV sink received record field(s) not in the frozen column set \
345                     and on_unknown_field=error: [{}]. The CSV header is fixed from the \
346                     first batch and cannot be extended; these values would be dropped.",
347                    unknown.join(", ")
348                )));
349            }
350            crate::config::OnUnknownField::Warn => {
351                if !already_warned {
352                    warned_unknown = true;
353                    tracing::warn!(
354                        fields = %unknown.join(", "),
355                        path = %config.path,
356                        "dropping field(s) not in the frozen CSV column set — the header is \
357                         fixed from the first batch and cannot be extended; set \
358                         on_unknown_field=error to fail instead"
359                    );
360                }
361            }
362        }
363    }
364
365    let mut count = 0;
366    for record in records {
367        let row: Vec<String> = state
368            .columns
369            .iter()
370            .map(|col| {
371                record
372                    .get(col)
373                    .map(CsvSink::value_to_csv_field)
374                    .unwrap_or_default()
375            })
376            .collect();
377
378        state
379            .writer
380            .write_record(&row)
381            .map_err(|e| FaucetError::Sink(format!("CSV write error: {e}")))?;
382        count += 1;
383    }
384
385    tracing::debug!(records = count, path = %config.path, "CSV batch written");
386
387    Ok(WriteOutcome {
388        state,
389        count,
390        warned_unknown,
391    })
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use faucet_core::Sink;
398    use serde_json::json;
399    use tempfile::NamedTempFile;
400
401    #[test]
402    fn dataset_uri_returns_file_scheme() {
403        let sink = CsvSink::new(CsvSinkConfig::new("/tmp/output.csv"));
404        assert_eq!(sink.dataset_uri(), "file:///tmp/output.csv");
405    }
406
407    #[tokio::test]
408    async fn writes_csv_records() {
409        let tmp = NamedTempFile::new().unwrap();
410        let path = tmp.path().to_str().unwrap().to_string();
411        let sink = CsvSink::new(CsvSinkConfig::new(&path));
412
413        let records = vec![
414            json!({"id": 1, "name": "Alice"}),
415            json!({"id": 2, "name": "Bob"}),
416        ];
417        let count = sink.write_batch(&records).await.unwrap();
418        sink.flush().await.unwrap();
419
420        assert_eq!(count, 2);
421
422        let content = tokio::fs::read_to_string(&path).await.unwrap();
423        let lines: Vec<&str> = content.trim().split('\n').collect();
424        // Header + 2 data rows.
425        assert_eq!(lines.len(), 3);
426    }
427
428    #[tokio::test]
429    async fn columns_union_across_first_batch_not_just_first_record() {
430        // H2 (audit #146): column order is the union of keys across the first
431        // batch's records. The first record lacks `email`; before the fix the
432        // header was fixed from record 0 and `email` was silently dropped from
433        // every row. After the fix `email` is a column and the second record's
434        // value appears (the first row leaves it empty).
435        let tmp = NamedTempFile::new().unwrap();
436        let path = tmp.path().to_str().unwrap().to_string();
437        let sink = CsvSink::new(CsvSinkConfig::new(&path));
438
439        let records = vec![
440            json!({ "id": 1, "name": "Alice" }),
441            json!({ "id": 2, "name": "Bob", "email": "bob@x.y" }),
442        ];
443        sink.write_batch(&records).await.unwrap();
444        sink.flush().await.unwrap();
445
446        let content = tokio::fs::read_to_string(&path).await.unwrap();
447        let lines: Vec<&str> = content.trim().split('\n').collect();
448        assert_eq!(lines.len(), 3, "header + 2 rows");
449        assert!(
450            lines[0].contains("email"),
451            "header must include the later-record-only column: {}",
452            lines[0]
453        );
454        // Row 2 carries the email value; row 1 leaves it empty.
455        assert!(
456            lines[2].contains("bob@x.y"),
457            "second row must carry the unioned column value: {}",
458            lines[2]
459        );
460    }
461
462    #[test]
463    fn unknown_fields_detects_late_keys_in_first_seen_order() {
464        let columns = vec!["id".to_string(), "name".to_string()];
465        let records = vec![
466            json!({ "id": 1, "name": "Alice" }),
467            json!({ "id": 2, "email": "b@x.y", "name": "Bob", "phone": "555" }),
468            json!({ "id": 3, "email": "c@x.y" }), // email is a dup, skipped
469            json!("not-an-object"),               // non-objects are ignored here
470        ];
471        let unknown = unknown_fields(&columns, &records);
472        assert_eq!(unknown, vec!["email".to_string(), "phone".to_string()]);
473    }
474
475    #[test]
476    fn unknown_fields_empty_when_all_known() {
477        let columns = vec!["a".to_string(), "b".to_string()];
478        let records = vec![json!({ "a": 1 }), json!({ "b": 2, "a": 3 })];
479        assert!(unknown_fields(&columns, &records).is_empty());
480    }
481
482    #[tokio::test]
483    async fn later_page_new_field_is_dropped_and_warns() {
484        // F31: the column set is frozen from the first batch. A field that
485        // first appears in a later batch (page 2) cannot be added to the
486        // already-written header, so its value is dropped — but the loss is
487        // now visible via a one-shot warning (default on_unknown_field=warn).
488        let tmp = NamedTempFile::new().unwrap();
489        let path = tmp.path().to_str().unwrap().to_string();
490        let sink = CsvSink::new(CsvSinkConfig::new(&path));
491
492        // Page 1 freezes columns to {id, name}.
493        sink.write_batch(&[json!({ "id": 1, "name": "Alice" })])
494            .await
495            .unwrap();
496        // Page 2 introduces a new field `email` absent from page 1.
497        let count = sink
498            .write_batch(&[json!({ "id": 2, "name": "Bob", "email": "bob@x.y" })])
499            .await
500            .unwrap();
501        sink.flush().await.unwrap();
502
503        assert_eq!(count, 1);
504        // The one-shot warn flag must have latched (warning path exercised).
505        assert!(
506            sink.warned_unknown
507                .load(std::sync::atomic::Ordering::Relaxed),
508            "the unknown-field warning must have fired"
509        );
510
511        let content = tokio::fs::read_to_string(&path).await.unwrap();
512        let lines: Vec<&str> = content.trim().split('\n').collect();
513        assert_eq!(lines.len(), 3, "header + 2 rows");
514        // The header carries only the first-page columns.
515        assert!(
516            !lines[0].contains("email"),
517            "header must not gain the late field: {}",
518            lines[0]
519        );
520        // The dropped field's value must NOT appear anywhere in the output.
521        assert!(
522            !content.contains("bob@x.y"),
523            "late field value must be dropped from output: {content}"
524        );
525    }
526
527    #[tokio::test]
528    async fn on_unknown_field_error_aborts_with_sink_error() {
529        use crate::config::OnUnknownField;
530        let tmp = NamedTempFile::new().unwrap();
531        let path = tmp.path().to_str().unwrap().to_string();
532        let sink = CsvSink::new(CsvSinkConfig::new(&path).on_unknown_field(OnUnknownField::Error));
533
534        sink.write_batch(&[json!({ "id": 1, "name": "Alice" })])
535            .await
536            .unwrap();
537        let err = sink
538            .write_batch(&[json!({ "id": 2, "name": "Bob", "email": "bob@x.y" })])
539            .await
540            .expect_err("a late field must abort under on_unknown_field=error");
541        match err {
542            FaucetError::Sink(msg) => {
543                assert!(msg.contains("email"), "error must name the field: {msg}");
544                assert!(
545                    msg.contains("on_unknown_field=error"),
546                    "error must explain: {msg}"
547                );
548            }
549            other => panic!("expected FaucetError::Sink, got {other:?}"),
550        }
551    }
552
553    #[tokio::test]
554    async fn first_batch_union_does_not_trigger_unknown_warning() {
555        // The first batch's column union already covers all its keys, so a
556        // later-record-only field within the first batch must NOT warn.
557        let tmp = NamedTempFile::new().unwrap();
558        let path = tmp.path().to_str().unwrap().to_string();
559        let sink = CsvSink::new(CsvSinkConfig::new(&path));
560        sink.write_batch(&[
561            json!({ "id": 1, "name": "Alice" }),
562            json!({ "id": 2, "name": "Bob", "email": "bob@x.y" }),
563        ])
564        .await
565        .unwrap();
566        sink.flush().await.unwrap();
567        assert!(
568            !sink
569                .warned_unknown
570                .load(std::sync::atomic::Ordering::Relaxed),
571            "first-batch union must not trigger the unknown-field warning"
572        );
573        // And the email value IS present (it was part of the frozen union).
574        let content = tokio::fs::read_to_string(&path).await.unwrap();
575        assert!(content.contains("bob@x.y"));
576    }
577
578    #[tokio::test]
579    async fn writes_csv_without_headers() {
580        let tmp = NamedTempFile::new().unwrap();
581        let path = tmp.path().to_str().unwrap().to_string();
582        let sink = CsvSink::new(CsvSinkConfig::new(&path).write_headers(false));
583
584        let records = vec![json!({"a": "1", "b": "2"})];
585        sink.write_batch(&records).await.unwrap();
586        sink.flush().await.unwrap();
587
588        let content = tokio::fs::read_to_string(&path).await.unwrap();
589        let lines: Vec<&str> = content.trim().split('\n').collect();
590        // No header, just 1 data row.
591        assert_eq!(lines.len(), 1);
592    }
593
594    #[tokio::test]
595    async fn empty_batch_returns_zero() {
596        let tmp = NamedTempFile::new().unwrap();
597        let path = tmp.path().to_str().unwrap().to_string();
598        let sink = CsvSink::new(CsvSinkConfig::new(&path));
599        let count = sink.write_batch(&[]).await.unwrap();
600        assert_eq!(count, 0);
601    }
602
603    #[tokio::test]
604    async fn multiple_batches_accumulate() {
605        let tmp = NamedTempFile::new().unwrap();
606        let path = tmp.path().to_str().unwrap().to_string();
607        let sink = CsvSink::new(CsvSinkConfig::new(&path));
608
609        sink.write_batch(&[json!({"x": "1"})]).await.unwrap();
610        sink.write_batch(&[json!({"x": "2"}), json!({"x": "3"})])
611            .await
612            .unwrap();
613        sink.flush().await.unwrap();
614
615        let content = tokio::fs::read_to_string(&path).await.unwrap();
616        let lines: Vec<&str> = content.trim().split('\n').collect();
617        // Header + 3 data rows.
618        assert_eq!(lines.len(), 4);
619    }
620
621    #[tokio::test]
622    async fn missing_fields_written_as_empty() {
623        let tmp = NamedTempFile::new().unwrap();
624        let path = tmp.path().to_str().unwrap().to_string();
625        let sink = CsvSink::new(CsvSinkConfig::new(&path));
626
627        let records = vec![
628            json!({"a": "1", "b": "2"}),
629            json!({"a": "3"}), // missing "b"
630        ];
631        sink.write_batch(&records).await.unwrap();
632        sink.flush().await.unwrap();
633
634        let content = tokio::fs::read_to_string(&path).await.unwrap();
635        let lines: Vec<&str> = content.trim().split('\n').collect();
636        assert_eq!(lines.len(), 3); // header + 2 rows
637    }
638
639    #[tokio::test]
640    async fn value_to_csv_field_handles_types() {
641        assert_eq!(CsvSink::value_to_csv_field(&json!(null)), "");
642        assert_eq!(CsvSink::value_to_csv_field(&json!("hello")), "hello");
643        assert_eq!(CsvSink::value_to_csv_field(&json!(42)), "42");
644        assert_eq!(CsvSink::value_to_csv_field(&json!(true)), "true");
645        assert_eq!(CsvSink::value_to_csv_field(&json!(2.72)), "2.72");
646    }
647
648    #[tokio::test]
649    async fn flush_without_write_is_noop() {
650        let tmp = NamedTempFile::new().unwrap();
651        let path = tmp.path().to_str().unwrap().to_string();
652        let sink = CsvSink::new(CsvSinkConfig::new(&path));
653        assert!(sink.flush().await.is_ok());
654    }
655
656    #[tokio::test]
657    async fn check_passes_when_parent_dir_exists() {
658        let dir = tempfile::tempdir().unwrap();
659        let path = dir.path().join("out.csv");
660        let path_str = path.to_str().unwrap().to_string();
661        let sink = CsvSink::new(CsvSinkConfig::new(&path_str));
662        let report = sink
663            .check(&faucet_core::check::CheckContext::default())
664            .await
665            .unwrap();
666        assert_eq!(report.failed_count(), 0);
667        assert_eq!(report.probes[0].name, "io");
668        // The probe must not have created the user's output file.
669        assert!(!path.exists(), "check() must not create the output file");
670    }
671
672    #[tokio::test]
673    async fn check_fails_when_parent_dir_missing() {
674        let dir = tempfile::tempdir().unwrap();
675        let path = dir.path().join("nope").join("out.csv");
676        let path_str = path.to_str().unwrap().to_string();
677        let sink = CsvSink::new(CsvSinkConfig::new(&path_str));
678        let report = sink
679            .check(&faucet_core::check::CheckContext::default())
680            .await
681            .unwrap();
682        assert_eq!(report.failed_count(), 1);
683        assert_eq!(report.probes[0].name, "io");
684    }
685
686    #[tokio::test]
687    async fn creates_missing_parent_directories() {
688        let dir = tempfile::tempdir().unwrap();
689        let nested = dir.path().join("a").join("b").join("out.csv");
690        let path_str = nested.to_str().unwrap().to_string();
691        let sink = CsvSink::new(CsvSinkConfig::new(&path_str));
692
693        let records = vec![json!({"id": "1", "name": "Alice"})];
694        let count = sink.write_batch(&records).await.unwrap();
695        sink.flush().await.unwrap();
696
697        assert_eq!(count, 1);
698        assert!(nested.exists(), "output file must exist after write");
699        let content = tokio::fs::read_to_string(&nested).await.unwrap();
700        let lines: Vec<&str> = content.trim().split('\n').collect();
701        // Header + 1 data row.
702        assert_eq!(lines.len(), 2);
703    }
704
705    #[cfg(feature = "compression")]
706    #[tokio::test]
707    async fn roundtrip_gzip() {
708        use faucet_core::CompressionConfig;
709        let tmp = NamedTempFile::with_suffix(".csv.gz").unwrap();
710        let path = tmp.path().to_str().unwrap().to_string();
711        let sink = CsvSink::new(CsvSinkConfig::new(&path).compression(CompressionConfig::Auto));
712
713        let records = vec![
714            json!({"id": "1", "name": "Alice"}),
715            json!({"id": "2", "name": "Bob"}),
716        ];
717        sink.write_batch(&records).await.unwrap();
718        sink.flush().await.unwrap();
719
720        let bytes = tokio::fs::read(&path).await.unwrap();
721        use std::io::Read;
722        let mut r =
723            faucet_core::compression::wrap_sync_reader(&bytes[..], faucet_core::Compression::Gzip);
724        let mut text = String::new();
725        r.read_to_string(&mut text).unwrap();
726        let lines: Vec<&str> = text.trim().split('\n').collect();
727        // Header + 2 rows.
728        assert_eq!(lines.len(), 3);
729    }
730
731    #[cfg(feature = "compression")]
732    #[tokio::test]
733    async fn roundtrip_zstd() {
734        use faucet_core::CompressionConfig;
735        let tmp = NamedTempFile::with_suffix(".csv.zst").unwrap();
736        let path = tmp.path().to_str().unwrap().to_string();
737        let sink = CsvSink::new(CsvSinkConfig::new(&path).compression(CompressionConfig::Auto));
738
739        sink.write_batch(&[json!({"x": "42"})]).await.unwrap();
740        sink.flush().await.unwrap();
741
742        let bytes = tokio::fs::read(&path).await.unwrap();
743        use std::io::Read;
744        let mut r =
745            faucet_core::compression::wrap_sync_reader(&bytes[..], faucet_core::Compression::Zstd);
746        let mut text = String::new();
747        r.read_to_string(&mut text).unwrap();
748        let lines: Vec<&str> = text.trim().split('\n').collect();
749        // Header + 1 row.
750        assert_eq!(lines.len(), 2);
751    }
752
753    #[tokio::test]
754    async fn write_flush_write_does_not_truncate() {
755        // Regression: flush() clears the writer; the next write_batch
756        // must reopen in append mode regardless of config.append (which
757        // defaults to false). Without the opened_once guard, the second
758        // open would truncate and lose the first batch's records.
759        let tmp = NamedTempFile::new().unwrap();
760        let path = tmp.path().to_str().unwrap().to_string();
761        let sink = CsvSink::new(CsvSinkConfig::new(&path));
762
763        sink.write_batch(&[json!({"id": "1"})]).await.unwrap();
764        sink.flush().await.unwrap();
765        sink.write_batch(&[json!({"id": "2"})]).await.unwrap();
766        sink.flush().await.unwrap();
767
768        let content = tokio::fs::read_to_string(&path).await.unwrap();
769        let lines: Vec<&str> = content.trim().split('\n').collect();
770        // Header + 2 data rows (header is written only on the first open).
771        assert_eq!(
772            lines.len(),
773            3,
774            "both batches must survive the mid-stream flush"
775        );
776    }
777
778    #[cfg(feature = "compression")]
779    #[tokio::test]
780    async fn write_flush_write_produces_multi_member_gzip_csv() {
781        // With compression, flush() finalises one gzip member; the
782        // next write_batch starts a fresh member appended after it.
783        // The decoder reads both members back correctly.
784        use faucet_core::CompressionConfig;
785        let tmp = NamedTempFile::with_suffix(".csv.gz").unwrap();
786        let path = tmp.path().to_str().unwrap().to_string();
787        let sink = CsvSink::new(CsvSinkConfig::new(&path).compression(CompressionConfig::Auto));
788        sink.write_batch(&[json!({"id": "1"})]).await.unwrap();
789        sink.flush().await.unwrap();
790        sink.write_batch(&[json!({"id": "2"})]).await.unwrap();
791        sink.flush().await.unwrap();
792
793        let bytes = tokio::fs::read(&path).await.unwrap();
794        use std::io::Read;
795        let mut r =
796            faucet_core::compression::wrap_sync_reader(&bytes[..], faucet_core::Compression::Gzip);
797        let mut text = String::new();
798        r.read_to_string(&mut text).unwrap();
799        let lines: Vec<&str> = text.trim().split('\n').collect();
800        // Header (from first open) + 2 data rows. The re-open uses
801        // append=true so no second header is written.
802        assert_eq!(lines.len(), 3);
803    }
804}