arcature-macros 0.1.0

Proc-macro crate for Arcature: #[model], #[request], #[controller], #[derive(Job)], #[derive(Event)].
Documentation
//! `#[policy(Model)]` -- declares a struct as an authorization policy.
//!
//! The macro generates `impl ::arcature::DxComponent` for the annotated
//! struct (the static `NAME` the module graph lists it under) plus a
//! `POLICY_FOR` const recording the resource type the policy
//! authorizes for. The developer writes `impl ::arcature::Policy<M>` by
//! hand -- the authorization logic (the `check` method) is business
//! behavior the macro must not guess or hide.
//!
//! ## Syntax
//!
//! ```ignore
//! #[policy(Link)]
//! pub struct LinkPolicy;
//!
//! impl ::arcature::Policy<Link> for LinkPolicy {
//!     type User = User;
//!     fn check(user: &User, action: &str, link: &Link) -> bool {
//!         match action {
//!             "view" => true,
//!             "update" => user.id == link.user_id,
//!             _ => false,
//!         }
//!     }
//! }
//! ```
//!
//! The `Model` argument is the resource type this policy authorizes for.
//! It is metadata only -- the macro does not generate `impl Policy<M>`.
//!
//! ## Custom name
//!
//! ```ignore
//! #[policy(Link, name = "LinkAuthz")]
//! pub struct LinkPolicy;
//! ```
//!
//! One file, one macro: this is the entirety of the `#[policy]` expansion.

use proc_macro2::TokenStream;
use quote::{ToTokens, quote};
use syn::parse::{Parse, ParseStream};

use crate::diagnostic::{MacroError, MacroErrorCode, MacroResult};

/// The implementation of `#[policy(Model)]`. Parses the attribute
/// arguments and struct, then expands the struct with `impl DxComponent`
/// and the `POLICY_FOR` const.
pub fn policy(attr: TokenStream, item: TokenStream) -> MacroResult {
    let args: PolicyArgs =
        syn::parse2(attr).map_err(|e| MacroError::from_syn(MacroErrorCode::ArcM002, e))?;

    let item_struct: syn::ItemStruct =
        syn::parse2(item).map_err(|e| MacroError::from_syn(MacroErrorCode::ArcM001, e))?;

    let struct_name = &item_struct.ident;
    let (impl_generics, ty_generics, where_clause) = item_struct.generics.split_for_impl();

    let name_lit = args.name.unwrap_or_else(|| struct_name.to_string());

    // The model type name. The model path (e.g. `Link`) is recorded as a
    // string so tooling can show which resource type each policy
    // authorizes for.
    let model_name = simple_path_name(&args.model);

    Ok(quote! {
        #item_struct

        impl #impl_generics ::arcature::DxComponent for #struct_name #ty_generics #where_clause {
            const NAME: &'static str = #name_lit;
        }

        impl #impl_generics #struct_name #ty_generics #where_clause {
            /// The resource type name this policy authorizes for.
            /// Generated by `#[policy(Model)]`.
            pub const POLICY_FOR: &'static str = #model_name;
        }
    })
}

/// The parsed `#[policy(...)]` attribute arguments.
struct PolicyArgs {
    model: syn::Path,
    name: Option<String>,
}

impl Parse for PolicyArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        // The first argument is the model path (required, bare -- not
        // `model = Link`, just `Link`).
        let model: syn::Path = input.parse()?;

        let mut name: Option<String> = None;

        // Optional comma-separated key=value args after the model.
        while input.peek(syn::Token![,]) {
            let _: syn::Token![,] = input.parse()?;
            let ident: syn::Ident = input.parse()?;
            let _: syn::Token![=] = input.parse()?;

            match ident.to_string().as_str() {
                "name" => {
                    let lit: syn::LitStr = input.parse()?;
                    name = Some(lit.value());
                }
                other => {
                    return Err(syn::Error::new(
                        ident.span(),
                        format!("unknown `#[policy]` argument `{other}`; expected `name`"),
                    ));
                }
            }
        }

        Ok(PolicyArgs { model, name })
    }
}

/// Extract the simple type name from a model path (e.g. `Link` from
/// `myapp::models::Link`).
fn simple_path_name(path: &syn::Path) -> String {
    path.segments
        .last()
        .map(|seg| seg.ident.to_string())
        .unwrap_or_else(|| path.to_token_stream().to_string())
}

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

    #[test]
    fn generates_dx_component_and_policy_for() {
        let expanded = policy(quote! { Link }, quote! { pub struct LinkPolicy; })
            .unwrap()
            .to_string();

        assert!(
            expanded.contains("DxComponent"),
            "missing DxComponent: {expanded}"
        );
        assert!(
            expanded.contains("\"LinkPolicy\""),
            "wrong NAME: {expanded}"
        );
        assert!(
            expanded.contains("POLICY_FOR"),
            "missing POLICY_FOR: {expanded}"
        );
        assert!(
            expanded.contains("\"Link\""),
            "missing model name: {expanded}"
        );
        // Should not generate the Policy *trait* impl.
        assert!(
            !expanded.contains(":: arcature :: Policy"),
            "should not generate Policy trait impl: {expanded}"
        );
    }

    #[test]
    fn uses_name_override() {
        let expanded = policy(
            quote! { Link, name = "LinkAuthz" },
            quote! { pub struct LinkPolicy; },
        )
        .unwrap()
        .to_string();
        assert!(
            expanded.contains("\"LinkAuthz\""),
            "expected LinkAuthz: {expanded}"
        );
    }

    #[test]
    fn qualified_model_path_uses_last_segment() {
        let expanded = policy(
            quote! { myapp::models::Link },
            quote! { pub struct LinkPolicy; },
        )
        .unwrap()
        .to_string();
        assert!(
            expanded.contains("\"Link\""),
            "expected simple name: {expanded}"
        );
    }

    #[test]
    fn rejects_enum() {
        let err = policy(quote! { Link }, quote! { enum Status { Active } }).unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM001);
    }

    #[test]
    fn rejects_unknown_argument() {
        let err = policy(quote! { Link, foo = 42 }, quote! { pub struct LinkPolicy; }).unwrap_err();
        assert_eq!(err.code(), MacroErrorCode::ArcM002);
    }
}