Skip to main content

aimdb_derive/
lib.rs

1//! Derive macros for AimDB record key types
2//!
3//! This crate provides the `#[derive(RecordKey)]` macro for defining
4//! compile-time checked record keys with optional connector metadata.
5//!
6//! # Example
7//!
8//! Illustrative (not compiled: the generated impl targets `aimdb_core`'s
9//! `RecordKey` trait, a circular dev-dependency for this proc-macro crate —
10//! compiled integration tests live in `aimdb-core`):
11//!
12//! ```rust,ignore
13//! use aimdb_derive::RecordKey;
14//!
15//! // Note: Hash is auto-generated by the derive macro to satisfy the Borrow<str> contract
16//! #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
17//! pub enum AppKey {
18//!     #[key = "temp.indoor"]
19//!     #[link_address = "mqtt://sensors/temp/indoor"]
20//!     TempIndoor,
21//!
22//!     #[key = "temp.outdoor"]
23//!     #[link_address = "knx://9/1/0"]
24//!     TempOutdoor,
25//! }
26//! ```
27
28use proc_macro::TokenStream;
29use quote::quote;
30use syn::{parse_macro_input, Data, DeriveInput, Error, Fields, Lit, Meta};
31
32mod migration_chain;
33
34/// Variable-arity replacement for `aimdb-data-contracts`'s old 3-arm
35/// `macro_rules! migration_chain`. Re-exported as
36/// `aimdb_data_contracts::migration_chain!` — see that crate's
37/// `migratable` feature and `migratable.rs` for the user-facing grammar
38/// and docs.
39#[proc_macro]
40pub fn migration_chain(input: TokenStream) -> TokenStream {
41    migration_chain::migration_chain(input)
42}
43
44/// Derive `Linkable` (JSON codec) for a schema type.
45///
46/// Emits `from_bytes`/`to_bytes` via `serde_json::from_slice`/`to_vec` — the
47/// boilerplate every hand-written `impl Linkable` for a JSON-wire type repeats.
48/// Requires `T: Serialize + DeserializeOwned` (a normal
49/// compile error surfaces if it's missing) and the `linkable-json` feature of
50/// `aimdb-data-contracts` (for the `Linkable` trait and its `__private::serde_json`
51/// re-export). Binary formats (e.g. KNX DPT codecs) still implement `Linkable`
52/// by hand — this derive is JSON-only.
53///
54/// # Example
55///
56/// Illustrative (not compiled: see the crate-level note — compiled
57/// integration tests live in `aimdb-data-contracts`).
58///
59/// ```rust,ignore
60/// use aimdb_data_contracts::Linkable;
61/// use serde::{Deserialize, Serialize};
62///
63/// #[derive(Clone, Debug, Serialize, Deserialize, Linkable)]
64/// struct Temperature {
65///     celsius: f32,
66///     timestamp: u64,
67/// }
68/// ```
69#[proc_macro_derive(Linkable)]
70pub fn derive_linkable(input: TokenStream) -> TokenStream {
71    let input = parse_macro_input!(input as DeriveInput);
72    let name = &input.ident;
73
74    let expanded = quote! {
75        impl ::aimdb_data_contracts::Linkable for #name {
76            fn from_bytes(
77                data: &[u8],
78            ) -> Result<Self, ::aimdb_data_contracts::__private::alloc::string::String> {
79                ::aimdb_data_contracts::__private::serde_json::from_slice(data).map_err(|e| {
80                    ::aimdb_data_contracts::__private::alloc::string::ToString::to_string(&e)
81                })
82            }
83
84            fn to_bytes(
85                &self,
86            ) -> Result<
87                ::aimdb_data_contracts::__private::alloc::vec::Vec<u8>,
88                ::aimdb_data_contracts::__private::alloc::string::String,
89            > {
90                ::aimdb_data_contracts::__private::serde_json::to_vec(self).map_err(|e| {
91                    ::aimdb_data_contracts::__private::alloc::string::ToString::to_string(&e)
92                })
93            }
94        }
95    };
96
97    expanded.into()
98}
99
100/// Derive the `RecordKey` trait for an enum
101///
102/// Each variant must have a `#[key = "..."]` attribute specifying its string key.
103/// Optionally, the enum can have a `#[key_prefix = "..."]` attribute to prepend
104/// a common prefix to all keys.
105///
106/// ## Connector Metadata
107///
108/// Variants can have a `#[link_address = "..."]` attribute to associate a connector
109/// URL/address with the key (MQTT topics, KNX group addresses, etc.):
110///
111/// # Example
112///
113/// Illustrative (not compiled: see the crate-level note — compiled
114/// integration tests live in `aimdb-core`):
115///
116/// ```rust,ignore
117/// // Note: Hash is auto-generated to satisfy the Borrow<str> contract
118/// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
119/// #[key_prefix = "sensors."]
120/// pub enum SensorKey {
121///     #[key = "temp"]           // → "sensors.temp"
122///     #[link_address = "mqtt://sensors/temp/indoor"]
123///     Temperature,
124///
125///     #[key = "humid"]          // → "sensors.humid"
126///     #[link_address = "knx://9/0/1"]
127///     Humidity,
128/// }
129/// ```
130///
131/// # Generated Code
132///
133/// The macro generates:
134/// - `impl aimdb_core::RecordKey for YourEnum` with `as_str()` and `link_address()` methods
135/// - `impl core::borrow::Borrow<str> for YourEnum` for O(1) HashMap lookups
136/// - `impl core::hash::Hash for YourEnum` that hashes the string key (required by Borrow contract)
137#[proc_macro_derive(RecordKey, attributes(key, key_prefix, link_address))]
138pub fn derive_record_key(input: TokenStream) -> TokenStream {
139    let input = parse_macro_input!(input as DeriveInput);
140
141    match derive_record_key_impl(input) {
142        Ok(tokens) => tokens.into(),
143        Err(err) => err.to_compile_error().into(),
144    }
145}
146
147fn derive_record_key_impl(input: DeriveInput) -> Result<proc_macro2::TokenStream, Error> {
148    let name = &input.ident;
149
150    // Check it's an enum
151    let data_enum = match &input.data {
152        Data::Enum(data) => data,
153        _ => {
154            return Err(Error::new_spanned(
155                &input,
156                "RecordKey can only be derived for enums",
157            ));
158        }
159    };
160
161    // Get optional key_prefix from enum attributes
162    let prefix = get_key_prefix(&input.attrs)?;
163
164    // Collect variant data
165    struct VariantData {
166        name: syn::Ident,
167        key: String,
168        link: Option<String>,
169    }
170
171    let mut variants = Vec::new();
172    let mut seen_keys = std::collections::HashSet::new();
173
174    for variant in &data_enum.variants {
175        // Ensure unit variant (no fields)
176        match &variant.fields {
177            Fields::Unit => {}
178            _ => {
179                return Err(Error::new_spanned(
180                    variant,
181                    "RecordKey variants must be unit variants (no fields)",
182                ));
183            }
184        }
185
186        let variant_name = &variant.ident;
187        let key = get_variant_key(&variant.attrs, variant_name)?;
188        let link = get_optional_attr(&variant.attrs, "link_address")?;
189
190        // Build full key with prefix
191        let full_key = if let Some(ref p) = prefix {
192            format!("{}{}", p, key)
193        } else {
194            key
195        };
196
197        // Check for duplicate keys
198        if !seen_keys.insert(full_key.clone()) {
199            return Err(Error::new_spanned(
200                variant,
201                format!(
202                    "Duplicate key \"{}\" - each variant must have a unique key",
203                    full_key
204                ),
205            ));
206        }
207
208        variants.push(VariantData {
209            name: variant_name.clone(),
210            key: full_key,
211            link,
212        });
213    }
214
215    // Check if any variant has link
216    let has_link = variants.iter().any(|v| v.link.is_some());
217
218    // Generate match arms for as_str()
219    let as_str_arms = variants.iter().map(|v| {
220        let variant_name = &v.name;
221        let key = &v.key;
222        quote! {
223            Self::#variant_name => #key
224        }
225    });
226
227    // Generate link_address() implementation if any variant has it
228    let link_impl = if has_link {
229        let arms = variants.iter().map(|v| {
230            let variant_name = &v.name;
231            match &v.link {
232                Some(url) => quote! { Self::#variant_name => Some(#url) },
233                None => quote! { Self::#variant_name => None },
234            }
235        });
236        quote! {
237            #[inline]
238            fn link_address(&self) -> Option<&str> {
239                match self {
240                    #(#arms),*
241                }
242            }
243        }
244    } else {
245        quote! {}
246    };
247
248    // Generate the implementations
249    let expanded = quote! {
250        impl aimdb_core::RecordKey for #name {
251            #[inline]
252            fn as_str(&self) -> &'static str {
253                match self {
254                    #(#as_str_arms),*
255                }
256            }
257
258            #link_impl
259        }
260
261        impl core::borrow::Borrow<str> for #name {
262            #[inline]
263            fn borrow(&self) -> &str {
264                <Self as aimdb_core::RecordKey>::as_str(self)
265            }
266        }
267
268        // Hash implementation that hashes the string key, ensuring compliance
269        // with Rust's Borrow trait contract: hash(k) == hash(k.borrow())
270        impl core::hash::Hash for #name {
271            fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
272                <Self as aimdb_core::RecordKey>::as_str(self).hash(state);
273            }
274        }
275    };
276
277    Ok(expanded)
278}
279
280/// Extract `#[key_prefix = "..."]` from enum attributes
281fn get_key_prefix(attrs: &[syn::Attribute]) -> Result<Option<String>, Error> {
282    get_optional_attr(attrs, "key_prefix")
283}
284
285/// Extract an optional `#[attr_name = "..."]` from attributes
286fn get_optional_attr(attrs: &[syn::Attribute], attr_name: &str) -> Result<Option<String>, Error> {
287    for attr in attrs {
288        if attr.path().is_ident(attr_name) {
289            let meta = &attr.meta;
290            if let Meta::NameValue(nv) = meta {
291                if let syn::Expr::Lit(expr_lit) = &nv.value {
292                    if let Lit::Str(lit_str) = &expr_lit.lit {
293                        return Ok(Some(lit_str.value()));
294                    }
295                }
296            }
297            return Err(Error::new_spanned(
298                attr,
299                format!("Expected #[{} = \"...\"]", attr_name),
300            ));
301        }
302    }
303    Ok(None)
304}
305
306/// Extract `#[key = "..."]` from variant attributes
307fn get_variant_key(attrs: &[syn::Attribute], variant_name: &syn::Ident) -> Result<String, Error> {
308    for attr in attrs {
309        if attr.path().is_ident("key") {
310            let meta = &attr.meta;
311            if let Meta::NameValue(nv) = meta {
312                if let syn::Expr::Lit(expr_lit) = &nv.value {
313                    if let Lit::Str(lit_str) = &expr_lit.lit {
314                        return Ok(lit_str.value());
315                    }
316                }
317            }
318            return Err(Error::new_spanned(attr, "Expected #[key = \"...\"]"));
319        }
320    }
321
322    Err(Error::new_spanned(
323        variant_name,
324        format!(
325            "Missing #[key = \"...\"] attribute on variant `{}`",
326            variant_name
327        ),
328    ))
329}
330
331#[cfg(test)]
332mod tests {
333    // Proc-macro crate tests are limited - integration tests are in aimdb-core
334}