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}