1use proc_macro::TokenStream;
2use quote::{ToTokens, quote};
3use syn::{
4 Data, DeriveInput, Fields, GenericArgument, Lit, Meta, PathArguments, parse_macro_input,
5};
6
7fn is_compound_type(ty: &syn::Type) -> bool {
10 let syn::Type::Path(type_path) = ty else {
11 return false;
12 };
13 let Some(ident) = type_path.path.segments.last().map(|s| &s.ident) else {
14 return false;
15 };
16 ident == "Vec" || ident == "HashMap" || ident == "PathBuf"
17}
18
19fn has_serde_skip(field: &syn::Field) -> bool {
21 for attr in &field.attrs {
22 if attr.path().is_ident("serde") {
23 if let Ok(nested) = attr.parse_args_with(
25 syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
26 ) {
27 for meta in &nested {
28 if meta.path().is_ident("skip") {
29 return true;
30 }
31 }
32 }
33 }
34 }
35 false
36}
37
38#[proc_macro_derive(Configurable, attributes(secret, nested, prefix, serde))]
62pub fn derive_configurable(input: TokenStream) -> TokenStream {
63 let input = parse_macro_input!(input as DeriveInput);
64 let struct_name = &input.ident;
65
66 let prefix = extract_prefix(&input);
67 let category = derive_category(&prefix);
68
69 let fields = match &input.data {
70 Data::Struct(data) => match &data.fields {
71 Fields::Named(fields) => &fields.named,
72 _ => {
73 return syn::Error::new_spanned(
74 &input,
75 "Configurable only supports structs with named fields",
76 )
77 .to_compile_error()
78 .into();
79 }
80 },
81 _ => {
82 return syn::Error::new_spanned(&input, "Configurable can only be derived for structs")
83 .to_compile_error()
84 .into();
85 }
86 };
87
88 let mut secret_field_entries = Vec::new();
90 let mut set_arms = Vec::new();
91 let mut encrypt_ops = Vec::new();
92 let mut decrypt_ops = Vec::new();
93 let mut nested_collect = Vec::new();
94 let mut nested_set = Vec::new();
95 let mut nested_encrypt = Vec::new();
96 let mut nested_decrypt = Vec::new();
97
98 let mut prop_field_entries = Vec::new();
100 let mut prop_names: Vec<String> = Vec::new();
101 let mut prop_kind_tokens = Vec::new();
102 let mut prop_is_option_flags = Vec::new();
103 let mut prop_is_secret_arms = Vec::new();
104 let mut nested_prop_fields = Vec::new();
105 let mut nested_get_prop = Vec::new();
106 let mut nested_set_prop = Vec::new();
107 let mut nested_prop_is_secret = Vec::new();
108 let mut init_defaults_ops = Vec::new();
109
110 for field in fields {
111 let field_ident = field.ident.as_ref().expect("Named field must have ident");
112 let is_secret = has_attr(field, "secret");
113 let is_nested = has_attr(field, "nested");
114 let serde_skip = has_serde_skip(field);
115
116 if is_secret {
118 let field_name_kebab = snake_to_kebab(&field_ident.to_string());
119 let full_name = if prefix.is_empty() {
120 field_name_kebab.clone()
121 } else {
122 format!("{}.{}", prefix, field_name_kebab)
123 };
124
125 let is_option = is_option_type(&field.ty);
126 let is_vec_string = extract_vec_inner(&field.ty)
127 .map(|inner| inner.to_token_stream().to_string() == "String")
128 .unwrap_or(false);
129 let full_name_lit = &full_name;
130 let category_lit = &category;
131
132 if is_vec_string {
133 secret_field_entries.push(quote! {
135 crate::config::SecretFieldInfo {
136 name: #full_name_lit,
137 category: #category_lit,
138 is_set: !self.#field_ident.is_empty(),
139 }
140 });
141 encrypt_ops.push(quote! {
142 for element in &mut self.#field_ident {
143 if !element.is_empty() && !crate::security::SecretStore::is_encrypted(element) {
144 *element = store.encrypt(element)
145 .with_context(|| format!("Failed to encrypt {}[]", #full_name_lit))?;
146 }
147 }
148 });
149 decrypt_ops.push(quote! {
150 for element in &mut self.#field_ident {
151 if crate::security::SecretStore::is_encrypted(element) {
152 *element = store.decrypt(element)
153 .with_context(|| format!("Failed to decrypt {}[]", #full_name_lit))?;
154 }
155 }
156 });
157 } else if is_option {
158 secret_field_entries.push(quote! {
159 crate::config::SecretFieldInfo {
160 name: #full_name_lit,
161 category: #category_lit,
162 is_set: self.#field_ident.as_ref().is_some_and(|v| !v.is_empty()),
163 }
164 });
165 set_arms.push(quote! {
166 #full_name_lit => { self.#field_ident = Some(value); Ok(()) }
167 });
168 encrypt_ops.push(quote! {
169 if let Some(raw) = &self.#field_ident {
170 if !crate::security::SecretStore::is_encrypted(raw) {
171 self.#field_ident = Some(
172 store.encrypt(raw)
173 .with_context(|| format!("Failed to encrypt {}", #full_name_lit))?
174 );
175 }
176 }
177 });
178 decrypt_ops.push(quote! {
179 if let Some(raw) = &self.#field_ident {
180 if crate::security::SecretStore::is_encrypted(raw) {
181 self.#field_ident = Some(
182 store.decrypt(raw)
183 .with_context(|| format!("Failed to decrypt {}", #full_name_lit))?
184 );
185 }
186 }
187 });
188 } else {
189 secret_field_entries.push(quote! {
190 crate::config::SecretFieldInfo {
191 name: #full_name_lit,
192 category: #category_lit,
193 is_set: !self.#field_ident.is_empty(),
194 }
195 });
196 set_arms.push(quote! {
197 #full_name_lit => { self.#field_ident = value; Ok(()) }
198 });
199 encrypt_ops.push(quote! {
200 if !self.#field_ident.is_empty() && !crate::security::SecretStore::is_encrypted(&self.#field_ident) {
201 self.#field_ident = store.encrypt(&self.#field_ident)
202 .with_context(|| format!("Failed to encrypt {}", #full_name_lit))?;
203 }
204 });
205 decrypt_ops.push(quote! {
206 if crate::security::SecretStore::is_encrypted(&self.#field_ident) {
207 self.#field_ident = store.decrypt(&self.#field_ident)
208 .with_context(|| format!("Failed to decrypt {}", #full_name_lit))?;
209 }
210 });
211 }
212 }
213
214 if is_nested {
215 let is_option = is_option_type(&field.ty);
217 let hashmap_value_ty = extract_hashmap_value_type(&field.ty);
218
219 if let Some(value_ty) = hashmap_value_ty {
220 nested_collect.push(quote! {
222 for inner in self.#field_ident.values() {
223 fields.extend(inner.secret_fields());
224 }
225 });
226 nested_set.push(quote! {
227 for inner in self.#field_ident.values_mut() {
228 if let Ok(()) = inner.set_secret(name, value.clone()) {
229 return Ok(());
230 }
231 }
232 });
233 nested_encrypt.push(quote! {
234 for inner in self.#field_ident.values_mut() {
235 inner.encrypt_secrets(store)?;
236 }
237 });
238 nested_decrypt.push(quote! {
239 for inner in self.#field_ident.values_mut() {
240 inner.decrypt_secrets(store)?;
241 }
242 });
243 nested_prop_is_secret.push(quote! {
244 if <#value_ty>::prop_is_secret(name) { return true; }
245 });
246
247 continue;
248 } else if is_option {
249 nested_collect.push(quote! {
250 if let Some(inner) = &self.#field_ident {
251 fields.extend(inner.secret_fields());
252 }
253 });
254 nested_set.push(quote! {
255 if let Some(inner) = &mut self.#field_ident {
256 if let Ok(()) = inner.set_secret(name, value.clone()) {
257 return Ok(());
258 }
259 }
260 });
261 nested_encrypt.push(quote! {
262 if let Some(inner) = &mut self.#field_ident {
263 inner.encrypt_secrets(store)?;
264 }
265 });
266 nested_decrypt.push(quote! {
267 if let Some(inner) = &mut self.#field_ident {
268 inner.decrypt_secrets(store)?;
269 }
270 });
271
272 nested_prop_fields.push(quote! {
274 if let Some(inner) = &self.#field_ident {
275 fields.extend(inner.prop_fields());
276 }
277 });
278 nested_get_prop.push(quote! {
279 if let Some(inner) = &self.#field_ident {
280 if let Ok(val) = inner.get_prop(name) {
281 return Ok(val);
282 }
283 }
284 });
285 nested_set_prop.push(quote! {
286 if let Some(inner) = &mut self.#field_ident {
287 if let Ok(()) = inner.set_prop(name, value_str) {
288 return Ok(());
289 }
290 }
291 });
292 nested_prop_is_secret.push(quote! {
293 });
296
297 if let Some(inner_ty) = extract_option_inner(&field.ty) {
299 let inner_ty_tokens = quote! { #inner_ty };
300 init_defaults_ops.push(quote! {
301 if self.#field_ident.is_none() {
302 let child_prefix = <#inner_ty_tokens>::configurable_prefix();
303 let dominated = prefix.map_or(true, |p| {
304 child_prefix.starts_with(p) || p.starts_with(child_prefix)
305 });
306 if dominated {
307 let mut probe = <#inner_ty_tokens as Default>::default();
308 let child_results = probe.init_defaults(prefix);
309 initialized.push(child_prefix);
310 initialized.extend(child_results);
311 self.#field_ident = Some(probe);
312 }
313 } else if let Some(inner) = &mut self.#field_ident {
314 initialized.extend(inner.init_defaults(prefix));
315 }
316 });
317
318 nested_prop_is_secret.pop(); nested_prop_is_secret.push(quote! {
321 if <#inner_ty_tokens>::prop_is_secret(name) {
322 return true;
323 }
324 });
325 }
326 } else {
327 nested_collect.push(quote! {
328 fields.extend(self.#field_ident.secret_fields());
329 });
330 nested_set.push(quote! {
331 if let Ok(()) = self.#field_ident.set_secret(name, value.clone()) {
332 return Ok(());
333 }
334 });
335 nested_encrypt.push(quote! {
336 self.#field_ident.encrypt_secrets(store)?;
337 });
338 nested_decrypt.push(quote! {
339 self.#field_ident.decrypt_secrets(store)?;
340 });
341
342 nested_prop_fields.push(quote! {
344 fields.extend(self.#field_ident.prop_fields());
345 });
346 nested_get_prop.push(quote! {
347 if let Ok(val) = self.#field_ident.get_prop(name) {
348 return Ok(val);
349 }
350 });
351 nested_set_prop.push(quote! {
352 if let Ok(()) = self.#field_ident.set_prop(name, value_str) {
353 return Ok(());
354 }
355 });
356
357 let field_ty = &field.ty;
359 nested_prop_is_secret.push(quote! {
360 if <#field_ty>::prop_is_secret(name) {
361 return true;
362 }
363 });
364
365 init_defaults_ops.push(quote! {
367 initialized.extend(self.#field_ident.init_defaults(prefix));
368 });
369 }
370
371 continue; }
373
374 if serde_skip {
376 continue;
377 }
378
379 let is_option = is_option_type(&field.ty);
381 let inner_ty = extract_option_inner(&field.ty).unwrap_or(&field.ty);
382
383 if is_compound_type(inner_ty) {
385 continue;
386 }
387
388 let field_name_kebab = snake_to_kebab(&field_ident.to_string());
389 let serde_name = field_ident.to_string();
390 let full_name = if prefix.is_empty() {
391 field_name_kebab.clone()
392 } else {
393 format!("{}.{}", prefix, field_name_kebab)
394 };
395 let full_name_lit = &full_name;
396 let serde_name_lit = &serde_name;
397 let category_lit = &category;
398 let type_str = field.ty.to_token_stream().to_string().replace(' ', "");
399 let type_hint_lit = &type_str;
400
401 let kind_token = quote! { <#inner_ty as crate::config::HasPropKind>::PROP_KIND };
405 let enum_variants_expr = quote! {
406 if <#inner_ty as crate::config::HasPropKind>::PROP_KIND == crate::config::PropKind::Enum {
407 Some(|| {
408 crate::config::enum_variants::<#inner_ty>()
409 .split(", ")
410 .map(|s| s.to_string())
411 .collect()
412 })
413 } else {
414 None
415 }
416 };
417
418 if is_secret {
419 prop_is_secret_arms.push(quote! { #full_name_lit => true, });
420 }
421
422 prop_names.push(full_name.clone());
423 prop_kind_tokens.push(kind_token.clone());
424 prop_is_option_flags.push(is_option);
425
426 prop_field_entries.push(quote! {
427 crate::config::make_prop_field(
428 __table.as_ref(),
429 #full_name_lit,
430 #serde_name_lit,
431 #category_lit,
432 #type_hint_lit,
433 #kind_token,
434 #is_secret,
435 #enum_variants_expr,
436 )
437 });
438 }
439
440 let prefix_lit = &prefix;
441
442 let expanded = quote! {
443 impl #struct_name {
444 pub fn configurable_prefix() -> &'static str {
446 #prefix_lit
447 }
448
449 pub fn secret_fields(&self) -> Vec<crate::config::SecretFieldInfo> {
451 let mut fields = vec![#(#secret_field_entries),*];
452 #(#nested_collect)*
453 fields
454 }
455
456 pub fn encrypt_secrets(&mut self, store: &crate::security::SecretStore) -> anyhow::Result<()> {
458 use anyhow::Context;
459 #(#encrypt_ops)*
460 #(#nested_encrypt)*
461 Ok(())
462 }
463
464 pub fn decrypt_secrets(&mut self, store: &crate::security::SecretStore) -> anyhow::Result<()> {
466 use anyhow::Context;
467 #(#decrypt_ops)*
468 #(#nested_decrypt)*
469 Ok(())
470 }
471
472 pub fn set_secret(&mut self, name: &str, value: String) -> anyhow::Result<()> {
474 match name {
476 #(#set_arms,)*
477 _ => {
478 #(#nested_set)*
480 anyhow::bail!("Unknown secret '{}'", name)
481 }
482 }
483 }
484
485 pub fn prop_fields(&self) -> Vec<crate::config::PropFieldInfo> {
487 let __table = toml::Value::try_from(self)
488 .ok()
489 .and_then(|v| match v { toml::Value::Table(t) => Some(t), _ => None });
490 let mut fields = vec![#(#prop_field_entries),*];
491 #(#nested_prop_fields)*
492 fields
493 }
494
495 pub fn get_prop(&self, name: &str) -> anyhow::Result<String> {
497 #(#nested_get_prop)*
498 const KNOWN: &[&str] = &[#(#prop_names),*];
499 if !KNOWN.contains(&name) {
500 anyhow::bail!("Unknown property '{}'", name);
501 }
502 crate::config::serde_get_prop(self, Self::configurable_prefix(), name, Self::prop_is_secret(name))
503 }
504
505 pub fn set_prop(&mut self, name: &str, value_str: &str) -> anyhow::Result<()> {
507 #(#nested_set_prop)*
508 const KNOWN: &[&str] = &[#(#prop_names),*];
509 const KINDS: &[crate::config::PropKind] = &[#(#prop_kind_tokens),*];
510 const IS_OPTION: &[bool] = &[#(#prop_is_option_flags),*];
511 let idx = KNOWN.iter().position(|&n| n == name)
512 .ok_or_else(|| anyhow::anyhow!("Unknown property '{}'", name))?;
513 crate::config::serde_set_prop(self, Self::configurable_prefix(), name, value_str, KINDS[idx], IS_OPTION[idx])
514 }
515
516 pub fn prop_is_secret(name: &str) -> bool {
518 match name {
519 #(#prop_is_secret_arms)*
520 _ => {
521 #(#nested_prop_is_secret)*
522 false
523 }
524 }
525 }
526
527 pub fn init_defaults(&mut self, prefix: Option<&str>) -> Vec<&'static str> {
530 let mut initialized: Vec<&'static str> = Vec::new();
531 #(#init_defaults_ops)*
532 initialized
533 }
534 }
535 };
536
537 TokenStream::from(expanded)
538}
539
540fn derive_category(prefix: &str) -> String {
541 if prefix.is_empty() {
542 return "Core".to_string();
543 }
544 let first = prefix.split('.').next().unwrap_or("");
545 match first {
546 "channels" => "Channels".to_string(),
547 "tts" => "TTS".to_string(),
548 "transcription" => "Transcription".to_string(),
549 other => {
550 let mut chars = other.chars();
551 match chars.next() {
552 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
553 None => "Core".to_string(),
554 }
555 }
556 }
557}
558
559fn extract_prefix(input: &DeriveInput) -> String {
560 for attr in &input.attrs {
561 if !attr.path().is_ident("prefix") {
562 continue;
563 }
564 let Meta::NameValue(nv) = &attr.meta else {
565 continue;
566 };
567 let syn::Expr::Lit(expr_lit) = &nv.value else {
568 continue;
569 };
570 let Lit::Str(lit_str) = &expr_lit.lit else {
571 continue;
572 };
573 return lit_str.value();
574 }
575 String::new()
576}
577
578fn has_attr(field: &syn::Field, name: &str) -> bool {
579 field.attrs.iter().any(|attr| attr.path().is_ident(name))
580}
581
582fn snake_to_kebab(s: &str) -> String {
583 s.replace('_', "-")
584}
585
586fn is_option_type(ty: &syn::Type) -> bool {
587 let syn::Type::Path(type_path) = ty else {
588 return false;
589 };
590 type_path
591 .path
592 .segments
593 .last()
594 .is_some_and(|s| s.ident == "Option")
595}
596
597fn extract_type_arg<'a>(
600 expected_ident: &str,
601 index: usize,
602 ty: &'a syn::Type,
603) -> Option<&'a syn::Type> {
604 let syn::Type::Path(type_path) = ty else {
605 return None;
606 };
607 let segment = type_path.path.segments.last()?;
608 if segment.ident != expected_ident {
609 return None;
610 }
611 let PathArguments::AngleBracketed(args) = &segment.arguments else {
612 return None;
613 };
614 args.args
615 .iter()
616 .filter_map(|a| {
617 if let GenericArgument::Type(t) = a {
618 Some(t)
619 } else {
620 None
621 }
622 })
623 .nth(index)
624}
625
626fn extract_option_inner(ty: &syn::Type) -> Option<&syn::Type> {
627 extract_type_arg("Option", 0, ty)
628}
629fn extract_vec_inner(ty: &syn::Type) -> Option<&syn::Type> {
630 extract_type_arg("Vec", 0, ty)
631}
632fn extract_hashmap_value_type(ty: &syn::Type) -> Option<&syn::Type> {
633 extract_type_arg("HashMap", 1, ty)
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use syn::parse_quote;
640
641 #[test]
642 fn snake_to_kebab_converts_underscores() {
643 assert_eq!(snake_to_kebab("access_token"), "access-token");
644 assert_eq!(snake_to_kebab("api_key"), "api-key");
645 assert_eq!(snake_to_kebab("bot_token"), "bot-token");
646 assert_eq!(snake_to_kebab("simple"), "simple");
647 }
648
649 #[test]
650 fn derive_category_from_prefix() {
651 assert_eq!(derive_category("channels.matrix"), "Channels");
652 assert_eq!(derive_category("channels.discord"), "Channels");
653 assert_eq!(derive_category("tts.openai"), "TTS");
654 assert_eq!(derive_category("tts.elevenlabs"), "TTS");
655 assert_eq!(derive_category("transcription"), "Transcription");
656 assert_eq!(derive_category("transcription.openai"), "Transcription");
657 assert_eq!(derive_category(""), "Core");
658 }
659
660 #[test]
661 fn has_serde_skip_detects_skip() {
662 let field: syn::Field = parse_quote! {
663 #[serde(skip)]
664 pub workspace_dir: String
665 };
666 assert!(has_serde_skip(&field));
667 }
668
669 #[test]
670 fn has_serde_skip_ignores_other_serde_attrs() {
671 let field: syn::Field = parse_quote! {
672 #[serde(default)]
673 pub enabled: bool
674 };
675 assert!(!has_serde_skip(&field));
676
677 let field: syn::Field = parse_quote! {
678 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub value: Option<String>
680 };
681 assert!(!has_serde_skip(&field));
682 }
683
684 #[test]
685 fn has_serde_skip_no_serde_attr() {
686 let field: syn::Field = parse_quote! {
687 pub name: String
688 };
689 assert!(!has_serde_skip(&field));
690 }
691
692 #[test]
693 fn has_serde_skip_with_other_attrs() {
694 let field: syn::Field = parse_quote! {
695 #[secret]
696 #[serde(skip)]
697 pub token: String
698 };
699 assert!(has_serde_skip(&field));
700 }
701}