razeri-plugin-api 0.18.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.

//! Built-in event sinks, implemented against the same public traits a
//! third-party sink uses — nothing here has access a plugin author lacks.
//!
//! Each sink implements [`EventSink`] and registers via
//! [`inventory::submit!`] so it is auto-discovered by
//! [`crate::PluginRegistryBuilder::with_auto_discovered()`].
//!
//! Use [`create_sink`] to construct a concrete sink from an
//! [`EventSinkConfig`].

// 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;
pub mod webhook;

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"`, `"jsonl"`, or `"webhook"`.
    #[serde(rename = "type")]
    pub sink_type: String,
    /// File path (required for `jsonl`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    /// HTTP endpoint URL (required for `webhook`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

/// Create a concrete [`EventSink`] from a config entry.
///
/// # Errors
///
/// Returns an error if the operation fails.
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))
        }
        "webhook" => {
            let url = config
                .url
                .as_deref()
                .ok_or_else(|| EventSinkError::SinkError("webhook sink requires 'url'".into()))?;
            Ok(Arc::new(webhook::WebhookSink::new(url)))
        }
        other => Err(EventSinkError::SinkError(format!("unknown sink type: {other}"))),
    }
}

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

    #[tokio::test]
    async fn create_stdout_sink_from_config() {
        let config = EventSinkConfig { sink_type: "stdout".into(), path: None, url: 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()),
            url: None,
        };
        let sink = create_sink(&config).await.unwrap();
        assert_eq!(sink.name(), "jsonl");
    }

    #[tokio::test]
    async fn create_webhook_sink_from_config() {
        let config = EventSinkConfig {
            sink_type: "webhook".into(),
            path: None,
            url: Some("https://example.com/events".into()),
        };
        let sink = create_sink(&config).await.unwrap();
        assert_eq!(sink.name(), "webhook");
    }

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

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

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