use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use crate::builder::EventSinkRegistration;
use crate::error::EventSinkError;
use crate::event::{EventSink, RazeriEvent};
pub struct JsonlSink {
path: PathBuf,
file: Mutex<tokio::fs::File>,
}
impl JsonlSink {
pub async fn open(path: impl AsRef<Path>) -> Result<Self, EventSinkError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| EventSinkError::SinkError(format!("mkdir failed: {e}")))?;
}
let file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
.map_err(|e| EventSinkError::SinkError(format!("open failed: {e}")))?;
Ok(Self { path, file: Mutex::new(file) })
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[async_trait]
impl EventSink for JsonlSink {
fn name(&self) -> &'static str {
"jsonl"
}
async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError> {
let mut line = serde_json::to_string(event)
.map_err(|e| EventSinkError::SinkError(format!("serialization failed: {e}")))?;
line.push('\n');
let mut file = self.file.lock().await;
file.write_all(line.as_bytes())
.await
.map_err(|e| EventSinkError::SinkError(format!("write failed: {e}")))?;
Ok(())
}
async fn flush(&self) -> Result<(), EventSinkError> {
let mut file = self.file.lock().await;
file.flush().await.map_err(|e| EventSinkError::SinkError(format!("flush failed: {e}")))?;
Ok(())
}
}
inventory::submit! {
EventSinkRegistration::new("jsonl", || {
Arc::new(DefaultJsonlSink)
})
}
struct DefaultJsonlSink;
#[async_trait]
impl EventSink for DefaultJsonlSink {
fn name(&self) -> &'static str {
"jsonl"
}
async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
Ok(())
}
async fn flush(&self) -> Result<(), EventSinkError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{RazeriEvent, WorkflowContext};
use chrono::Utc;
fn test_workflow() -> WorkflowContext {
WorkflowContext::new("test-wf").unwrap()
}
#[tokio::test]
async fn jsonl_sink_writes_events_to_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("events.jsonl");
let sink = JsonlSink::open(&path).await.unwrap();
assert_eq!(sink.name(), "jsonl");
let event = RazeriEvent::design_validated(
"jsonl-test-1",
Utc::now(),
test_workflow(),
"test.yaml",
0,
0,
)
.unwrap();
sink.on_event(&event).await.unwrap();
sink.flush().await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(!content.is_empty());
assert!(content.ends_with('\n'));
let _: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
}
#[tokio::test]
async fn jsonl_sink_appends_multiple_events() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("multi.jsonl");
let sink = JsonlSink::open(&path).await.unwrap();
for i in 0..3 {
let event = RazeriEvent::design_validated(
format!("jsonl-multi-{i}"),
Utc::now(),
test_workflow(),
"test.yaml",
0,
0,
)
.unwrap();
sink.on_event(&event).await.unwrap();
}
sink.flush().await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
let lines: Vec<&str> = content.trim().lines().collect();
assert_eq!(lines.len(), 3);
}
#[tokio::test]
async fn jsonl_sink_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("dir").join("events.jsonl");
let sink = JsonlSink::open(&path).await.unwrap();
assert_eq!(sink.path(), path);
let event = RazeriEvent::design_validated(
"jsonl-dir-1",
Utc::now(),
test_workflow(),
"test.yaml",
0,
0,
)
.unwrap();
sink.on_event(&event).await.unwrap();
sink.flush().await.unwrap();
assert!(path.exists());
}
}