Skip to main content

faucet_sink_stdout/
sink.rs

1//! Stdout/stderr sink implementation.
2
3use 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
11/// State guarded by a single mutex so the running record count, the writer,
12/// and the "consumer closed the pipe" flag can't race against each other.
13struct State {
14    writer: Box<dyn AsyncWrite + Unpin + Send>,
15    written: usize,
16    closed: bool,
17}
18
19/// A sink that writes records to standard output or standard error.
20pub struct StdoutSink {
21    config: StdoutSinkConfig,
22    state: Mutex<State>,
23}
24
25impl StdoutSink {
26    /// Create a new stdout/stderr sink. Opens the underlying stream eagerly.
27    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    /// Construct with a caller-provided async writer. Used by tests to capture
36    /// output, and by integrators who want to redirect into something other
37    /// than the real stdio handles (e.g. a log file, an in-memory buffer).
38    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        // Render strings without JSON quoting, but neutralise control chars
93        // that would corrupt the TSV layout.
94        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
102/// Encode one record as an RFC-4180 CSV line. Same column resolution as
103/// [`encode_tsv`] (keys sorted, no header row), but the `csv` crate handles
104/// quoting/escaping so values with commas, quotes, or newlines round-trip.
105/// The line terminator is `\n` to match the sink's other formats.
106fn 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
125/// A single CSV cell. Unlike [`tsv_cell`] we do NOT neutralise control chars —
126/// the `csv` writer quotes them per RFC-4180 instead.
127fn 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    /// Preflight probe for `faucet doctor`. The standard streams are always
206    /// reachable (the OS hands them to every process), so there is nothing to
207    /// fail on — this always passes immediately.
208    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    /// In-memory async writer that records bytes for assertions and can
245    /// optionally simulate a broken-pipe error after a fixed number of writes.
246    #[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        // sorted: active, id, name, tags
348        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        // sorted keys: active, id, name, tags — nested array as compact JSON,
378        // which the csv writer quotes because it contains a comma.
379        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        // Round-trip through a csv reader to prove RFC-4180 validity.
390        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        // keys sorted: meta (object), val (null) — exercises the Null and
421        // Object/Array cell arms.
422        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}"#); // meta (object → compact JSON)
431        assert_eq!(&rec[1], ""); // val (null → empty)
432    }
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        // Subsequent calls become no-ops.
451        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        // Writer accepts 1 write then errors with BrokenPipe.
477        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        // Further writes are no-ops because the sink is now marked closed.
485        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}