eventcore-macros 1.0.1

Procedural macros for EventCore event sourcing library
Documentation
//! Procedural macros for the EventCore event sourcing library.
//!
//! This crate provides the `#[derive(Command)]` macro, which generates a
//! `CommandStreams` trait implementation for a command struct. The generated
//! implementation's `stream_declarations()` method returns every `StreamId`
//! field annotated with `#[stream]`, declaring the event streams that the
//! command reads and writes within a single atomic consistency boundary.
//!
//! # Usage
//!
//! Annotate a command struct with `#[derive(Command)]` and mark each
//! `StreamId` field that participates in the consistency boundary with
//! `#[stream]`:
//!
//! ```no_run
//! use eventcore::{Command, StreamId};
//!
//! #[derive(Command)]
//! pub struct TransferFunds {
//!     #[stream]
//!     pub source_account: StreamId,
//!     #[stream]
//!     pub destination_account: StreamId,
//!     pub amount: u64,
//! }
//! ```
//!
//! # Requirements
//!
//! - The derive target must be a struct with named fields (tuple structs are not
//!   supported).
//! - At least one field must carry the `#[stream]` attribute.
//! - Every `#[stream]`-annotated field must have type `StreamId` (or
//!   `eventcore::StreamId`).

use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{
    Data, DeriveInput, Error, Field, Fields, Meta, Path, Type, parse_macro_input,
    punctuated::Punctuated,
};

/// Entry point for `#[derive(Command)]`.
///
/// Generates a `CommandStreams` implementation for the annotated struct. Every
/// `StreamId` field marked with `#[stream]` is
/// included in the generated `stream_declarations()` return value, establishing
/// the command's atomic consistency boundary.
///
/// See the [crate-level documentation](self) for requirements and a usage
/// example.
#[proc_macro_derive(Command, attributes(stream))]
pub fn command(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    match expand_command(&input) {
        Ok(tokens) => tokens.into(),
        Err(error) => error.to_compile_error().into(),
    }
}

fn expand_command(input: &DeriveInput) -> syn::Result<TokenStream2> {
    let ident = &input.ident;
    let fields = extract_named_fields(input)?;
    let stream_exprs = collect_stream_fields(fields)?;

    if stream_exprs.is_empty() {
        return Err(Error::new_spanned(
            ident,
            "EventCore: #[derive(Command)] requires at least one #[stream] StreamId field; add #[stream] to your StreamId member",
        ));
    }

    Ok(quote! {
        impl ::eventcore::CommandStreams for #ident {
            fn stream_declarations(&self) -> ::eventcore::StreamDeclarations {
                ::eventcore::StreamDeclarations::try_from_streams(vec![
                    #( #stream_exprs ),*
                ])
                .expect("valid stream declarations generated by #[derive(Command)]")
            }
        }
    })
}

/// Ensures the derive target is a struct with named fields and returns them for
/// further validation.
fn extract_named_fields(input: &DeriveInput) -> syn::Result<&Punctuated<Field, syn::token::Comma>> {
    let Data::Struct(data_struct) = &input.data else {
        return Err(Error::new_spanned(
            &input.ident,
            "EventCore: #[derive(Command)] can only be applied to structs, not enums or unions",
        ));
    };

    let Fields::Named(fields) = &data_struct.fields else {
        return Err(Error::new_spanned(
            &input.ident,
            "EventCore: #[derive(Command)] requires named fields; tuple structs are not supported",
        ));
    };

    Ok(&fields.named)
}

/// Walks the struct fields, validating #[stream] usages and producing the
/// expressions used in the generated CommandStreams impl.
fn collect_stream_fields(
    fields: &Punctuated<Field, syn::token::Comma>,
) -> syn::Result<Vec<TokenStream2>> {
    let mut stream_exprs = Vec::new();

    for field in fields {
        if has_stream_marker(field)? {
            stream_exprs.push(stream_expression(field)?);
        }
    }

    Ok(stream_exprs)
}

/// Returns true when the field carries a valid #[stream] attribute.
fn has_stream_marker(field: &Field) -> syn::Result<bool> {
    let mut marked = false;

    for attr in &field.attrs {
        if attr.path().is_ident("stream") {
            if !matches!(&attr.meta, Meta::Path(_)) {
                return Err(Error::new_spanned(
                    attr,
                    "EventCore: #[stream] does not accept parameters",
                ));
            }

            marked = true;
        }
    }

    Ok(marked)
}

/// Produces the `self.field.clone()` expression for a validated stream field.
fn stream_expression(field: &Field) -> syn::Result<TokenStream2> {
    let Some(field_ident) = &field.ident else {
        return Err(Error::new_spanned(
            field,
            "EventCore: #[derive(Command)] requires named fields",
        ));
    };

    ensure_stream_id_type(field)?;

    Ok(quote! { self.#field_ident.clone() })
}

/// Confirms the field type resolves to StreamId (allowing qualified paths).
fn ensure_stream_id_type(field: &Field) -> syn::Result<()> {
    match &field.ty {
        Type::Path(type_path) if is_eventcore_stream_id(&type_path.path) => Ok(()),
        _ => Err(Error::new_spanned(
            field,
            "EventCore: #[stream] fields must have type eventcore::StreamId",
        )),
    }
}

fn is_eventcore_stream_id(path: &Path) -> bool {
    let Some(last) = path.segments.last() else {
        return false;
    };

    if last.ident != "StreamId" {
        return false;
    }

    path.segments
        .iter()
        .take(path.segments.len().saturating_sub(1))
        .all(|segment| {
            matches!(
                segment.ident.to_string().as_str(),
                "eventcore" | "crate" | "self" | "super"
            )
        })
}