grpctestify 1.5.1

gRPC testing utility written in Rust
Documentation
//! Assertion plugins — built-in functions for gRPC test assertions.
//!
//! Plugins extend the assertion engine with custom validation logic.
//! Each plugin implements the [`Plugin`] trait and is registered with [`PluginManager`].
//!
//! # Type System
//!
//! Plugin signatures include full type information (`TypeInfo`, `ArgTypeInfo`)
//! that is consumed by:
//! - **Optimizer**: type-aware rewrites (e.g., `@len(.x) >= 0 → true`)
//! - **LSP**: hover information, completion, signature help
//! - **Semantics**: type-checking assertion expressions
//! - **Explain/Inspect**: human-readable type information
//!
//! # Available Plugins
//!
//! | Plugin | Purpose | Returns |
//! |--------|---------|---------|
//! | `@uuid` | Validate UUID format | bool |
//! | `@email` | Validate email format | bool |
//! | `@ip` | Validate IP address | bool |
//! | `@url` | Validate URL format | bool |
//! | `@timestamp` | Validate Unix timestamp | bool |
//! | `@regex` | Regex matching | bool |
//! | `@len` / `@empty` | Length/emptiness checks | non-negative integer / bool |
//! | `@header` / `@has_header` | HTTP header extraction/checks | string|null / bool |
//! | `@trailer` / `@has_trailer` | gRPC trailer extraction/checks | string|null / bool |
//! | `@env` | Environment variable (with optional default) | string|null |
//! | `@elapsed_ms` / `@total_elapsed_ms` | Timing assertions | non-negative integer |
//! | `@scope_message_count` / `@scope_index` | Streaming scope info | non-negative integer |

pub mod email;
pub mod empty;
pub mod env;
pub mod header_extract;
pub mod ip;
pub mod len;
pub mod macros;
pub mod regex;
pub mod timestamp;
pub mod timing;
pub mod trailer_extract;
pub mod type_info;
pub mod url;
pub mod uuid;

pub use type_info::{ArgTypeInfo, TypeInfo, TypedPluginSignature};

use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, RwLock};

use crate::assert::engine::AssertionResult;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginPurity {
    Pure,
    ContextDependent,
    Impure,
}

#[derive(Debug, Clone, Copy)]
pub struct PluginSignature {
    /// Extended return type — the single source of truth for return type information.
    /// Used by optimizer, LSP hover, and semantics.
    pub return_type: TypeInfo,
    /// Type information for each argument (for LSP signature help).
    pub arg_types: &'static [ArgTypeInfo],
    pub purity: PluginPurity,
    pub deterministic: bool,
    pub idempotent: bool,
    pub safe_for_rewrite: bool,
    /// Human-readable argument names for signature display.
    pub arg_names: &'static [&'static str],
}

impl Default for PluginSignature {
    fn default() -> Self {
        Self {
            return_type: TypeInfo::Any,
            arg_types: &[],
            purity: PluginPurity::Impure,
            deterministic: false,
            idempotent: false,
            safe_for_rewrite: false,
            arg_names: &[],
        }
    }
}

/// Context passed to plugins during execution
pub struct PluginContext<'a> {
    pub response: &'a Value,
    pub headers: Option<&'a HashMap<String, String>>,
    pub trailers: Option<&'a HashMap<String, String>>,
    pub timing: Option<&'a AssertionTiming>,
}

impl<'a> PluginContext<'a> {
    pub fn new(response: &'a Value) -> Self {
        Self {
            response,
            headers: None,
            trailers: None,
            timing: None,
        }
    }

    pub fn with_headers(mut self, headers: Option<&'a HashMap<String, String>>) -> Self {
        self.headers = headers;
        self
    }

    pub fn with_trailers(mut self, trailers: Option<&'a HashMap<String, String>>) -> Self {
        self.trailers = trailers;
        self
    }

    pub fn with_timing(mut self, timing: Option<&'a AssertionTiming>) -> Self {
        self.timing = timing;
        self
    }
}

/// Timing context available for assertion plugins.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssertionTiming {
    /// Duration for current assertion scope (message or batch), in milliseconds.
    pub elapsed_ms: u64,
    /// Cumulative duration across all completed assertion scopes, in milliseconds.
    pub total_elapsed_ms: u64,
    /// Number of messages in current scope.
    pub scope_message_count: usize,
    /// Monotonic index of current scope (1-based).
    pub scope_index: usize,
}

/// Result of a plugin execution
#[derive(Debug, Clone)]
pub enum PluginResult {
    /// The plugin performed an assertion (pass/fail)
    Assertion(AssertionResult),
    /// The plugin computed a value to be used in further expressions
    Value(Value),
}

/// Trait for all plugins
pub trait Plugin: Send + Sync {
    /// unique name of the plugin (e.g., "uuid", "len")
    fn name(&self) -> &str;
    /// Description of what the plugin does
    fn description(&self) -> &str;
    /// Execute the plugin logic
    fn execute(&self, args: &[Value], context: &PluginContext) -> Result<PluginResult>;

    /// Static plugin signature used by optimizer/LSP.
    fn signature(&self) -> PluginSignature {
        PluginSignature::default()
    }
}

pub fn normalize_plugin_name(name: &str) -> &str {
    let trimmed = name.trim();
    trimmed.strip_prefix('@').unwrap_or(trimmed)
}

pub fn extract_plugin_call_name(expr: &str) -> Option<String> {
    let e = expr.trim();
    if !e.starts_with('@') || !e.ends_with(')') {
        return None;
    }

    let open = e.find('(')?;
    if open <= 1 {
        return None;
    }

    Some(e[1..open].trim().to_string())
}

pub fn plugin_signature_map() -> HashMap<String, PluginSignature> {
    PluginManager::new()
        .list()
        .into_iter()
        .map(|plugin| (plugin.name().to_string(), plugin.signature()))
        .collect()
}

/// Cached plugin signatures — single source of truth for all modules.
pub static PLUGIN_SIGNATURES: LazyLock<HashMap<String, PluginSignature>> =
    LazyLock::new(plugin_signature_map);

/// Manager to register and retrieve plugins
pub struct PluginManager {
    plugins: RwLock<HashMap<String, Arc<dyn Plugin>>>,
}

impl PluginManager {
    pub fn new() -> Self {
        let mut manager = Self {
            plugins: RwLock::new(HashMap::new()),
        };
        manager.register_defaults();
        manager
    }

    fn register_defaults(&mut self) {
        self.register(Arc::new(uuid::UuidPlugin));
        self.register(Arc::new(email::EmailPlugin));
        self.register(Arc::new(empty::EmptyPlugin));
        self.register(Arc::new(ip::IpPlugin));
        self.register(Arc::new(url::UrlPlugin));
        self.register(Arc::new(timestamp::TimestampPlugin));
        self.register(Arc::new(header_extract::HeaderExtractPlugin));
        self.register(Arc::new(header_extract::HasHeaderPlugin));
        self.register(Arc::new(trailer_extract::TrailerExtractPlugin));
        self.register(Arc::new(trailer_extract::HasTrailerPlugin));
        self.register(Arc::new(len::LenPlugin));
        self.register(Arc::new(env::EnvPlugin));
        self.register(Arc::new(regex::RegexPlugin));
        self.register(Arc::new(timing::ElapsedMsPlugin));
        self.register(Arc::new(timing::TotalElapsedMsPlugin));
        self.register(Arc::new(timing::ScopeMessageCountPlugin));
        self.register(Arc::new(timing::ScopeIndexPlugin));
    }

    pub fn register(&mut self, plugin: Arc<dyn Plugin>) {
        self.plugins
            .write()
            .expect("PluginManager write lock poisoned")
            .insert(plugin.name().to_string(), plugin);
    }

    pub fn register_with_name(&mut self, name: &str, plugin: Arc<dyn Plugin>) {
        self.plugins
            .write()
            .expect("PluginManager write lock poisoned")
            .insert(name.to_string(), plugin);
    }

    pub fn get(&self, name: &str) -> Option<Arc<dyn Plugin>> {
        let normalized = normalize_plugin_name(name);
        self.plugins
            .read()
            .expect("PluginManager read lock poisoned")
            .get(normalized)
            .cloned()
    }

    pub fn list(&self) -> Vec<Arc<dyn Plugin>> {
        self.plugins
            .read()
            .expect("PluginManager read lock poisoned")
            .values()
            .cloned()
            .collect()
    }
}

impl Default for PluginManager {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn test_plugin_manager_new() {
        let manager = PluginManager::new();
        // PluginManager registers defaults on creation
        let plugins = manager.plugins.read().unwrap();
        // Should have default plugins registered
        assert!(!plugins.is_empty());
    }

    #[test]
    fn test_plugin_manager_register() {
        let mut manager = PluginManager::new();
        // Test registration
        let plugin = Arc::new(uuid::UuidPlugin);
        manager.register(plugin);
        let plugins = manager.plugins.read().unwrap();
        assert!(plugins.contains_key("uuid"));
    }

    #[test]
    fn test_plugin_manager_get() {
        let manager = PluginManager::new();
        // Test retrieval of registered plugin
        let plugin = manager.get("uuid");
        assert!(plugin.is_some());
        assert_eq!(plugin.unwrap().name(), "uuid");
    }

    #[test]
    fn test_plugin_manager_get_accepts_at_prefix() {
        let manager = PluginManager::new();
        let plugin = manager.get("@uuid");
        assert!(plugin.is_some());
        assert_eq!(plugin.unwrap().name(), "uuid");
    }

    #[test]
    fn test_plugin_manager_list() {
        let manager = PluginManager::new();
        let plugins = manager.list();
        // Should have at least the default plugins
        assert!(plugins.len() >= 8); // uuid, email, ip, url, timestamp, header, trailer, len
    }

    #[test]
    fn test_plugin_manager_execute_plugin() {
        let manager = PluginManager::new();
        // Test execution with real plugin (uuid)
        let plugin = manager.get("uuid").unwrap();
        let context = PluginContext::new(&Value::Null);
        let result = plugin.execute(&[Value::String("test".to_string())], &context);
        // UUID plugin should return a value
        assert!(result.is_ok());
    }

    #[test]
    fn test_plugin_manager_has_header_registered() {
        let manager = PluginManager::new();
        let plugin = manager.get("has_header");
        assert!(plugin.is_some(), "has_header plugin should be registered");
        assert_eq!(plugin.unwrap().name(), "has_header");
    }

    #[test]
    fn test_plugin_manager_empty_registered() {
        let manager = PluginManager::new();
        let plugin = manager.get("empty");
        assert!(plugin.is_some(), "empty plugin should be registered");
        assert_eq!(plugin.unwrap().name(), "empty");
    }

    #[test]
    fn test_plugin_manager_has_trailer_registered() {
        let manager = PluginManager::new();
        let plugin = manager.get("has_trailer");
        assert!(plugin.is_some(), "has_trailer plugin should be registered");
        assert_eq!(plugin.unwrap().name(), "has_trailer");
    }

    #[test]
    fn test_signature_metadata_empty() {
        let manager = PluginManager::new();
        let signature = manager.get("empty").unwrap().signature();
        assert_eq!(signature.return_type, TypeInfo::Bool);
        assert_eq!(signature.purity, PluginPurity::Pure);
        assert!(signature.deterministic);
        assert!(signature.idempotent);
        assert!(signature.safe_for_rewrite);
    }

    #[test]
    fn test_signature_metadata_env() {
        let manager = PluginManager::new();
        let signature = manager.get("env").unwrap().signature();
        assert_eq!(signature.return_type, TypeInfo::StringOrNull);
        assert_eq!(signature.purity, PluginPurity::Impure);
        assert!(!signature.deterministic);
        assert!(!signature.idempotent);
        assert!(!signature.safe_for_rewrite);
    }
}