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 }
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 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 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 #[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 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 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 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 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}