Skip to main content

modkit_canonical_errors_macro/
lib.rs

1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2//! Proc-macro for canonical error resource types.
3//!
4//! Provides the `#[resource_error("gts...")]` attribute macro.
5
6use proc_macro::TokenStream;
7use proc_macro2::{Span, TokenStream as TokenStream2};
8use quote::quote;
9use syn::LitStr;
10use syn::parse_macro_input;
11
12/// Attribute macro that generates a resource error type with builder-returning
13/// constructors for the 13 canonical error categories that carry a
14/// `resource_type`.
15///
16/// # Usage
17///
18/// ```rust,ignore
19/// use modkit_canonical_errors::resource_error;
20///
21/// #[resource_error("gts.cf.core.users.user.v1~")]
22/// struct UserResourceError;
23/// ```
24///
25/// The GTS resource-type literal is validated at compile time.
26///
27/// Generated constructors either accept a detail string or are zero-argument
28/// (using a default message). Each returns a `ResourceErrorBuilder` with
29/// typestate enforcement.
30#[proc_macro_attribute]
31pub fn resource_error(attr: TokenStream, item: TokenStream) -> TokenStream {
32    let gts_lit = parse_macro_input!(attr as LitStr);
33    let input = parse_macro_input!(item as syn::ItemStruct);
34
35    match generate_resource_error(&gts_lit, &input) {
36        Ok(tokens) => tokens.into(),
37        Err(e) => e.to_compile_error().into(),
38    }
39}
40
41const CANONICAL_ERRORS_PKG: &str = "cf-modkit-canonical-errors";
42const CANONICAL_ERRORS_LIB: &str = "modkit_canonical_errors";
43
44/// Resolves the path to the `modkit_canonical_errors` crate at the expansion site.
45///
46/// Uses `CARGO_PKG_NAME` to detect when the macro is invoked from within the
47/// canonical-errors package itself (e.g. integration tests), where the lib name
48/// (`modkit_canonical_errors`) differs from the package name
49/// (`cf-modkit-canonical-errors`). For external consumers the resolution is
50/// delegated to `proc_macro_crate`.
51fn resolve_crate_path(gts_lit: &LitStr) -> syn::Result<TokenStream2> {
52    let in_self = std::env::var("CARGO_PKG_NAME").is_ok_and(|p| p == CANONICAL_ERRORS_PKG);
53
54    if in_self {
55        // Inside the cf-modkit-canonical-errors package.
56        // `crate` is correct only for the lib target; integration tests and
57        // examples access the library as an extern crate by its [lib] name.
58        let is_lib = std::env::var("CARGO_CRATE_NAME").is_ok_and(|c| c == CANONICAL_ERRORS_LIB);
59
60        if is_lib {
61            return Ok(quote!(crate));
62        }
63
64        let ident = syn::Ident::new(CANONICAL_ERRORS_LIB, proc_macro2::Span::call_site());
65        return Ok(quote!(::#ident));
66    }
67
68    match proc_macro_crate::crate_name(CANONICAL_ERRORS_PKG) {
69        Ok(proc_macro_crate::FoundCrate::Itself) => Ok(quote!(crate)),
70        Ok(proc_macro_crate::FoundCrate::Name(n)) => {
71            // When the dependency is not renamed, `proc_macro_crate` returns the
72            // package name normalised to a Rust identifier.  If [lib].name differs
73            // from the package name (as it does here) we must map back to the actual
74            // lib name, otherwise the generated code references a non-existent crate.
75            let pkg_normalized = CANONICAL_ERRORS_PKG.replace('-', "_");
76            let effective = if n == pkg_normalized {
77                CANONICAL_ERRORS_LIB
78            } else {
79                &n
80            };
81            let ident = syn::Ident::new(effective, proc_macro2::Span::call_site());
82            Ok(quote!(::#ident))
83        }
84        Err(_) => Err(syn::Error::new_spanned(
85            gts_lit,
86            "cf-modkit-canonical-errors must be a direct dependency",
87        )),
88    }
89}
90
91fn generate_resource_error(gts_lit: &LitStr, input: &syn::ItemStruct) -> syn::Result<TokenStream2> {
92    let gts_type = gts_lit.value();
93    validate_gts_resource_type_str(&gts_type, gts_lit.span())?;
94
95    if !matches!(input.fields, syn::Fields::Unit) {
96        return Err(syn::Error::new_spanned(
97            &input.ident,
98            "#[resource_error] only supports unit structs (e.g. `struct MyError;`)",
99        ));
100    }
101    if !input.generics.params.is_empty() || input.generics.where_clause.is_some() {
102        return Err(syn::Error::new_spanned(
103            &input.ident,
104            "#[resource_error] does not support generics or where-clauses",
105        ));
106    }
107
108    let crate_path = resolve_crate_path(gts_lit)?;
109
110    let vis = &input.vis;
111    let name = &input.ident;
112
113    Ok(quote! {
114        #input
115
116        impl #name {
117            // --- resource_name required ---
118
119            #vis fn not_found(detail: impl Into<String>)
120                -> #crate_path::ResourceErrorBuilder<
121                    #crate_path::builder::ResourceMissing,
122                    #crate_path::builder::NoContext,
123                >
124            {
125                #crate_path::ResourceErrorBuilder::__not_found(#gts_type, detail)
126            }
127
128            #vis fn already_exists(detail: impl Into<String>)
129                -> #crate_path::ResourceErrorBuilder<
130                    #crate_path::builder::ResourceMissing,
131                    #crate_path::builder::NoContext,
132                >
133            {
134                #crate_path::ResourceErrorBuilder::__already_exists(#gts_type, detail)
135            }
136
137            #vis fn data_loss(detail: impl Into<String>)
138                -> #crate_path::ResourceErrorBuilder<
139                    #crate_path::builder::ResourceMissing,
140                    #crate_path::builder::NoContext,
141                >
142            {
143                #crate_path::ResourceErrorBuilder::__data_loss(#gts_type, detail)
144            }
145
146            // --- resource_name optional ---
147
148            #vis fn aborted(detail: impl Into<String>)
149                -> #crate_path::ResourceErrorBuilder<
150                    #crate_path::builder::ResourceOptional,
151                    #crate_path::builder::NeedsReason,
152                >
153            {
154                #crate_path::ResourceErrorBuilder::__aborted(#gts_type, detail)
155            }
156
157            #vis fn unknown(detail: impl Into<String>)
158                -> #crate_path::ResourceErrorBuilder<
159                    #crate_path::builder::ResourceOptional,
160                    #crate_path::builder::NoContext,
161                >
162            {
163                #crate_path::ResourceErrorBuilder::__unknown(#gts_type, detail)
164            }
165
166            #vis fn deadline_exceeded(detail: impl Into<String>)
167                -> #crate_path::ResourceErrorBuilder<
168                    #crate_path::builder::ResourceOptional,
169                    #crate_path::builder::NoContext,
170                >
171            {
172                #crate_path::ResourceErrorBuilder::__deadline_exceeded(#gts_type, detail)
173            }
174
175            // --- resource_name absent ---
176
177            #vis fn permission_denied()
178                -> #crate_path::ResourceErrorBuilder<
179                    #crate_path::builder::ResourceAbsent,
180                    #crate_path::builder::NeedsReason,
181                >
182            {
183                #crate_path::ResourceErrorBuilder::__permission_denied(#gts_type, "You do not have permission to perform this operation")
184            }
185
186            #vis fn unimplemented(detail: impl Into<String>)
187                -> #crate_path::ResourceErrorBuilder<
188                    #crate_path::builder::ResourceOptional,
189                    #crate_path::builder::NoContext,
190                >
191            {
192                #crate_path::ResourceErrorBuilder::__unimplemented(#gts_type, detail)
193            }
194
195            #vis fn cancelled()
196                -> #crate_path::ResourceErrorBuilder<
197                    #crate_path::builder::ResourceAbsent,
198                    #crate_path::builder::NoContext,
199                >
200            {
201                #crate_path::ResourceErrorBuilder::__cancelled(#gts_type, "Operation cancelled by the client")
202            }
203
204            // --- resource_name optional, needs field violations ---
205
206            #vis fn invalid_argument()
207                -> #crate_path::ResourceErrorBuilder<
208                    #crate_path::builder::ResourceOptional,
209                    #crate_path::builder::NeedsFieldViolation,
210                >
211            {
212                #crate_path::ResourceErrorBuilder::__invalid_argument(#gts_type, "Request validation failed")
213            }
214
215            #vis fn out_of_range(detail: impl Into<String>)
216                -> #crate_path::ResourceErrorBuilder<
217                    #crate_path::builder::ResourceOptional,
218                    #crate_path::builder::NeedsFieldViolation,
219                >
220            {
221                #crate_path::ResourceErrorBuilder::__out_of_range(#gts_type, detail)
222            }
223
224            // --- resource_name optional, needs quota violations ---
225
226            #vis fn resource_exhausted(detail: impl Into<String>)
227                -> #crate_path::ResourceErrorBuilder<
228                    #crate_path::builder::ResourceOptional,
229                    #crate_path::builder::NeedsQuotaViolation,
230                >
231            {
232                #crate_path::ResourceErrorBuilder::__resource_exhausted(#gts_type, detail)
233            }
234
235            // --- resource_name optional, needs precondition violations ---
236
237            #vis fn failed_precondition()
238                -> #crate_path::ResourceErrorBuilder<
239                    #crate_path::builder::ResourceOptional,
240                    #crate_path::builder::NeedsPreconditionViolation,
241                >
242            {
243                #crate_path::ResourceErrorBuilder::__failed_precondition(#gts_type, "Operation precondition not met")
244            }
245        }
246    })
247}
248
249/// Validates a GTS resource-type literal at proc-macro time.
250///
251/// Expected format: `gts.<vendor>.<package>.<namespace>.<type>.<version>~`
252fn validate_gts_resource_type_str(s: &str, span: Span) -> syn::Result<()> {
253    let b = s.as_bytes();
254    let len = b.len();
255
256    if len == 0 {
257        return Err(syn::Error::new(span, "GTS resource type must not be empty"));
258    }
259
260    if b[len - 1] != b'~' {
261        return Err(syn::Error::new(span, "GTS resource type must end with '~'"));
262    }
263
264    #[allow(unknown_lints)]
265    #[allow(de0901_gts_string_pattern)]
266    if len < 6 || !s.starts_with("gts.") {
267        return Err(syn::Error::new(
268            span,
269            "GTS resource type must start with 'gts.'",
270        ));
271    }
272
273    let body = &s[4..len - 1];
274    if body.is_empty() {
275        return Err(syn::Error::new(
276            span,
277            "GTS resource type must have segments after 'gts.' prefix",
278        ));
279    }
280
281    let segments: Vec<&str> = body.split('.').collect();
282
283    for seg in &segments {
284        if seg.is_empty() {
285            return Err(syn::Error::new(
286                span,
287                "GTS resource type contains an empty segment",
288            ));
289        }
290        if !seg
291            .bytes()
292            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
293        {
294            return Err(syn::Error::new(
295                span,
296                "GTS resource type segments must contain only lowercase ASCII letters, digits, or underscores",
297            ));
298        }
299    }
300
301    // Need >= 5 segments: vendor.package.namespace.type.version
302    if segments.len() < 5 {
303        return Err(syn::Error::new(
304            span,
305            "GTS resource type must have at least 5 segments after 'gts.': vendor.package.namespace.type.version",
306        ));
307    }
308
309    // Version segment validation
310    // SAFETY: segments.len() >= 5 is checked above, so `.last()` is always `Some`.
311    let Some(version) = segments.last() else {
312        unreachable!()
313    };
314    if !version.starts_with('v') || version.len() < 2 {
315        return Err(syn::Error::new(
316            span,
317            "GTS resource type must end with a version segment starting with 'v' (e.g. v1)",
318        ));
319    }
320    if !version[1..].bytes().all(|c| c.is_ascii_digit()) {
321        return Err(syn::Error::new(
322            span,
323            "GTS resource type version segment after 'v' must contain only ASCII digits",
324        ));
325    }
326
327    Ok(())
328}