Skip to main content

awa_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{parse_macro_input, DeriveInput, LitStr};
4
5/// Derive macro for job argument types.
6///
7/// Generates the `JobArgs` trait implementation including:
8/// - `kind()` — snake_case kind string (or custom override)
9/// - Requires `Serialize + Deserialize` (user must derive those)
10///
11/// # Usage
12///
13/// ```ignore
14/// #[derive(Debug, Serialize, Deserialize, JobArgs)]
15/// struct SendEmail {
16///     pub to: String,
17///     pub subject: String,
18/// }
19/// // kind() returns "send_email"
20///
21/// #[derive(Debug, Serialize, Deserialize, JobArgs)]
22/// #[awa(kind = "custom_kind")]
23/// struct MyJob {
24///     pub data: String,
25/// }
26/// // kind() returns "custom_kind"
27/// ```
28#[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    // Check for #[awa(kind = "custom")] attribute
34    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
63/// Convert CamelCase to snake_case.
64///
65/// Algorithm (from PRD §9.2):
66/// 1. Insert `_` before each uppercase letter following a lowercase letter or digit.
67/// 2. Insert `_` before an uppercase letter followed by a lowercase letter, if preceded by uppercase.
68/// 3. Lowercase everything.
69fn 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    /// Golden test cases from PRD §9.2.
99    #[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}