Skip to main content

razeri_plugin_api/sinks/
jsonl.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! JSONL file sink — appends events as newline-delimited JSON.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use tokio::io::AsyncWriteExt;
12use tokio::sync::Mutex;
13
14use crate::builder::EventSinkRegistration;
15use crate::error::EventSinkError;
16use crate::event::{EventSink, RazeriEvent};
17
18/// Appends each event as a single JSON line to a file.
19pub struct JsonlSink {
20    path: PathBuf,
21    file: Mutex<tokio::fs::File>,
22}
23
24impl JsonlSink {
25    /// Open or create the JSONL file at `path`.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if the operation fails.
30    pub async fn open(path: impl AsRef<Path>) -> Result<Self, EventSinkError> {
31        let path = path.as_ref().to_path_buf();
32        if let Some(parent) = path.parent()
33            && !parent.as_os_str().is_empty()
34        {
35            tokio::fs::create_dir_all(parent)
36                .await
37                .map_err(|e| EventSinkError::SinkError(format!("mkdir failed: {e}")))?;
38        }
39        let file = tokio::fs::OpenOptions::new()
40            .create(true)
41            .append(true)
42            .open(&path)
43            .await
44            .map_err(|e| EventSinkError::SinkError(format!("open failed: {e}")))?;
45        Ok(Self { path, file: Mutex::new(file) })
46    }
47
48    /// Return the file path this sink writes to.
49    pub fn path(&self) -> &Path {
50        &self.path
51    }
52}
53
54#[async_trait]
55impl EventSink for JsonlSink {
56    fn name(&self) -> &'static str {
57        "jsonl"
58    }
59
60    async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError> {
61        let mut line = serde_json::to_string(event)
62            .map_err(|e| EventSinkError::SinkError(format!("serialization failed: {e}")))?;
63        line.push('\n');
64        let mut file = self.file.lock().await;
65        file.write_all(line.as_bytes())
66            .await
67            .map_err(|e| EventSinkError::SinkError(format!("write failed: {e}")))?;
68        Ok(())
69    }
70
71    async fn flush(&self) -> Result<(), EventSinkError> {
72        let mut file = self.file.lock().await;
73        file.flush().await.map_err(|e| EventSinkError::SinkError(format!("flush failed: {e}")))?;
74        Ok(())
75    }
76}
77
78// JSONL sink requires a runtime path so auto-discovery uses a default path.
79// Real usage constructs the sink via `JsonlSink::open()`.
80inventory::submit! {
81    EventSinkRegistration::new("jsonl", || {
82        // Default JSONL sink writes to a well-known path.
83        // The runtime must call `JsonlSink::open(path)` for custom paths.
84        Arc::new(DefaultJsonlSink)
85    })
86}
87
88/// Placeholder sink returned by auto-discovery. Real JSONL sinks are
89/// constructed via [`JsonlSink::open`] in the composition root.
90struct DefaultJsonlSink;
91
92#[async_trait]
93impl EventSink for DefaultJsonlSink {
94    fn name(&self) -> &'static str {
95        "jsonl"
96    }
97
98    async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
99        // No-op: real JsonlSink must be created with a file path
100        Ok(())
101    }
102
103    async fn flush(&self) -> Result<(), EventSinkError> {
104        Ok(())
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::event::{RazeriEvent, WorkflowContext};
112    use chrono::Utc;
113
114    fn test_workflow() -> WorkflowContext {
115        WorkflowContext::new("test-wf").unwrap()
116    }
117
118    #[tokio::test]
119    async fn jsonl_sink_writes_events_to_file() {
120        let dir = tempfile::tempdir().unwrap();
121        let path = dir.path().join("events.jsonl");
122
123        let sink = JsonlSink::open(&path).await.unwrap();
124        assert_eq!(sink.name(), "jsonl");
125
126        let event = RazeriEvent::design_validated(
127            "jsonl-test-1",
128            Utc::now(),
129            test_workflow(),
130            "test.yaml",
131            0,
132            0,
133        )
134        .unwrap();
135        sink.on_event(&event).await.unwrap();
136        sink.flush().await.unwrap();
137
138        let content = tokio::fs::read_to_string(&path).await.unwrap();
139        assert!(!content.is_empty());
140        assert!(content.ends_with('\n'));
141        // Verify it's valid JSON
142        let _: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
143    }
144
145    #[tokio::test]
146    async fn jsonl_sink_appends_multiple_events() {
147        let dir = tempfile::tempdir().unwrap();
148        let path = dir.path().join("multi.jsonl");
149
150        let sink = JsonlSink::open(&path).await.unwrap();
151
152        for i in 0..3 {
153            let event = RazeriEvent::design_validated(
154                format!("jsonl-multi-{i}"),
155                Utc::now(),
156                test_workflow(),
157                "test.yaml",
158                0,
159                0,
160            )
161            .unwrap();
162            sink.on_event(&event).await.unwrap();
163        }
164        sink.flush().await.unwrap();
165
166        let content = tokio::fs::read_to_string(&path).await.unwrap();
167        let lines: Vec<&str> = content.trim().lines().collect();
168        assert_eq!(lines.len(), 3);
169    }
170
171    #[tokio::test]
172    async fn jsonl_sink_creates_parent_dirs() {
173        let dir = tempfile::tempdir().unwrap();
174        let path = dir.path().join("nested").join("dir").join("events.jsonl");
175
176        let sink = JsonlSink::open(&path).await.unwrap();
177        assert_eq!(sink.path(), path);
178
179        let event = RazeriEvent::design_validated(
180            "jsonl-dir-1",
181            Utc::now(),
182            test_workflow(),
183            "test.yaml",
184            0,
185            0,
186        )
187        .unwrap();
188        sink.on_event(&event).await.unwrap();
189        sink.flush().await.unwrap();
190
191        assert!(path.exists());
192    }
193}