Skip to main content

optative_derive/
lib.rs

1//! Procedural macros for the [optative](https://crates.io/crates/optative) reconciler
2//! library.
3//!
4//! - [`macro@lifecycle_trace`] — wraps a `Lifecycle` impl's `enter` /
5//!   `reconcile_self` / `exit` with `tracing` events.
6//! - [`macro@Ephemeral`] — generates a `Drop` impl that reconciles managed sets to
7//!   empty so their `exit` hooks run on drop.
8//!
9//! # Requirements at the call site
10//!
11//! The code these macros generate refers to crates by name. Any crate that uses
12//! `#[lifecycle_trace]` must have **`serde_json`** and **`tracing`** in scope (as
13//! standard for derive macros, they are not re-exported by this crate).
14
15use proc_macro::TokenStream;
16use quote::quote;
17use syn::{Data, DeriveInput, Fields, ImplItem, ItemImpl, parse_macro_input};
18
19/// Wraps an `impl Lifecycle for T` block so each lifecycle transition emits a
20/// `tracing` event.
21///
22/// Injects `wrap_enter`, `wrap_reconcile`, and `wrap_exit` overrides that call
23/// the user's `enter` / `reconcile_self` / `exit` and emit `tracing::info!` on
24/// success (`"entering"` / `"exiting"`) or `tracing::error!` on failure, with
25/// `key`, `display_name`, `metadata`, and `error` fields.
26///
27/// The generated code calls `serde_json::to_string` and the `tracing` macros, so
28/// both crates must be available at the call site.
29#[proc_macro_attribute]
30pub fn lifecycle_trace(_attr: TokenStream, item: TokenStream) -> TokenStream {
31    let mut input = parse_macro_input!(item as ItemImpl);
32
33    let is_lifecycle_impl = input
34        .trait_
35        .as_ref()
36        .and_then(|(_, path, _)| path.segments.last())
37        .map(|seg| seg.ident == "Lifecycle")
38        .unwrap_or(false);
39
40    if !is_lifecycle_impl {
41        return syn::Error::new_spanned(
42            &input,
43            "#[lifecycle_trace] can only be applied to `impl Lifecycle for T` blocks",
44        )
45        .to_compile_error()
46        .into();
47    }
48
49    let entering: ImplItem = syn::parse_quote! {
50        fn wrap_enter(self, ctx: &mut Self::Context, output: &mut Self::Output) -> Result<Self::State, Self::Error> {
51            let _lc = self.lifecycle_context();
52            let _key = self.key();
53            let _meta = serde_json::to_string(&_lc.metadata).unwrap_or_default();
54            let result = self.enter(ctx, output);
55            match &result {
56                Ok(_) => tracing::info!(key = ?_key, display_name = %_lc.display_name, metadata = %_meta, "entering"),
57                Err(e) => tracing::error!(key = ?_key, display_name = %_lc.display_name, metadata = %_meta, error = %e, "entering failed"),
58            }
59            result
60        }
61    };
62
63    let reconciling: ImplItem = syn::parse_quote! {
64        fn wrap_reconcile(self, state: &mut Self::State, ctx: &mut Self::Context, output: &mut Self::Output) -> Result<(), Self::Error> {
65            let _lc = self.lifecycle_context();
66            let _key = self.key();
67            let _meta = serde_json::to_string(&_lc.metadata).unwrap_or_default();
68            let result = self.reconcile_self(state, ctx, output);
69            if let Err(e) = &result {
70                tracing::error!(key = ?_key, display_name = %_lc.display_name, metadata = %_meta, error = %e, "reconciling failed");
71            }
72            result
73        }
74    };
75
76    let exiting: ImplItem = syn::parse_quote! {
77        fn wrap_exit(state: Self::State, ctx: &mut Self::Context, output: &mut Self::Output) -> Result<(), Self::Error> {
78            let _lc = Self::lifecycle_state_context(&state);
79            let _meta = serde_json::to_string(&_lc.metadata).unwrap_or_default();
80            let result = Self::exit(state, ctx, output);
81            match &result {
82                Ok(_) => tracing::info!(display_name = %_lc.display_name, metadata = %_meta, "exiting"),
83                Err(e) => tracing::error!(display_name = %_lc.display_name, metadata = %_meta, error = %e, "exiting failed"),
84            }
85            result
86        }
87    };
88
89    input.items.push(entering);
90    input.items.push(reconciling);
91    input.items.push(exiting);
92
93    quote! { #input }.into()
94}
95
96/// Generates a `Drop` impl that reconciles `#[reconciler]`-annotated fields to an
97/// empty desired set, running their managed items' `exit` hooks on drop.
98///
99/// Each field annotated `#[reconciler(output = <field_name>)]` gets a drop-time
100/// call of `self.<field>.reconcile(Vec::new(), &mut Default::default(), &mut
101/// self.<output_field>)`. The reconciler's `Context` type must implement
102/// `Default`.
103#[proc_macro_derive(Ephemeral, attributes(reconciler))]
104pub fn derive_ephemeral(input: TokenStream) -> TokenStream {
105    let input = parse_macro_input!(input as DeriveInput);
106    let name = &input.ident;
107    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
108
109    let fields = match &input.data {
110        Data::Struct(s) => match &s.fields {
111            Fields::Named(f) => &f.named,
112            _ => panic!("Ephemeral only supports named fields"),
113        },
114        _ => panic!("Ephemeral only supports structs"),
115    };
116
117    let mut calls = vec![];
118    for field in fields {
119        let field_name = field.ident.as_ref().unwrap();
120        for attr in &field.attrs {
121            if !attr.path().is_ident("reconciler") {
122                continue;
123            }
124            let mut output_ident: Option<syn::Ident> = None;
125            attr.parse_nested_meta(|meta| {
126                if meta.path.is_ident("output")
127                    && let syn::Expr::Path(p) = meta.value()?.parse::<syn::Expr>()?
128                {
129                    output_ident = p.path.get_ident().cloned();
130                }
131                Ok(())
132            })
133            .unwrap();
134            let output = output_ident.expect("reconciler attribute requires output = <field_name>");
135            calls.push(quote! {
136                { let mut __ctx = ::core::default::Default::default(); self.#field_name.reconcile(::std::vec::Vec::new(), &mut __ctx, &mut self.#output); }
137            });
138        }
139    }
140
141    quote! {
142        impl #impl_generics Drop for #name #ty_generics #where_clause {
143            fn drop(&mut self) { #(#calls)* }
144        }
145    }
146    .into()
147}