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 opened_once: std::sync::atomic::AtomicBool,
47 warned_unknown: std::sync::atomic::AtomicBool,
53}
54
55impl CsvSink {
56 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 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 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 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 self.opened_once
132 .store(true, std::sync::atomic::Ordering::Relaxed);
133
134 if warned_unknown {
136 self.warned_unknown
137 .store(true, std::sync::atomic::Ordering::Relaxed);
138 }
139
140 {
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 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 let inner = writer
171 .into_inner()
172 .map_err(|e| FaucetError::Sink(format!("CSV flush failed: {e}")))?;
173 #[cfg(feature = "compression")]
174 {
175 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 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 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
215struct WriteOutcome {
219 state: WriterState,
220 count: usize,
221 warned_unknown: bool,
222}
223
224fn 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
244fn 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 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 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 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 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 assert_eq!(lines.len(), 3);
426 }
427
428 #[tokio::test]
429 async fn columns_union_across_first_batch_not_just_first_record() {
430 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 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" }), json!("not-an-object"), ];
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 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 sink.write_batch(&[json!({ "id": 1, "name": "Alice" })])
494 .await
495 .unwrap();
496 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 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 assert!(
516 !lines[0].contains("email"),
517 "header must not gain the late field: {}",
518 lines[0]
519 );
520 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 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 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 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 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"}), ];
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); }
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 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 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 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 assert_eq!(lines.len(), 2);
751 }
752
753 #[tokio::test]
754 async fn write_flush_write_does_not_truncate() {
755 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 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 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 assert_eq!(lines.len(), 3);
803 }
804}