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