1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{parse_macro_input, DeriveInput, LitStr};
4
5#[proc_macro_derive(JobArgs, attributes(awa))]
29pub fn derive_job_args(input: TokenStream) -> TokenStream {
30 let input = parse_macro_input!(input as DeriveInput);
31 let name = &input.ident;
32
33 let custom_kind = input.attrs.iter().find_map(|attr| {
35 if !attr.path().is_ident("awa") {
36 return None;
37 }
38 let mut kind_value = None;
39 let _ = attr.parse_nested_meta(|meta| {
40 if meta.path.is_ident("kind") {
41 let value = meta.value()?;
42 let s: LitStr = value.parse()?;
43 kind_value = Some(s.value());
44 }
45 Ok(())
46 });
47 kind_value
48 });
49
50 let kind_str = custom_kind.unwrap_or_else(|| camel_to_snake(&name.to_string()));
51
52 let expanded = quote! {
53 impl awa_model::JobArgs for #name {
54 fn kind() -> &'static str {
55 #kind_str
56 }
57 }
58 };
59
60 TokenStream::from(expanded)
61}
62
63fn camel_to_snake(name: &str) -> String {
70 let mut result = String::with_capacity(name.len() + 4);
71 let chars: Vec<char> = name.chars().collect();
72
73 for i in 0..chars.len() {
74 let current = chars[i];
75
76 if current.is_uppercase() {
77 let needs_separator = i > 0
78 && ((chars[i - 1].is_lowercase() || chars[i - 1].is_ascii_digit())
79 || (chars[i - 1].is_uppercase()
80 && i + 1 < chars.len()
81 && chars[i + 1].is_lowercase()));
82 if needs_separator {
83 result.push('_');
84 }
85 result.push(current.to_lowercase().next().unwrap());
86 } else {
87 result.push(current);
88 }
89 }
90
91 result
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
100 fn test_kind_derivation_golden_cases() {
101 let cases = vec![
102 ("SendEmail", "send_email"),
103 ("SendConfirmationEmail", "send_confirmation_email"),
104 ("SMTPEmail", "smtp_email"),
105 ("OAuthRefresh", "o_auth_refresh"),
106 ("PDFRenderJob", "pdf_render_job"),
107 ("ProcessV2Import", "process_v2_import"),
108 ("ReconcileQ3Revenue", "reconcile_q3_revenue"),
109 ("HTMLToPDF", "html_to_pdf"),
110 ("IOError", "io_error"),
111 ];
112
113 for (input, expected) in cases {
114 let result = camel_to_snake(input);
115 assert_eq!(
116 result, expected,
117 "camel_to_snake({input:?}): expected {expected:?}, got {result:?}"
118 );
119 }
120 }
121
122 #[test]
123 fn test_simple_cases() {
124 assert_eq!(camel_to_snake("Job"), "job");
125 assert_eq!(camel_to_snake("MyJob"), "my_job");
126 assert_eq!(camel_to_snake("A"), "a");
127 assert_eq!(camel_to_snake("AB"), "ab");
128 }
129}