Skip to main content

razeri_plugin_api/
sinks.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//! The event sinks razeri ships, implemented against the same public traits a
6//! third-party sink uses — nothing here has access a plugin author lacks.
7//!
8//! | Sink | Type | Writes to |
9//! |------|------|-----------|
10//! | [`stdout::StdoutSink`] | `stdout` | the error output, one line of JSON per event |
11//! | [`jsonl::JsonlSink`] | `jsonl` | a file, one line of JSON appended per event |
12//!
13//! Neither is submitted for auto-discovery, so a registry built
14//! [`with_auto_discovered()`](crate::PluginRegistryBuilder::with_auto_discovered)
15//! holds neither: a program registers the one it wants, or builds it with
16//! [`create_sink`] from an [`EventSinkConfig`]. No sink here sends events to
17//! an HTTP endpoint.
18
19// The JSONL sink writes files via `tokio::fs`, which is unavailable on
20// wasm32-unknown-unknown — it is a native-only sink (a browser has no
21// filesystem). Gated out of the wasm build; `create_sink` rejects the type there.
22#[cfg(not(target_arch = "wasm32"))]
23pub mod jsonl;
24pub mod stdout;
25
26use std::sync::Arc;
27
28use crate::error::EventSinkError;
29use crate::event::EventSink;
30
31/// Sink configuration entry — matches the agent YAML schema.
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct EventSinkConfig {
34    /// Sink type: `"stdout"` or `"jsonl"`.
35    #[serde(rename = "type")]
36    pub sink_type: String,
37    /// File path (required for `jsonl`).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub path: Option<String>,
40}
41
42/// Create a concrete [`EventSink`] from a config entry.
43///
44/// # Errors
45///
46/// When the type is not `stdout` or `jsonl`, when a `jsonl` sink names no
47/// path, or when its file cannot be opened for appending.
48pub async fn create_sink(config: &EventSinkConfig) -> Result<Arc<dyn EventSink>, EventSinkError> {
49    match config.sink_type.as_str() {
50        "stdout" => Ok(Arc::new(stdout::StdoutSink::new())),
51        #[cfg(not(target_arch = "wasm32"))]
52        "jsonl" => {
53            let path = config
54                .path
55                .as_deref()
56                .ok_or_else(|| EventSinkError::SinkError("jsonl sink requires 'path'".into()))?;
57            let sink = jsonl::JsonlSink::open(path).await?;
58            Ok(Arc::new(sink))
59        }
60        #[cfg(target_arch = "wasm32")]
61        "jsonl" => Err(EventSinkError::SinkError(
62            "a `jsonl` sink writes a file, and a program built for the browser has none".into(),
63        )),
64        other => Err(EventSinkError::SinkError(format!(
65            "there is no event sink of type `{other}`: the types are `stdout` and `jsonl`. \
66             For any other destination, register a sink of your own"
67        ))),
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[tokio::test]
76    async fn create_stdout_sink_from_config() {
77        let config = EventSinkConfig { sink_type: "stdout".into(), path: None };
78        let sink = create_sink(&config).await.unwrap();
79        assert_eq!(sink.name(), "stdout");
80    }
81
82    #[tokio::test]
83    async fn create_jsonl_sink_from_config() {
84        let dir = tempfile::tempdir().unwrap();
85        let path = dir.path().join("test.jsonl");
86        let config = EventSinkConfig {
87            sink_type: "jsonl".into(),
88            path: Some(path.to_string_lossy().into()),
89        };
90        let sink = create_sink(&config).await.unwrap();
91        assert_eq!(sink.name(), "jsonl");
92    }
93
94    #[tokio::test]
95    async fn create_jsonl_sink_missing_path_returns_error() {
96        let config = EventSinkConfig { sink_type: "jsonl".into(), path: None };
97        let result = create_sink(&config).await;
98        assert!(result.is_err());
99    }
100
101    /// **No `webhook` sink is built** (`rs-1109-02`, ruled 2026-11-10): the
102    /// one razeri had serialised each event and sent it nowhere. A program
103    /// that asks for one is refused, and the refusal names the sinks there are.
104    #[tokio::test]
105    async fn a_webhook_sink_is_refused_by_name() {
106        let config = EventSinkConfig { sink_type: "webhook".into(), path: None };
107        let Err(refusal) = create_sink(&config).await else {
108            panic!("a webhook sink was built, and it sends nothing");
109        };
110        let words = refusal.to_string();
111        assert!(
112            words.contains("`webhook`") && words.contains("`stdout`") && words.contains("`jsonl`"),
113            "{words}"
114        );
115    }
116
117    #[tokio::test]
118    async fn create_unknown_sink_returns_error() {
119        let config = EventSinkConfig { sink_type: "kafka".into(), path: None };
120        let result = create_sink(&config).await;
121        assert!(result.is_err());
122    }
123}