modkit_canonical_errors_macro/
lib.rs1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2use proc_macro::TokenStream;
7use proc_macro2::{Span, TokenStream as TokenStream2};
8use quote::quote;
9use syn::LitStr;
10use syn::parse_macro_input;
11
12#[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(>s_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
44fn 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 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 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(>s_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 #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 #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 #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 #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 #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 #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
249fn 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 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 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}