1use proc_macro::TokenStream;
2use proc_macro2::Span;
3use proc_macro_crate::{crate_name, FoundCrate};
4use quote::quote;
5use syn::{parse_macro_input, DeriveInput, Ident, LitStr};
6
7#[proc_macro_derive(JobArgs, attributes(awa))]
35pub fn derive_job_args(input: TokenStream) -> TokenStream {
36 let input = parse_macro_input!(input as DeriveInput);
37 let name = &input.ident;
38
39 let custom_kind = input.attrs.iter().find_map(|attr| {
41 if !attr.path().is_ident("awa") {
42 return None;
43 }
44 let mut kind_value = None;
45 let _ = attr.parse_nested_meta(|meta| {
46 if meta.path.is_ident("kind") {
47 let value = meta.value()?;
48 let s: LitStr = value.parse()?;
49 kind_value = Some(s.value());
50 }
51 Ok(())
52 });
53 kind_value
54 });
55
56 let kind_str = custom_kind.unwrap_or_else(|| camel_to_snake(&name.to_string()));
57
58 let model_path = if let Ok(found) = crate_name("awa") {
62 let ident = match found {
64 FoundCrate::Itself => Ident::new("awa", Span::call_site()),
65 FoundCrate::Name(name) => Ident::new(&name, Span::call_site()),
66 };
67 quote!(::#ident::awa_model)
68 } else if let Ok(found) = crate_name("awa-model") {
69 match found {
71 FoundCrate::Itself => quote!(crate),
72 FoundCrate::Name(name) => {
73 let ident = Ident::new(&name, Span::call_site());
74 quote!(::#ident)
75 }
76 }
77 } else {
78 quote!(::awa_model)
79 };
80
81 let expanded = quote! {
82 impl #model_path::JobArgs for #name {
83 fn kind() -> &'static str {
84 #kind_str
85 }
86 }
87 };
88
89 TokenStream::from(expanded)
90}
91
92fn camel_to_snake(name: &str) -> String {
99 let mut result = String::with_capacity(name.len() + 4);
100 let chars: Vec<char> = name.chars().collect();
101
102 for i in 0..chars.len() {
103 let current = chars[i];
104
105 if current.is_uppercase() {
106 let needs_separator = i > 0
107 && ((chars[i - 1].is_lowercase() || chars[i - 1].is_ascii_digit())
108 || (chars[i - 1].is_uppercase()
109 && i + 1 < chars.len()
110 && chars[i + 1].is_lowercase()));
111 if needs_separator {
112 result.push('_');
113 }
114 result.push(current.to_lowercase().next().unwrap());
115 } else {
116 result.push(current);
117 }
118 }
119
120 result
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
129 fn test_kind_derivation_golden_cases() {
130 let cases = vec![
131 ("SendEmail", "send_email"),
132 ("SendConfirmationEmail", "send_confirmation_email"),
133 ("SMTPEmail", "smtp_email"),
134 ("OAuthRefresh", "o_auth_refresh"),
135 ("PDFRenderJob", "pdf_render_job"),
136 ("ProcessV2Import", "process_v2_import"),
137 ("ReconcileQ3Revenue", "reconcile_q3_revenue"),
138 ("HTMLToPDF", "html_to_pdf"),
139 ("IOError", "io_error"),
140 ];
141
142 for (input, expected) in cases {
143 let result = camel_to_snake(input);
144 assert_eq!(
145 result, expected,
146 "camel_to_snake({input:?}): expected {expected:?}, got {result:?}"
147 );
148 }
149 }
150
151 #[test]
152 fn test_simple_cases() {
153 assert_eq!(camel_to_snake("Job"), "job");
154 assert_eq!(camel_to_snake("MyJob"), "my_job");
155 assert_eq!(camel_to_snake("A"), "a");
156 assert_eq!(camel_to_snake("AB"), "ab");
157 }
158}