razeri-plugin-api 0.23.0

Extension traits and registries for Razeri plugins
Documentation
// Copyright (C) 2026 Gabriel Shea
// Licensed under either of the Apache License, Version 2.0 or the MIT
// license, at your option.

//! The event sinks razeri ships, implemented against the same public traits a
//! third-party sink uses — nothing here has access a plugin author lacks.
//!
//! | Sink | Type | Writes to |
//! |------|------|-----------|
//! | [`stdout::StdoutSink`] | `stdout` | the error output, one line of JSON per event |
//! | [`jsonl::JsonlSink`] | `jsonl` | a file, one line of JSON appended per event |
//!
//! Neither is submitted for auto-discovery, so a registry built
//! [`with_auto_discovered()`](crate::PluginRegistryBuilder::with_auto_discovered)
//! holds neither: a program registers the one it wants, or builds it with
//! [`create_sink`] from an [`EventSinkConfig`]. No sink here sends events to
//! an HTTP endpoint.

// The JSONL sink writes files via `tokio::fs`, which is unavailable on
// wasm32-unknown-unknown — it is a native-only sink (a browser has no
// filesystem). Gated out of the wasm build; `create_sink` rejects the type there.
#[cfg(not(target_arch = "wasm32"))]
pub mod jsonl;
pub mod stdout;

use std::sync::Arc;

use crate::error::EventSinkError;
use crate::event::EventSink;

/// Sink configuration entry — matches the agent YAML schema.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EventSinkConfig {
    /// Sink type: `"stdout"` or `"jsonl"`.
    #[serde(rename = "type")]
    pub sink_type: String,
    /// File path (required for `jsonl`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
}

/// Create a concrete [`EventSink`] from a config entry.
///
/// # Errors
///
/// When the type is not `stdout` or `jsonl`, when a `jsonl` sink names no
/// path, or when its file cannot be opened for appending.
pub async fn create_sink(config: &EventSinkConfig) -> Result<Arc<dyn EventSink>, EventSinkError> {
    match config.sink_type.as_str() {
        "stdout" => Ok(Arc::new(stdout::StdoutSink::new())),
        #[cfg(not(target_arch = "wasm32"))]
        "jsonl" => {
            let path = config
                .path
                .as_deref()
                .ok_or_else(|| EventSinkError::SinkError("jsonl sink requires 'path'".into()))?;
            let sink = jsonl::JsonlSink::open(path).await?;
            Ok(Arc::new(sink))
        }
        #[cfg(target_arch = "wasm32")]
        "jsonl" => Err(EventSinkError::SinkError(
            "a `jsonl` sink writes a file, and a program built for the browser has none".into(),
        )),
        other => Err(EventSinkError::SinkError(format!(
            "there is no event sink of type `{other}`: the types are `stdout` and `jsonl`. \
             For any other destination, register a sink of your own"
        ))),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn create_stdout_sink_from_config() {
        let config = EventSinkConfig { sink_type: "stdout".into(), path: None };
        let sink = create_sink(&config).await.unwrap();
        assert_eq!(sink.name(), "stdout");
    }

    #[tokio::test]
    async fn create_jsonl_sink_from_config() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.jsonl");
        let config = EventSinkConfig {
            sink_type: "jsonl".into(),
            path: Some(path.to_string_lossy().into()),
        };
        let sink = create_sink(&config).await.unwrap();
        assert_eq!(sink.name(), "jsonl");
    }

    #[tokio::test]
    async fn create_jsonl_sink_missing_path_returns_error() {
        let config = EventSinkConfig { sink_type: "jsonl".into(), path: None };
        let result = create_sink(&config).await;
        assert!(result.is_err());
    }

    /// **No `webhook` sink is built** (`rs-1109-02`, ruled 2026-11-10): the
    /// one razeri had serialised each event and sent it nowhere. A program
    /// that asks for one is refused, and the refusal names the sinks there are.
    #[tokio::test]
    async fn a_webhook_sink_is_refused_by_name() {
        let config = EventSinkConfig { sink_type: "webhook".into(), path: None };
        let Err(refusal) = create_sink(&config).await else {
            panic!("a webhook sink was built, and it sends nothing");
        };
        let words = refusal.to_string();
        assert!(
            words.contains("`webhook`") && words.contains("`stdout`") && words.contains("`jsonl`"),
            "{words}"
        );
    }

    #[tokio::test]
    async fn create_unknown_sink_returns_error() {
        let config = EventSinkConfig { sink_type: "kafka".into(), path: None };
        let result = create_sink(&config).await;
        assert!(result.is_err());
    }
}