Skip to main content

crdt_migrate_macros/
lib.rs

1//! Proc macros for `crdt-migrate`.
2//!
3//! Provides two macros:
4//!
5//! - **`#[crdt_schema]`** — Attribute macro that generates `CrdtVersioned` and
6//!   `Schema` implementations for a struct.
7//!
8//! - **`#[migration]`** — Attribute macro that wraps a migration function into
9//!   a `MigrationStep` implementation.
10
11use proc_macro::TokenStream;
12use quote::quote;
13use syn::{parse_macro_input, punctuated::Punctuated, token::Comma, ItemFn, ItemStruct, Meta};
14
15/// Attribute macro that generates schema + versioning impls for a struct.
16///
17/// # Attributes
18///
19/// - `version = N` — **Required.** The schema version number.
20/// - `table = "name"` — **Required.** The storage namespace/table name.
21/// - `min_version = N` — Optional. Minimum supported version (defaults to 1).
22///
23/// # Generated Implementations
24///
25/// - `crdt_store::CrdtVersioned` with `SCHEMA_VERSION = version`
26/// - `crdt_migrate::Schema` with `VERSION`, `MIN_SUPPORTED_VERSION`, `NAMESPACE`
27///
28/// # Example
29///
30/// ```ignore
31/// use crdt_migrate_macros::crdt_schema;
32/// use serde::{Serialize, Deserialize};
33///
34/// #[crdt_schema(version = 1, table = "sensors")]
35/// #[derive(Debug, Serialize, Deserialize)]
36/// struct SensorData {
37///     device_id: String,
38///     temperature: f64,
39/// }
40/// ```
41#[proc_macro_attribute]
42pub fn crdt_schema(attr: TokenStream, item: TokenStream) -> TokenStream {
43    let input = parse_macro_input!(item as ItemStruct);
44    let args = parse_macro_input!(attr with Punctuated::<Meta, Comma>::parse_terminated);
45
46    let mut version: Option<u32> = None;
47    let mut table: Option<String> = None;
48    let mut min_version: Option<u32> = None;
49
50    for meta in &args {
51        if let Meta::NameValue(nv) = meta {
52            let key = nv
53                .path
54                .get_ident()
55                .map(|i| i.to_string())
56                .unwrap_or_default();
57            match key.as_str() {
58                "version" => {
59                    if let syn::Expr::Lit(syn::ExprLit {
60                        lit: syn::Lit::Int(lit),
61                        ..
62                    }) = &nv.value
63                    {
64                        version = lit.base10_parse().ok();
65                    }
66                }
67                "table" => {
68                    if let syn::Expr::Lit(syn::ExprLit {
69                        lit: syn::Lit::Str(lit),
70                        ..
71                    }) = &nv.value
72                    {
73                        table = Some(lit.value());
74                    }
75                }
76                "min_version" => {
77                    if let syn::Expr::Lit(syn::ExprLit {
78                        lit: syn::Lit::Int(lit),
79                        ..
80                    }) = &nv.value
81                    {
82                        min_version = lit.base10_parse().ok();
83                    }
84                }
85                _ => {
86                    return syn::Error::new_spanned(&nv.path, format!("unknown attribute `{key}`"))
87                        .to_compile_error()
88                        .into();
89                }
90            }
91        }
92    }
93
94    let version = match version {
95        Some(v) => v,
96        None => {
97            return syn::Error::new(
98                proc_macro2::Span::call_site(),
99                "missing required attribute `version`",
100            )
101            .to_compile_error()
102            .into();
103        }
104    };
105
106    let table = match table {
107        Some(t) => t,
108        None => {
109            return syn::Error::new(
110                proc_macro2::Span::call_site(),
111                "missing required attribute `table`",
112            )
113            .to_compile_error()
114            .into();
115        }
116    };
117
118    let min_ver = min_version.unwrap_or(1);
119    let version_u8 = version as u8;
120    let struct_name = &input.ident;
121
122    let expanded = quote! {
123        #input
124
125        impl crdt_store::CrdtVersioned for #struct_name {
126            const SCHEMA_VERSION: u8 = #version_u8;
127        }
128
129        impl crdt_migrate::Schema for #struct_name {
130            const VERSION: u32 = #version;
131            const MIN_SUPPORTED_VERSION: u32 = #min_ver;
132            const NAMESPACE: &'static str = #table;
133        }
134    };
135
136    expanded.into()
137}
138
139/// Attribute macro that wraps a migration function into a `MigrationStep`.
140///
141/// The function must take a single argument (the old version's data) and return
142/// the new version's data. Both types must implement `Serialize` and `DeserializeOwned`.
143///
144/// # Attributes
145///
146/// - `from = N` — **Required.** Source schema version.
147/// - `to = M` — **Required.** Target schema version.
148///
149/// # Generated Code
150///
151/// Creates a struct `{FnName}Migration` that implements `MigrationStep`.
152/// The struct handles deserialization of the old format, calls your function,
153/// and serializes the result.
154///
155/// Also generates a `register_{fn_name}` function that returns a boxed
156/// `MigrationStep` for convenient registration.
157///
158/// # Example
159///
160/// ```ignore
161/// use crdt_migrate_macros::migration;
162/// use serde::{Serialize, Deserialize};
163///
164/// #[derive(Serialize, Deserialize)]
165/// struct SensorV1 { temperature: f32 }
166///
167/// #[derive(Serialize, Deserialize)]
168/// struct SensorV2 { temperature: f32, humidity: Option<f32> }
169///
170/// #[migration(from = 1, to = 2)]
171/// fn add_humidity(old: SensorV1) -> SensorV2 {
172///     SensorV2 {
173///         temperature: old.temperature,
174///         humidity: None,
175///     }
176/// }
177/// // Generates: AddHumidityMigration struct + impl MigrationStep
178/// // Generates: fn register_add_humidity() -> Box<dyn MigrationStep>
179/// ```
180#[proc_macro_attribute]
181pub fn migration(attr: TokenStream, item: TokenStream) -> TokenStream {
182    let input = parse_macro_input!(item as ItemFn);
183    let args = parse_macro_input!(attr with Punctuated::<Meta, Comma>::parse_terminated);
184
185    let mut from_version: Option<u32> = None;
186    let mut to_version: Option<u32> = None;
187
188    for meta in &args {
189        if let Meta::NameValue(nv) = meta {
190            let key = nv
191                .path
192                .get_ident()
193                .map(|i| i.to_string())
194                .unwrap_or_default();
195            match key.as_str() {
196                "from" => {
197                    if let syn::Expr::Lit(syn::ExprLit {
198                        lit: syn::Lit::Int(lit),
199                        ..
200                    }) = &nv.value
201                    {
202                        from_version = lit.base10_parse().ok();
203                    }
204                }
205                "to" => {
206                    if let syn::Expr::Lit(syn::ExprLit {
207                        lit: syn::Lit::Int(lit),
208                        ..
209                    }) = &nv.value
210                    {
211                        to_version = lit.base10_parse().ok();
212                    }
213                }
214                _ => {
215                    return syn::Error::new_spanned(&nv.path, format!("unknown attribute `{key}`"))
216                        .to_compile_error()
217                        .into();
218                }
219            }
220        }
221    }
222
223    let from_ver = match from_version {
224        Some(v) => v,
225        None => {
226            return syn::Error::new(
227                proc_macro2::Span::call_site(),
228                "missing required attribute `from`",
229            )
230            .to_compile_error()
231            .into();
232        }
233    };
234
235    let to_ver = match to_version {
236        Some(v) => v,
237        None => {
238            return syn::Error::new(
239                proc_macro2::Span::call_site(),
240                "missing required attribute `to`",
241            )
242            .to_compile_error()
243            .into();
244        }
245    };
246
247    let fn_name = &input.sig.ident;
248
249    // Extract the input type from the function signature
250    let input_type = match input.sig.inputs.first() {
251        Some(syn::FnArg::Typed(pat_type)) => &pat_type.ty,
252        _ => {
253            return syn::Error::new_spanned(
254                &input.sig,
255                "migration function must take exactly one argument",
256            )
257            .to_compile_error()
258            .into();
259        }
260    };
261
262    // Extract the output type
263    let output_type = match &input.sig.output {
264        syn::ReturnType::Type(_, ty) => ty,
265        syn::ReturnType::Default => {
266            return syn::Error::new_spanned(
267                &input.sig,
268                "migration function must have a return type",
269            )
270            .to_compile_error()
271            .into();
272        }
273    };
274
275    // Generate struct name: snake_case -> PascalCase + "Migration"
276    let struct_name = {
277        let name = fn_name.to_string();
278        let pascal: String = name
279            .split('_')
280            .map(|part| {
281                let mut chars = part.chars();
282                match chars.next() {
283                    Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
284                    None => String::new(),
285                }
286            })
287            .collect();
288        syn::Ident::new(&format!("{pascal}Migration"), fn_name.span())
289    };
290
291    // Generate register function name
292    let register_fn = syn::Ident::new(&format!("register_{fn_name}"), fn_name.span());
293
294    let expanded = quote! {
295        #input
296
297        /// Auto-generated migration step struct.
298        pub struct #struct_name;
299
300        impl crdt_migrate::MigrationStep for #struct_name {
301            fn source_version(&self) -> u32 {
302                #from_ver
303            }
304
305            fn target_version(&self) -> u32 {
306                #to_ver
307            }
308
309            fn migrate(&self, data: &[u8]) -> Result<Vec<u8>, crdt_migrate::MigrationError> {
310                let old: #input_type = postcard::from_bytes(data)
311                    .map_err(|e| crdt_migrate::MigrationError::Deserialization(
312                        e.to_string()
313                    ))?;
314                let new: #output_type = #fn_name(old);
315                postcard::to_allocvec(&new)
316                    .map_err(|e| crdt_migrate::MigrationError::Serialization(
317                        e.to_string()
318                    ))
319            }
320        }
321
322        /// Register this migration step for use with `MigrationEngine`.
323        pub fn #register_fn() -> Box<dyn crdt_migrate::MigrationStep> {
324            Box::new(#struct_name)
325        }
326    };
327
328    expanded.into()
329}