razeri_plugin_api/sinks/
jsonl.rs1use 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
18pub struct JsonlSink {
20 path: PathBuf,
21 file: Mutex<tokio::fs::File>,
22}
23
24impl JsonlSink {
25 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 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
78inventory::submit! {
81 EventSinkRegistration::new("jsonl", || {
82 Arc::new(DefaultJsonlSink)
85 })
86}
87
88struct 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 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 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}