Skip to main content

awa_macros/
lib.rs

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/// Derive macro for job argument types.
8///
9/// Generates the `JobArgs` trait implementation including:
10/// - `kind()` — snake_case kind string (or custom override)
11/// - Requires `Serialize + Deserialize` (user must derive those)
12///
13/// # Usage
14///
15/// ```ignore
16/// use awa_model::JobArgs;
17///
18/// #[derive(Debug, Serialize, Deserialize, JobArgs)]
19/// struct SendEmail {
20///     pub to: String,
21///     pub subject: String,
22/// }
23/// // kind() returns "send_email"
24///
25/// use awa_model::JobArgs;
26///
27/// #[derive(Debug, Serialize, Deserialize, JobArgs)]
28/// #[awa(kind = "custom_kind")]
29/// struct MyJob {
30///     pub data: String,
31/// }
32/// // kind() returns "custom_kind"
33/// ```
34#[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    // Check for #[awa(kind = "custom")] attribute
40    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    // Resolve the path to awa_model::JobArgs. Users may depend on either
59    // the `awa` facade crate or `awa-model` directly. We check for `awa`
60    // first (which re-exports awa_model), then fall back to `awa-model`.
61    let model_path = if let Ok(found) = crate_name("awa") {
62        // User depends on the `awa` facade which re-exports awa_model.
63        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        // User depends on awa-model directly.
70        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
92/// Convert CamelCase to snake_case.
93///
94/// Algorithm (from PRD §9.2):
95/// 1. Insert `_` before each uppercase letter following a lowercase letter or digit.
96/// 2. Insert `_` before an uppercase letter followed by a lowercase letter, if preceded by uppercase.
97/// 3. Lowercase everything.
98fn 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    /// Golden test cases from PRD §9.2.
128    #[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}