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        }
68    }
69}
70
71fn encode_tsv(record: &Value) -> Result<Vec<u8>, FaucetError> {
72    let obj = record.as_object().ok_or_else(|| {
73        FaucetError::Sink("Tsv format requires each record to be a JSON object".into())
74    })?;
75    let mut keys: Vec<&String> = obj.keys().collect();
76    keys.sort();
77    let mut line = String::new();
78    for (i, key) in keys.iter().enumerate() {
79        if i > 0 {
80            line.push('\t');
81        }
82        let value = &obj[*key];
83        line.push_str(&tsv_cell(value)?);
84    }
85    line.push('\n');
86    Ok(line.into_bytes())
87}
88
89fn tsv_cell(value: &Value) -> Result<String, FaucetError> {
90    Ok(match value {
91        // Render strings without JSON quoting, but neutralise control chars
92        // that would corrupt the TSV layout.
93        Value::String(s) => s.replace(['\t', '\n', '\r'], " "),
94        Value::Null => String::new(),
95        Value::Bool(_) | Value::Number(_) => value.to_string(),
96        Value::Array(_) | Value::Object(_) => serde_json::to_string(value)
97            .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?,
98    })
99}
100
101#[async_trait]
102impl faucet_core::Sink for StdoutSink {
103    fn config_schema(&self) -> Value {
104        serde_json::to_value(faucet_core::schema_for!(StdoutSinkConfig))
105            .expect("schema serialization")
106    }
107
108    fn dataset_uri(&self) -> String {
109        use crate::config::StdStream;
110        match self.config.destination {
111            StdStream::Stdout => "stdout://".to_string(),
112            StdStream::Stderr => "stderr://".to_string(),
113        }
114    }
115
116    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
117        if records.is_empty() {
118            return Ok(0);
119        }
120
121        let mut state = self.state.lock().await;
122        if state.closed {
123            return Ok(0);
124        }
125
126        let remaining = match self.config.max_records {
127            Some(max) => max.saturating_sub(state.written),
128            None => usize::MAX,
129        };
130        if remaining == 0 {
131            return Ok(0);
132        }
133
134        let take = records.len().min(remaining);
135        let mut written_this_call = 0usize;
136        for record in records.iter().take(take) {
137            let bytes = self.encode(record)?;
138            match state.writer.write_all(&bytes).await {
139                Ok(()) => {}
140                Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {
141                    state.closed = true;
142                    tracing::debug!("stdout consumer closed pipe; stopping writes");
143                    return Ok(written_this_call);
144                }
145                Err(e) => return Err(FaucetError::Sink(format!("write failed: {e}"))),
146            }
147            if self.config.flush_per_record {
148                state
149                    .writer
150                    .flush()
151                    .await
152                    .map_err(|e| FaucetError::Sink(format!("flush failed: {e}")))?;
153            }
154            state.written += 1;
155            written_this_call += 1;
156        }
157        Ok(written_this_call)
158    }
159
160    async fn flush(&self) -> Result<(), FaucetError> {
161        let mut state = self.state.lock().await;
162        state
163            .writer
164            .flush()
165            .await
166            .map_err(|e| FaucetError::Sink(format!("flush failed: {e}")))
167    }
168
169    /// Preflight probe for `faucet doctor`. The standard streams are always
170    /// reachable (the OS hands them to every process), so there is nothing to
171    /// fail on — this always passes immediately.
172    async fn check(
173        &self,
174        _ctx: &faucet_core::check::CheckContext,
175    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
176        use faucet_core::check::{CheckReport, Probe};
177        Ok(CheckReport::single(Probe::pass(
178            "io",
179            std::time::Duration::ZERO,
180        )))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use faucet_core::Sink;
188    use serde_json::json;
189    use std::pin::Pin;
190    use std::sync::Arc;
191    use std::sync::Mutex as StdMutex;
192    use std::task::{Context, Poll};
193    use tokio::io::AsyncWrite;
194
195    #[test]
196    fn dataset_uri_stdout() {
197        let sink = StdoutSink::new(StdoutSinkConfig::new());
198        assert_eq!(sink.dataset_uri(), "stdout://");
199    }
200
201    #[test]
202    fn dataset_uri_stderr() {
203        use crate::config::StdStream;
204        let sink = StdoutSink::new(StdoutSinkConfig::new().destination(StdStream::Stderr));
205        assert_eq!(sink.dataset_uri(), "stderr://");
206    }
207
208    /// In-memory async writer that records bytes for assertions and can
209    /// optionally simulate a broken-pipe error after a fixed number of writes.
210    #[derive(Clone, Default)]
211    struct CaptureWriter {
212        inner: Arc<StdMutex<CaptureInner>>,
213    }
214
215    #[derive(Default)]
216    struct CaptureInner {
217        bytes: Vec<u8>,
218        flushes: usize,
219        fail_after: Option<usize>,
220        writes: usize,
221    }
222
223    impl CaptureWriter {
224        fn fail_after(n: usize) -> Self {
225            let me = Self::default();
226            me.inner.lock().unwrap().fail_after = Some(n);
227            me
228        }
229        fn captured(&self) -> Vec<u8> {
230            self.inner.lock().unwrap().bytes.clone()
231        }
232        fn flushes(&self) -> usize {
233            self.inner.lock().unwrap().flushes
234        }
235        fn as_str(&self) -> String {
236            String::from_utf8(self.captured()).unwrap()
237        }
238    }
239
240    impl AsyncWrite for CaptureWriter {
241        fn poll_write(
242            self: Pin<&mut Self>,
243            _cx: &mut Context<'_>,
244            buf: &[u8],
245        ) -> Poll<io::Result<usize>> {
246            let mut inner = self.inner.lock().unwrap();
247            inner.writes += 1;
248            if let Some(fail_after) = inner.fail_after
249                && inner.writes > fail_after
250            {
251                return Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe)));
252            }
253            inner.bytes.extend_from_slice(buf);
254            Poll::Ready(Ok(buf.len()))
255        }
256        fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
257            self.inner.lock().unwrap().flushes += 1;
258            Poll::Ready(Ok(()))
259        }
260        fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
261            Poll::Ready(Ok(()))
262        }
263    }
264
265    fn sink_with(config: StdoutSinkConfig) -> (StdoutSink, CaptureWriter) {
266        let writer = CaptureWriter::default();
267        let sink = StdoutSink::with_writer(config, Box::new(writer.clone()));
268        (sink, writer)
269    }
270
271    #[tokio::test]
272    async fn json_lines_emits_one_record_per_line() {
273        let (sink, capture) = sink_with(StdoutSinkConfig::new());
274        let records = vec![json!({"id": 1}), json!({"id": 2})];
275        let n = sink.write_batch(&records).await.unwrap();
276        assert_eq!(n, 2);
277        let out = capture.as_str();
278        let lines: Vec<&str> = out.lines().collect();
279        assert_eq!(lines.len(), 2);
280        assert_eq!(
281            serde_json::from_str::<Value>(lines[0]).unwrap(),
282            json!({"id": 1})
283        );
284        assert_eq!(
285            serde_json::from_str::<Value>(lines[1]).unwrap(),
286            json!({"id": 2})
287        );
288    }
289
290    #[tokio::test]
291    async fn pretty_json_indents_and_separates_records() {
292        let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::PrettyJson));
293        sink.write_batch(&[json!({"id": 1, "nested": {"k": "v"}})])
294            .await
295            .unwrap();
296        let out = capture.as_str();
297        assert!(out.contains("  \"id\": 1"));
298        assert!(out.contains("  \"nested\": {"));
299        assert!(out.ends_with('\n'));
300    }
301
302    #[tokio::test]
303    async fn tsv_emits_keys_sorted_with_tab_separators() {
304        let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Tsv));
305        sink.write_batch(&[json!({"name": "alice", "id": 7, "tags": ["a","b"], "active": true})])
306            .await
307            .unwrap();
308        let out = capture.as_str();
309        let line = out.lines().next().unwrap();
310        let cells: Vec<&str> = line.split('\t').collect();
311        // sorted: active, id, name, tags
312        assert_eq!(cells, vec!["true", "7", "alice", r#"["a","b"]"#]);
313    }
314
315    #[tokio::test]
316    async fn tsv_replaces_tabs_and_newlines_in_string_values() {
317        let (sink, capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Tsv));
318        sink.write_batch(&[json!({"a": "tab\there\nand-newline"})])
319            .await
320            .unwrap();
321        let out = capture.as_str();
322        let line = out.lines().next().unwrap();
323        assert_eq!(line, "tab here and-newline");
324    }
325
326    #[tokio::test]
327    async fn tsv_rejects_non_object_records() {
328        let (sink, _capture) = sink_with(StdoutSinkConfig::new().format(StdoutFormat::Tsv));
329        let result = sink.write_batch(&[json!([1, 2, 3])]).await;
330        assert!(matches!(result, Err(FaucetError::Sink(_))));
331    }
332
333    #[tokio::test]
334    async fn empty_batch_returns_zero() {
335        let (sink, _capture) = sink_with(StdoutSinkConfig::new());
336        let n = sink.write_batch(&[]).await.unwrap();
337        assert_eq!(n, 0);
338    }
339
340    #[tokio::test]
341    async fn max_records_caps_output() {
342        let (sink, capture) = sink_with(StdoutSinkConfig::new().max_records(2));
343        let n = sink
344            .write_batch(&[json!({"id": 1}), json!({"id": 2}), json!({"id": 3})])
345            .await
346            .unwrap();
347        assert_eq!(n, 2);
348        assert_eq!(capture.as_str().lines().count(), 2);
349        // Subsequent calls become no-ops.
350        let n2 = sink.write_batch(&[json!({"id": 4})]).await.unwrap();
351        assert_eq!(n2, 0);
352        assert_eq!(capture.as_str().lines().count(), 2);
353    }
354
355    #[tokio::test]
356    async fn flush_per_record_flushes_after_each() {
357        let (sink, capture) = sink_with(StdoutSinkConfig::new().flush_per_record(true));
358        sink.write_batch(&[json!({"id": 1}), json!({"id": 2})])
359            .await
360            .unwrap();
361        assert_eq!(capture.flushes(), 2);
362    }
363
364    #[tokio::test]
365    async fn batch_boundary_flush_only_on_explicit_flush() {
366        let (sink, capture) = sink_with(StdoutSinkConfig::new());
367        sink.write_batch(&[json!({"id": 1})]).await.unwrap();
368        assert_eq!(capture.flushes(), 0);
369        sink.flush().await.unwrap();
370        assert_eq!(capture.flushes(), 1);
371    }
372
373    #[tokio::test]
374    async fn broken_pipe_is_treated_as_clean_termination() {
375        // Writer accepts 1 write then errors with BrokenPipe.
376        let capture = CaptureWriter::fail_after(1);
377        let sink = StdoutSink::with_writer(StdoutSinkConfig::new(), Box::new(capture.clone()));
378        let n = sink
379            .write_batch(&[json!({"id": 1}), json!({"id": 2}), json!({"id": 3})])
380            .await
381            .unwrap();
382        assert_eq!(n, 1);
383        // Further writes are no-ops because the sink is now marked closed.
384        let n2 = sink.write_batch(&[json!({"id": 4})]).await.unwrap();
385        assert_eq!(n2, 0);
386    }
387
388    #[tokio::test]
389    async fn as_trait_object() {
390        let capture = CaptureWriter::default();
391        let sink: Box<dyn Sink> = Box::new(StdoutSink::with_writer(
392            StdoutSinkConfig::new(),
393            Box::new(capture.clone()),
394        ));
395        let n = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
396        assert_eq!(n, 1);
397        assert!(capture.as_str().contains("\"id\":1"));
398    }
399
400    #[tokio::test]
401    async fn config_schema_is_well_formed_object() {
402        let sink = StdoutSink::new(StdoutSinkConfig::new());
403        let schema = sink.config_schema();
404        assert_eq!(schema["type"], "object");
405        assert!(schema["properties"].is_object());
406    }
407
408    #[tokio::test]
409    async fn check_always_passes() {
410        let sink = StdoutSink::new(StdoutSinkConfig::new());
411        let report = sink
412            .check(&faucet_core::check::CheckContext::default())
413            .await
414            .unwrap();
415        assert_eq!(report.failed_count(), 0);
416        assert_eq!(report.probes.len(), 1);
417        assert_eq!(report.probes[0].name, "io");
418    }
419}