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//! ```rust,ignore
9//! use aimdb_derive::RecordKey;
10//!
11//! // Note: Hash is auto-generated by the derive macro to satisfy the Borrow<str> contract
12//! #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
13//! pub enum AppKey {
14//!     #[key = "temp.indoor"]
15//!     #[link_address = "mqtt://sensors/temp/indoor"]
16//!     TempIndoor,
17//!
18//!     #[key = "temp.outdoor"]
19//!     #[link_address = "knx://9/1/0"]
20//!     TempOutdoor,
21//! }
22//! ```
23
24use proc_macro::TokenStream;
25use quote::quote;
26use syn::{parse_macro_input, Data, DeriveInput, Error, Fields, Lit, Meta};
27
28/// Derive the `RecordKey` trait for an enum
29///
30/// Each variant must have a `#[key = "..."]` attribute specifying its string key.
31/// Optionally, the enum can have a `#[key_prefix = "..."]` attribute to prepend
32/// a common prefix to all keys.
33///
34/// ## Connector Metadata
35///
36/// Variants can have a `#[link_address = "..."]` attribute to associate a connector
37/// URL/address with the key (MQTT topics, KNX group addresses, etc.):
38///
39/// # Example
40///
41/// ```rust,ignore
42/// // Note: Hash is auto-generated to satisfy the Borrow<str> contract
43/// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)]
44/// #[key_prefix = "sensors."]
45/// pub enum SensorKey {
46///     #[key = "temp"]           // → "sensors.temp"
47///     #[link_address = "mqtt://sensors/temp/indoor"]
48///     Temperature,
49///
50///     #[key = "humid"]          // → "sensors.humid"
51///     #[link_address = "knx://9/0/1"]
52///     Humidity,
53/// }
54/// ```
55///
56/// # Generated Code
57///
58/// The macro generates:
59/// - `impl aimdb_core::RecordKey for YourEnum` with `as_str()` and `link_address()` methods
60/// - `impl core::borrow::Borrow<str> for YourEnum` for O(1) HashMap lookups
61/// - `impl core::hash::Hash for YourEnum` that hashes the string key (required by Borrow contract)
62#[proc_macro_derive(RecordKey, attributes(key, key_prefix, link_address))]
63pub fn derive_record_key(input: TokenStream) -> TokenStream {
64    let input = parse_macro_input!(input as DeriveInput);
65
66    match derive_record_key_impl(input) {
67        Ok(tokens) => tokens.into(),
68        Err(err) => err.to_compile_error().into(),
69    }
70}
71
72fn derive_record_key_impl(input: DeriveInput) -> Result<proc_macro2::TokenStream, Error> {
73    let name = &input.ident;
74
75    // Check it's an enum
76    let data_enum = match &input.data {
77        Data::Enum(data) => data,
78        _ => {
79            return Err(Error::new_spanned(
80                &input,
81                "RecordKey can only be derived for enums",
82            ));
83        }
84    };
85
86    // Get optional key_prefix from enum attributes
87    let prefix = get_key_prefix(&input.attrs)?;
88
89    // Collect variant data
90    struct VariantData {
91        name: syn::Ident,
92        key: String,
93        link: Option<String>,
94    }
95
96    let mut variants = Vec::new();
97    let mut seen_keys = std::collections::HashSet::new();
98
99    for variant in &data_enum.variants {
100        // Ensure unit variant (no fields)
101        match &variant.fields {
102            Fields::Unit => {}
103            _ => {
104                return Err(Error::new_spanned(
105                    variant,
106                    "RecordKey variants must be unit variants (no fields)",
107                ));
108            }
109        }
110
111        let variant_name = &variant.ident;
112        let key = get_variant_key(&variant.attrs, variant_name)?;
113        let link = get_optional_attr(&variant.attrs, "link_address")?;
114
115        // Build full key with prefix
116        let full_key = if let Some(ref p) = prefix {
117            format!("{}{}", p, key)
118        } else {
119            key
120        };
121
122        // Check for duplicate keys
123        if !seen_keys.insert(full_key.clone()) {
124            return Err(Error::new_spanned(
125                variant,
126                format!(
127                    "Duplicate key \"{}\" - each variant must have a unique key",
128                    full_key
129                ),
130            ));
131        }
132
133        variants.push(VariantData {
134            name: variant_name.clone(),
135            key: full_key,
136            link,
137        });
138    }
139
140    // Check if any variant has link
141    let has_link = variants.iter().any(|v| v.link.is_some());
142
143    // Generate match arms for as_str()
144    let as_str_arms = variants.iter().map(|v| {
145        let variant_name = &v.name;
146        let key = &v.key;
147        quote! {
148            Self::#variant_name => #key
149        }
150    });
151
152    // Generate link_address() implementation if any variant has it
153    let link_impl = if has_link {
154        let arms = variants.iter().map(|v| {
155            let variant_name = &v.name;
156            match &v.link {
157                Some(url) => quote! { Self::#variant_name => Some(#url) },
158                None => quote! { Self::#variant_name => None },
159            }
160        });
161        quote! {
162            #[inline]
163            fn link_address(&self) -> Option<&str> {
164                match self {
165                    #(#arms),*
166                }
167            }
168        }
169    } else {
170        quote! {}
171    };
172
173    // Generate the implementations
174    let expanded = quote! {
175        impl aimdb_core::RecordKey for #name {
176            #[inline]
177            fn as_str(&self) -> &str {
178                match self {
179                    #(#as_str_arms),*
180                }
181            }
182
183            #link_impl
184        }
185
186        impl core::borrow::Borrow<str> for #name {
187            #[inline]
188            fn borrow(&self) -> &str {
189                <Self as aimdb_core::RecordKey>::as_str(self)
190            }
191        }
192
193        // Hash implementation that hashes the string key, ensuring compliance
194        // with Rust's Borrow trait contract: hash(k) == hash(k.borrow())
195        impl core::hash::Hash for #name {
196            fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
197                <Self as aimdb_core::RecordKey>::as_str(self).hash(state);
198            }
199        }
200    };
201
202    Ok(expanded)
203}
204
205/// Extract `#[key_prefix = "..."]` from enum attributes
206fn get_key_prefix(attrs: &[syn::Attribute]) -> Result<Option<String>, Error> {
207    get_optional_attr(attrs, "key_prefix")
208}
209
210/// Extract an optional `#[attr_name = "..."]` from attributes
211fn get_optional_attr(attrs: &[syn::Attribute], attr_name: &str) -> Result<Option<String>, Error> {
212    for attr in attrs {
213        if attr.path().is_ident(attr_name) {
214            let meta = &attr.meta;
215            if let Meta::NameValue(nv) = meta {
216                if let syn::Expr::Lit(expr_lit) = &nv.value {
217                    if let Lit::Str(lit_str) = &expr_lit.lit {
218                        return Ok(Some(lit_str.value()));
219                    }
220                }
221            }
222            return Err(Error::new_spanned(
223                attr,
224                format!("Expected #[{} = \"...\"]", attr_name),
225            ));
226        }
227    }
228    Ok(None)
229}
230
231/// Extract `#[key = "..."]` from variant attributes
232fn get_variant_key(attrs: &[syn::Attribute], variant_name: &syn::Ident) -> Result<String, Error> {
233    for attr in attrs {
234        if attr.path().is_ident("key") {
235            let meta = &attr.meta;
236            if let Meta::NameValue(nv) = meta {
237                if let syn::Expr::Lit(expr_lit) = &nv.value {
238                    if let Lit::Str(lit_str) = &expr_lit.lit {
239                        return Ok(lit_str.value());
240                    }
241                }
242            }
243            return Err(Error::new_spanned(attr, "Expected #[key = \"...\"]"));
244        }
245    }
246
247    Err(Error::new_spanned(
248        variant_name,
249        format!(
250            "Missing #[key = \"...\"] attribute on variant `{}`",
251            variant_name
252        ),
253    ))
254}
255
256#[cfg(test)]
257mod tests {
258    // Proc-macro crate tests are limited - integration tests are in aimdb-core
259}