1use crate::config::{StdStream, StdoutFormat, StdoutSinkConfig};
4use async_trait::async_trait;
5use faucet_core::FaucetError;
6use serde_json::Value;
7use std::io;
8use tokio::io::{AsyncWrite, AsyncWriteExt};
9use tokio::sync::Mutex;
10
11struct State {
14 writer: Box<dyn AsyncWrite + Unpin + Send>,
15 written: usize,
16 closed: bool,
17}
18
19pub struct StdoutSink {
21 config: StdoutSinkConfig,
22 state: Mutex<State>,
23}
24
25impl StdoutSink {
26 pub fn new(config: StdoutSinkConfig) -> Self {
28 let writer: Box<dyn AsyncWrite + Unpin + Send> = match config.destination {
29 StdStream::Stdout => Box::new(tokio::io::stdout()),
30 StdStream::Stderr => Box::new(tokio::io::stderr()),
31 };
32 Self::with_writer(config, writer)
33 }
34
35 pub fn with_writer(
39 config: StdoutSinkConfig,
40 writer: Box<dyn AsyncWrite + Unpin + Send>,
41 ) -> Self {
42 Self {
43 config,
44 state: Mutex::new(State {
45 writer,
46 written: 0,
47 closed: false,
48 }),
49 }
50 }
51
52 fn encode(&self, record: &Value) -> Result<Vec<u8>, FaucetError> {
53 match self.config.format {
54 StdoutFormat::JsonLines => {
55 let mut bytes = serde_json::to_vec(record)
56 .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
57 bytes.push(b'\n');
58 Ok(bytes)
59 }
60 StdoutFormat::PrettyJson => {
61 let mut bytes = serde_json::to_vec_pretty(record)
62 .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
63 bytes.push(b'\n');
64 Ok(bytes)
65 }
66 StdoutFormat::Tsv => encode_tsv(record),
67 StdoutFormat::Csv => encode_csv(record),
68 }
69 }
70}
71
72fn encode_tsv(record: &Value) -> Result<Vec<u8>, FaucetError> {
73 let obj = record.as_object().ok_or_else(|| {
74 FaucetError::Sink("Tsv format requires each record to be a JSON object".into())
75 })?;
76 let mut keys: Vec<&String> = obj.keys().collect();
77 keys.sort();
78 let mut line = String::new();
79 for (i, key) in keys.iter().enumerate() {
80 if i > 0 {
81 line.push('\t');
82 }
83 let value = &obj[*key];
84 line.push_str(&tsv_cell(value)?);
85 }
86 line.push('\n');
87 Ok(line.into_bytes())
88}
89
90fn tsv_cell(value: &Value) -> Result<String, FaucetError> {
91 Ok(match value {
92 Value::String(s) => s.replace(['\t', '\n', '\r'], " "),
95 Value::Null => String::new(),
96 Value::Bool(_) | Value::Number(_) => value.to_string(),
97 Value::Array(_) | Value::Object(_) => serde_json::to_string(value)
98 .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?,
99 })
100}
101
102fn encode_csv(record: &Value) -> Result<Vec<u8>, FaucetError> {
107 let obj = record.as_object().ok_or_else(|| {
108 FaucetError::Sink("Csv format requires each record to be a JSON object".into())
109 })?;
110 let mut keys: Vec<&String> = obj.keys().collect();
111 keys.sort();
112 let mut wtr = csv::WriterBuilder::new()
113 .terminator(csv::Terminator::Any(b'\n'))
114 .from_writer(Vec::new());
115 let cells: Vec<String> = keys
116 .iter()
117 .map(|key| csv_cell(&obj[*key]))
118 .collect::<Result<_, _>>()?;
119 wtr.write_record(&cells)
120 .map_err(|e| FaucetError::Sink(format!("CSV serialization failed: {e}")))?;
121 wtr.into_inner()
122 .map_err(|e| FaucetError::Sink(format!("CSV flush failed: {e}")))
123}
124
125fn csv_cell(value: &Value) -> Result<String, FaucetError> {
128 Ok(match value {
129 Value::String(s) => s.clone(),
130 Value::Null => String::new(),
131 Value::Bool(_) | Value::Number(_) => value.to_string(),
132 Value::Array(_) | Value::Object(_) => serde_json::to_string(value)
133 .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?,
134 })
135}
136
137#[async_trait]
138impl faucet_core::Sink for StdoutSink {
139 fn config_schema(&self) -> Value {
140 serde_json::to_value(faucet_core::schema_for!(StdoutSinkConfig))
141 .expect("schema serialization")
142 }
143
144 fn dataset_uri(&self) -> String {
145 use crate::config::StdStream;
146 match self.config.destination {
147 StdStream::Stdout => "stdout://".to_string(),
148 StdStream::Stderr => "stderr://".to_string(),
149 }
150 }
151
152 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
153 if records.is_empty() {
154 return Ok(0);
155 }
156
157 let mut state = self.state.lock().await;
158 if state.closed {
159 return Ok(0);
160 }
161
162 let remaining = match self.config.max_records {
163 Some(max) => max.saturating_sub(state.written),
164 None => usize::MAX,
165 };
166 if remaining == 0 {
167 return Ok(0);
168 }
169
170 let take = records.len().min(remaining);
171 let mut written_this_call = 0usize;
172 for record in records.iter().take(take) {
173 let bytes = self.encode(record)?;
174 match state.writer.write_all(&bytes).await {
175 Ok(()) => {}
176 Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {
177 state.closed = true;
178 tracing::debug!("stdout consumer closed pipe; stopping writes");
179 return Ok(written_this_call);
180 }
181 Err(e) => return Err(FaucetError::Sink(format!("write failed: {e}"))),
182 }
183 if self.config.flush_per_record {
184 state
185 .writer
186 .flush()
187 .await
188 .map_err(|e| FaucetError::Sink(format!("flush failed: {e}")))?;
189 }
190 state.written += 1;
191 written_this_call += 1;
192 }
193 Ok(written_this_call)
194 }
195
196 async fn flush(&self) -> Result<(), FaucetError> {
197 let mut state = self.state.lock().await;
198 state
199 .writer
200 .flush()
201 .await
202 .map_err(|e| FaucetError::Sink(format!("flush failed: {e}")))
203 }
204
205 async fn check(
209 &self,
210 _ctx: &faucet_core::check::CheckContext,
211 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
212 use faucet_core::check::{CheckReport, Probe};
213 Ok(CheckReport::single(Probe::pass(
214 "io",
215 std::time::Duration::ZERO,
216 )))
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use faucet_core::Sink;
224 use serde_json::json;
225 use std::pin::Pin;
226 use std::sync::Arc;
227 use std::sync::Mutex as StdMutex;
228 use std::task::{Context, Poll};
229 use tokio::io::AsyncWrite;
230
231 #[test]
232 fn dataset_uri_stdout() {
233 let sink = StdoutSink::new(StdoutSinkConfig::new());
234 assert_eq!(sink.dataset_uri(), "stdout://");
235 }
236
237 #[test]
238 fn dataset_uri_stderr() {
239 use crate::config::StdStream;
240 let sink = StdoutSink::new(StdoutSinkConfig::new().destination(StdStream::Stderr));
241 assert_eq!(sink.dataset_uri(), "stderr://");
242 }
243
244 #[derive(Clone, Default)]
247 struct CaptureWriter {
248 inner: Arc<StdMutex<CaptureInner>>,
249 }
250
251 #[derive(Default)]
252 struct CaptureInner {
253 bytes: Vec<u8>,
254 flushes: usize,
255 fail_after: Option<usize>,
256 writes: usize,
257 }
258
259 impl CaptureWriter {
260 fn fail_after(n: usize) -> Self {
261 let me = Self::default();
262 me.inner.lock().unwrap().fail_after = Some(n);
263 me
264 }
265 fn captured(&self) -> Vec<u8> {
266 self.inner.lock().unwrap().bytes.clone()
267 }
268 fn flushes(&self) -> usize {
269 self.inner.lock().unwrap().flushes
270 }
271 fn as_str(&self) -> String {
272 String::from_utf8(self.captured()).unwrap()
273 }
274 }
275
276 impl AsyncWrite for CaptureWriter {
277 fn poll_write(
278 self: Pin<&mut Self>,
279 _cx: &mut Context<'_>,
280 buf: &[u8],
281 ) -> Poll<io::Result<usize>> {
282 let mut inner = self.inner.lock().unwrap();
283 inner.writes += 1;
284 if let Some(fail_after) = inner.fail_after
285 && inner.writes > fail_after
286 {
287 return Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe)));
288 }
289 inner.bytes.extend_from_slice(buf);
290 Poll::Ready(Ok(buf.len()))
291 }
292 fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
293 self.inner.lock().unwrap().flushes += 1;
294 Poll::Ready(Ok(()))
295 }
296 fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
297 Poll::Ready(Ok(()))
298 }
299 }
300
301 fn sink_with(config: StdoutSinkConfig) -> (StdoutSink, CaptureWriter) {
302 let writer = CaptureWriter::default();
303 let sink = StdoutSink::with_writer(config, Box::new(writer.clone()));
304 (sink, writer)
305 }
306
307 #[tokio::test]
308 async fn json_lines_emits_one_record_per_line() {
309 let (sink, capture) = sink_with(StdoutSinkConfig::new());
310 let records = vec![json!({"id": 1}), json!({"id": 2})];
311 let n = sink.write_batch(&records).await.unwrap();
312 assert_eq!(n, 2);
313 let out = capture.as_str();
314 let lines: Vec<&str> = out.lines().collect();
315 assert_eq!(lines.len(), 2);
316 assert_eq!(
317 serde_json::from_str::<Value>(lines[0]).unwrap(),
318 json!({"id": 1})
319 );
320 assert_eq!(
321 serde_json::from_str::<Value>(lines[1]).unwrap(),
322 json!({"id": 2})
323 );
324 }
325
326 #[tokio::test]
327 async fn pretty_json_indents_and_separates_records() {
328 let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::PrettyJson));
329 sink.write_batch(&[json!({"id": 1, "nested": {"k": "v"}})])
330 .await
331 .unwrap();
332 let out = capture.as_str();
333 assert!(out.contains(" \"id\": 1"));
334 assert!(out.contains(" \"nested\": {"));
335 assert!(out.ends_with('\n'));
336 }
337
338 #[tokio::test]
339 async fn tsv_emits_keys_sorted_with_tab_separators() {
340 let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Tsv));
341 sink.write_batch(&[json!({"name": "alice", "id": 7, "tags": ["a","b"], "active": true})])
342 .await
343 .unwrap();
344 let out = capture.as_str();
345 let line = out.lines().next().unwrap();
346 let cells: Vec<&str> = line.split('\t').collect();
347 assert_eq!(cells, vec!["true", "7", "alice", r#"["a","b"]"#]);
349 }
350
351 #[tokio::test]
352 async fn tsv_replaces_tabs_and_newlines_in_string_values() {
353 let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Tsv));
354 sink.write_batch(&[json!({"a": "tab\there\nand-newline"})])
355 .await
356 .unwrap();
357 let out = capture.as_str();
358 let line = out.lines().next().unwrap();
359 assert_eq!(line, "tab here and-newline");
360 }
361
362 #[tokio::test]
363 async fn tsv_rejects_non_object_records() {
364 let (sink, _capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Tsv));
365 let result = sink.write_batch(&[json!([1, 2, 3])]).await;
366 assert!(matches!(result, Err(FaucetError::Sink(_))));
367 }
368
369 #[tokio::test]
370 async fn csv_emits_keys_sorted_with_comma_separators() {
371 let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Csv));
372 sink.write_batch(&[json!({"name": "alice", "id": 7, "tags": ["a","b"], "active": true})])
373 .await
374 .unwrap();
375 let out = capture.as_str();
376 let line = out.lines().next().unwrap();
377 assert_eq!(line, r#"true,7,alice,"[""a"",""b""]""#);
380 }
381
382 #[tokio::test]
383 async fn csv_quotes_commas_quotes_and_newlines() {
384 let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Csv));
385 sink.write_batch(&[json!({"a": "x,y", "b": "he said \"hi\"", "c": "line1\nline2"})])
386 .await
387 .unwrap();
388 let out = capture.as_str();
389 let mut rdr = csv::ReaderBuilder::new()
391 .has_headers(false)
392 .from_reader(out.as_bytes());
393 let rec = rdr.records().next().unwrap().unwrap();
394 assert_eq!(&rec[0], "x,y");
395 assert_eq!(&rec[1], "he said \"hi\"");
396 assert_eq!(&rec[2], "line1\nline2");
397 }
398
399 #[tokio::test]
400 async fn csv_line_terminator_is_lf_not_crlf() {
401 let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Csv));
402 sink.write_batch(&[json!({"a": 1}), json!({"a": 2})])
403 .await
404 .unwrap();
405 let out = capture.as_str();
406 assert_eq!(out, "1\n2\n");
407 assert!(!out.contains('\r'));
408 }
409
410 #[tokio::test]
411 async fn csv_rejects_non_object_records() {
412 let (sink, _capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Csv));
413 let result = sink.write_batch(&[json!([1, 2, 3])]).await;
414 assert!(matches!(result, Err(FaucetError::Sink(_))));
415 }
416
417 #[tokio::test]
418 async fn csv_renders_null_as_empty_and_object_as_json() {
419 let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Csv));
420 sink.write_batch(&[json!({"val": null, "meta": {"k": 1}})])
423 .await
424 .unwrap();
425 let out = capture.as_str();
426 let mut rdr = csv::ReaderBuilder::new()
427 .has_headers(false)
428 .from_reader(out.as_bytes());
429 let rec = rdr.records().next().unwrap().unwrap();
430 assert_eq!(&rec[0], r#"{"k":1}"#); assert_eq!(&rec[1], ""); }
433
434 #[tokio::test]
435 async fn empty_batch_returns_zero() {
436 let (sink, _capture) = sink_with(StdoutSinkConfig::new());
437 let n = sink.write_batch(&[]).await.unwrap();
438 assert_eq!(n, 0);
439 }
440
441 #[tokio::test]
442 async fn max_records_caps_output() {
443 let (sink, capture) = sink_with(StdoutSinkConfig::new().max_records(2));
444 let n = sink
445 .write_batch(&[json!({"id": 1}), json!({"id": 2}), json!({"id": 3})])
446 .await
447 .unwrap();
448 assert_eq!(n, 2);
449 assert_eq!(capture.as_str().lines().count(), 2);
450 let n2 = sink.write_batch(&[json!({"id": 4})]).await.unwrap();
452 assert_eq!(n2, 0);
453 assert_eq!(capture.as_str().lines().count(), 2);
454 }
455
456 #[tokio::test]
457 async fn flush_per_record_flushes_after_each() {
458 let (sink, capture) = sink_with(StdoutSinkConfig::new().flush_per_record(true));
459 sink.write_batch(&[json!({"id": 1}), json!({"id": 2})])
460 .await
461 .unwrap();
462 assert_eq!(capture.flushes(), 2);
463 }
464
465 #[tokio::test]
466 async fn batch_boundary_flush_only_on_explicit_flush() {
467 let (sink, capture) = sink_with(StdoutSinkConfig::new());
468 sink.write_batch(&[json!({"id": 1})]).await.unwrap();
469 assert_eq!(capture.flushes(), 0);
470 sink.flush().await.unwrap();
471 assert_eq!(capture.flushes(), 1);
472 }
473
474 #[tokio::test]
475 async fn broken_pipe_is_treated_as_clean_termination() {
476 let capture = CaptureWriter::fail_after(1);
478 let sink = StdoutSink::with_writer(StdoutSinkConfig::new(), Box::new(capture.clone()));
479 let n = sink
480 .write_batch(&[json!({"id": 1}), json!({"id": 2}), json!({"id": 3})])
481 .await
482 .unwrap();
483 assert_eq!(n, 1);
484 let n2 = sink.write_batch(&[json!({"id": 4})]).await.unwrap();
486 assert_eq!(n2, 0);
487 }
488
489 #[tokio::test]
490 async fn as_trait_object() {
491 let capture = CaptureWriter::default();
492 let sink: Box<dyn Sink> = Box::new(StdoutSink::with_writer(
493 StdoutSinkConfig::new(),
494 Box::new(capture.clone()),
495 ));
496 let n = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
497 assert_eq!(n, 1);
498 assert!(capture.as_str().contains("\"id\":1"));
499 }
500
501 #[tokio::test]
502 async fn config_schema_is_well_formed_object() {
503 let sink = StdoutSink::new(StdoutSinkConfig::new());
504 let schema = sink.config_schema();
505 assert_eq!(schema["type"], "object");
506 assert!(schema["properties"].is_object());
507 }
508
509 #[tokio::test]
510 async fn check_always_passes() {
511 let sink = StdoutSink::new(StdoutSinkConfig::new());
512 let report = sink
513 .check(&faucet_core::check::CheckContext::default())
514 .await
515 .unwrap();
516 assert_eq!(report.failed_count(), 0);
517 assert_eq!(report.probes.len(), 1);
518 assert_eq!(report.probes[0].name, "io");
519 }
520}