1use 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#[cfg(feature = "compression")]
15type SinkWriter = faucet_core::compression::SyncCompressWriter<std::fs::File>;
16#[cfg(not(feature = "compression"))]
17type SinkWriter = std::fs::File;
18
19struct WriterState {
21 writer: csv::Writer<SinkWriter>,
22 columns: Vec<String>,
23}
24
25pub struct CsvSink {
38 config: CsvSinkConfig,
39 state: Mutex<Option<WriterState>>,
40 frozen_columns: Mutex<Option<Vec<String>>>,
47 opened_once: std::sync::atomic::AtomicBool,
54 warned_unknown: std::sync::atomic::AtomicBool,
60}
61
62impl CsvSink {
63 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 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 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 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 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 self.opened_once
151 .store(true, std::sync::atomic::Ordering::Relaxed);
152
153 {
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 if warned_unknown {
167 self.warned_unknown
168 .store(true, std::sync::atomic::Ordering::Relaxed);
169 }
170
171 {
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 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 let inner = writer
202 .into_inner()
203 .map_err(|e| FaucetError::Sink(format!("CSV flush failed: {e}")))?;
204 #[cfg(feature = "compression")]
205 {
206 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 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 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
246struct WriteOutcome {
250 state: WriterState,
251 count: usize,
252 warned_unknown: bool,
253}
254
255fn 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
275fn 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 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 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 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 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 assert_eq!(lines.len(), 3);
467 }
468
469 #[tokio::test]
470 async fn columns_union_across_first_batch_not_just_first_record() {
471 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 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" }), json!("not-an-object"), ];
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 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 sink.write_batch(&[json!({ "id": 1, "name": "Alice" })])
535 .await
536 .unwrap();
537 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 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 assert!(
557 !lines[0].contains("email"),
558 "header must not gain the late field: {}",
559 lines[0]
560 );
561 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 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 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 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 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"}), ];
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); }
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 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 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 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 assert_eq!(lines.len(), 2);
792 }
793
794 #[tokio::test]
795 async fn write_flush_write_does_not_truncate() {
796 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 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 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 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 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 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 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 assert_eq!(lines.len(), 3);
900 }
901}