Skip to main content

rok_config_macros/
lib.rs

1//! Proc-macro internals for `rok-core::config`.
2//!
3//! Do not use this crate directly — import `rok-core` with `features = ["config"]` instead.
4
5use proc_macro::TokenStream;
6use proc_macro2::TokenStream as TokenStream2;
7use quote::quote;
8use syn::{
9    parse::{Parse, ParseStream},
10    parse_macro_input, Attribute, Data, DeriveInput, Fields, Ident, Lit, LitStr, Meta, Token, Type,
11};
12
13// ── field type classification ─────────────────────────────────────────────────
14
15#[derive(Debug, Clone)]
16enum FieldKind {
17    Str,
18    Bool,
19    Num(proc_macro2::TokenStream), // token stream of the type for parse::<T>()
20    OptStr,
21    OptBool,
22    OptNum(proc_macro2::TokenStream),
23    Other,
24}
25
26fn classify(ty: &Type) -> FieldKind {
27    let Type::Path(tp) = ty else {
28        return FieldKind::Other;
29    };
30    let segs = &tp.path.segments;
31    if segs.is_empty() {
32        return FieldKind::Other;
33    }
34    let last = segs.last().unwrap();
35    let name = last.ident.to_string();
36
37    match name.as_str() {
38        "String" => FieldKind::Str,
39        "bool" => FieldKind::Bool,
40        n @ ("u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128"
41        | "f32" | "f64" | "usize" | "isize") => {
42            let ident = Ident::new(n, proc_macro2::Span::call_site());
43            FieldKind::Num(quote! { #ident })
44        }
45        "Option" => {
46            if let syn::PathArguments::AngleBracketed(ab) = &last.arguments {
47                if let Some(syn::GenericArgument::Type(inner)) = ab.args.first() {
48                    return match classify(inner) {
49                        FieldKind::Str => FieldKind::OptStr,
50                        FieldKind::Bool => FieldKind::OptBool,
51                        FieldKind::Num(t) => FieldKind::OptNum(t),
52                        _ => FieldKind::Other,
53                    };
54                }
55            }
56            FieldKind::Other
57        }
58        _ => FieldKind::Other,
59    }
60}
61
62// ── #[env("VAR", default = value)] attribute ──────────────────────────────────
63
64struct EnvAttr {
65    var_name: LitStr,
66    default: Option<Lit>,
67}
68
69impl Parse for EnvAttr {
70    fn parse(input: ParseStream) -> syn::Result<Self> {
71        let var_name: LitStr = input.parse()?;
72        let default = if input.peek(Token![,]) {
73            input.parse::<Token![,]>()?;
74            let key: Ident = input.parse()?;
75            if key != "default" {
76                return Err(syn::Error::new(key.span(), "expected `default`"));
77            }
78            input.parse::<Token![=]>()?;
79            Some(input.parse::<Lit>()?)
80        } else {
81            None
82        };
83        Ok(EnvAttr { var_name, default })
84    }
85}
86
87// ── derive entry point ────────────────────────────────────────────────────────
88
89/// Derive `rok_config::FromEnv` for a struct, reading each field from an
90/// environment variable declared with `#[env("VAR_NAME")]` or
91/// `#[env("VAR_NAME", default = value)]`.
92///
93/// Fields without a default **must** be set in the environment or the binary
94/// panics with a clear message at startup.
95///
96/// # Example
97///
98/// ```rust,ignore
99/// #[derive(Config)]
100/// pub struct AppConfig {
101///     #[env("APP_NAME", default = "rok-app")]
102///     pub name: String,
103///
104///     #[env("APP_DEBUG", default = false)]
105///     pub debug: bool,
106///
107///     #[env("JWT_SECRET")]    // required — no default
108///     pub jwt_secret: String,
109///
110///     #[env("REDIS_URL")]     // optional Option<String>
111///     pub redis_url: Option<String>,
112/// }
113/// ```
114#[proc_macro_derive(Config, attributes(env))]
115pub fn derive_config(input: TokenStream) -> TokenStream {
116    let input = parse_macro_input!(input as DeriveInput);
117    expand_config(input).unwrap_or_else(|e| e.to_compile_error().into())
118}
119
120fn expand_config(input: DeriveInput) -> syn::Result<TokenStream> {
121    let name = &input.ident;
122
123    let Data::Struct(data) = &input.data else {
124        return Err(syn::Error::new_spanned(
125            &input.ident,
126            "#[derive(Config)] only supports structs",
127        ));
128    };
129    let Fields::Named(fields) = &data.fields else {
130        return Err(syn::Error::new_spanned(
131            &input.ident,
132            "#[derive(Config)] only supports structs with named fields",
133        ));
134    };
135
136    let mut field_inits: Vec<TokenStream2> = Vec::new();
137
138    for field in &fields.named {
139        let field_ident = field.ident.as_ref().unwrap();
140        let kind = classify(&field.ty);
141
142        // Find the #[env(...)] attribute.
143        let env_attr = field
144            .attrs
145            .iter()
146            .find(|a| a.path().is_ident("env"))
147            .ok_or_else(|| {
148                syn::Error::new_spanned(
149                    field_ident,
150                    "each field must have an #[env(\"VAR_NAME\")] attribute",
151                )
152            })?;
153
154        let parsed: EnvAttr = env_attr.parse_args()?;
155        let var_name = &parsed.var_name;
156        let var_str = var_name.value();
157
158        let init = match kind {
159            FieldKind::Str => match &parsed.default {
160                Some(Lit::Str(default)) => quote! {
161                    #field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| #default.to_string()),
162                },
163                None => {
164                    let msg = format!(
165                        "required env var `{var_str}` is not set — add it to .env or the environment"
166                    );
167                    quote! {
168                        #field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg)),
169                    }
170                }
171                Some(other) => {
172                    return Err(syn::Error::new_spanned(
173                        other,
174                        "default for a String field must be a string literal",
175                    ))
176                }
177            },
178
179            FieldKind::Bool => {
180                let default_val = match &parsed.default {
181                    Some(Lit::Bool(b)) => b.value,
182                    None => false,
183                    Some(other) => {
184                        return Err(syn::Error::new_spanned(
185                            other,
186                            "default for a bool field must be `true` or `false`",
187                        ))
188                    }
189                };
190                quote! {
191                    #field_ident: ::std::env::var(#var_name)
192                        .ok()
193                        .and_then(|v| match v.to_lowercase().as_str() {
194                            "true" | "1" | "yes" | "on"  => ::std::option::Option::Some(true),
195                            "false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
196                            _ => ::std::option::Option::None,
197                        })
198                        .unwrap_or(#default_val),
199                }
200            }
201
202            FieldKind::Num(ref ty_tokens) => match &parsed.default {
203                Some(Lit::Int(n)) => quote! {
204                    #field_ident: ::std::env::var(#var_name)
205                        .ok()
206                        .and_then(|v| v.parse::<#ty_tokens>().ok())
207                        .unwrap_or(#n as #ty_tokens),
208                },
209                Some(Lit::Float(f)) => quote! {
210                    #field_ident: ::std::env::var(#var_name)
211                        .ok()
212                        .and_then(|v| v.parse::<#ty_tokens>().ok())
213                        .unwrap_or(#f as #ty_tokens),
214                },
215                None => {
216                    let msg = format!(
217                        "required env var `{var_str}` is not set — add it to .env or the environment"
218                    );
219                    let bad_msg = format!("env var `{var_str}` must be a valid number");
220                    quote! {
221                        #field_ident: {
222                            let __raw = ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg));
223                            __raw.parse::<#ty_tokens>().unwrap_or_else(|_| panic!(#bad_msg))
224                        },
225                    }
226                }
227                Some(other) => {
228                    return Err(syn::Error::new_spanned(
229                        other,
230                        "default for a numeric field must be a numeric literal",
231                    ))
232                }
233            },
234
235            FieldKind::OptStr => quote! {
236                #field_ident: ::std::env::var(#var_name).ok(),
237            },
238
239            FieldKind::OptBool => quote! {
240                #field_ident: ::std::env::var(#var_name)
241                    .ok()
242                    .and_then(|v| match v.to_lowercase().as_str() {
243                        "true" | "1" | "yes" | "on"  => ::std::option::Option::Some(true),
244                        "false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
245                        _ => ::std::option::Option::None,
246                    }),
247            },
248
249            FieldKind::OptNum(ref ty_tokens) => quote! {
250                #field_ident: ::std::env::var(#var_name)
251                    .ok()
252                    .and_then(|v| v.parse::<#ty_tokens>().ok()),
253            },
254
255            FieldKind::Other => {
256                return Err(syn::Error::new_spanned(
257                    &field.ty,
258                    "#[derive(Config)] supports String, bool, numeric types, and their Option<T> wrappers",
259                ))
260            }
261        };
262
263        field_inits.push(init);
264    }
265
266    let expanded = quote! {
267        impl ::rok_core::config::FromEnv for #name {
268            fn from_env() -> Self {
269                Self {
270                    #(#field_inits)*
271                }
272            }
273        }
274
275        impl #name {
276            /// Load this config from environment variables (reads `.env` automatically).
277            pub fn load() -> Self {
278                ::rok_core::config::Config::load::<Self>()
279            }
280        }
281    };
282
283    Ok(expanded.into())
284}
285
286// ── config attribute helpers ──────────────────────────────────────────────────
287
288/// Parse `#[config(prefix = "app")]` from struct-level attributes.
289fn extract_prefix(attrs: &[Attribute]) -> syn::Result<String> {
290    for attr in attrs {
291        if attr.path().is_ident("config") {
292            let meta: Meta = attr.parse_args()?;
293            match &meta {
294                Meta::NameValue(nv) if nv.path.is_ident("prefix") => {
295                    if let syn::Expr::Lit(expr_lit) = &nv.value {
296                        if let Lit::Str(s) = &expr_lit.lit {
297                            return Ok(s.value());
298                        }
299                    }
300                }
301                _ => {
302                    return Err(syn::Error::new_spanned(
303                        &meta,
304                        "expected `#[config(prefix = \"...\")]`",
305                    ))
306                }
307            }
308        }
309    }
310    Err(syn::Error::new(
311        proc_macro2::Span::call_site(),
312        "missing `#[config(prefix = \"app\")]` attribute",
313    ))
314}
315
316// ── RokConfig derive ──────────────────────────────────────────────────────────
317
318/// Derive `rok_core::config::Configurable` for a struct, combining
319/// environment-variable loading (like `#[derive(Config)]`) with a
320/// config-key prefix for file-based discovery.
321///
322/// # Attributes
323///
324/// | Level   | Attribute | Description |
325/// |---------|-----------|-------------|
326/// | Struct  | `#[config(prefix = "app")]` | Config key for `config/app.toml` |
327/// | Field   | `#[env("VAR", default = val)]` | Same as `#[derive(Config)]` |
328///
329/// # Generated
330///
331/// - `impl Configurable` (with `key()` returning the prefix)
332/// - `impl FromEnv` (same as `#[derive(Config)]`)
333/// - `fn load()` — tries `load_config` first, falls back to env
334///
335/// # Example
336///
337/// ```rust,ignore
338/// #[derive(RokConfig)]
339/// #[config(prefix = "auth")]
340/// pub struct AuthConfig {
341///     #[env("JWT_SECRET")]
342///     pub jwt_secret: String,
343///
344///     #[env("JWT_TTL", default = 3600)]
345///     pub jwt_ttl: u64,
346/// }
347/// ```
348#[proc_macro_derive(RokConfig, attributes(config, env))]
349pub fn derive_rok_config(input: TokenStream) -> TokenStream {
350    let input = parse_macro_input!(input as DeriveInput);
351    expand_rok_config(input).unwrap_or_else(|e| e.to_compile_error().into())
352}
353
354fn expand_rok_config(input: DeriveInput) -> syn::Result<TokenStream> {
355    let prefix = extract_prefix(&input.attrs)?;
356    let name = &input.ident;
357    let prefix_str = prefix.clone();
358
359    // Reuse the same field-expansion logic as Config
360    let Data::Struct(data) = &input.data else {
361        return Err(syn::Error::new_spanned(
362            &input.ident,
363            "#[derive(RokConfig)] only supports structs",
364        ));
365    };
366    let Fields::Named(fields) = &data.fields else {
367        return Err(syn::Error::new_spanned(
368            &input.ident,
369            "#[derive(RokConfig)] only supports structs with named fields",
370        ));
371    };
372
373    let mut field_inits: Vec<TokenStream2> = Vec::new();
374
375    for field in &fields.named {
376        let field_ident = field.ident.as_ref().unwrap();
377        let kind = classify(&field.ty);
378
379        let env_attr = field
380            .attrs
381            .iter()
382            .find(|a| a.path().is_ident("env"))
383            .ok_or_else(|| {
384                syn::Error::new_spanned(
385                    field_ident,
386                    "each field must have an #[env(\"VAR_NAME\")] attribute",
387                )
388            })?;
389
390        let parsed: EnvAttr = env_attr.parse_args()?;
391        let var_name = &parsed.var_name;
392        let var_str = var_name.value();
393
394        let init = match kind {
395            FieldKind::Str => match &parsed.default {
396                Some(Lit::Str(default)) => quote! {
397                    #field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| #default.to_string()),
398                },
399                None => {
400                    let msg = format!(
401                        "required env var `{var_str}` is not set — add it to .env or the environment"
402                    );
403                    quote! {
404                        #field_ident: ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg)),
405                    }
406                }
407                Some(other) => {
408                    return Err(syn::Error::new_spanned(
409                        other,
410                        "default for a String field must be a string literal",
411                    ))
412                }
413            },
414
415            FieldKind::Bool => {
416                let default_val = match &parsed.default {
417                    Some(Lit::Bool(b)) => b.value,
418                    None => false,
419                    Some(other) => {
420                        return Err(syn::Error::new_spanned(
421                            other,
422                            "default for a bool field must be `true` or `false`",
423                        ))
424                    }
425                };
426                quote! {
427                    #field_ident: ::std::env::var(#var_name)
428                        .ok()
429                        .and_then(|v| match v.to_lowercase().as_str() {
430                            "true" | "1" | "yes" | "on"  => ::std::option::Option::Some(true),
431                            "false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
432                            _ => ::std::option::Option::None,
433                        })
434                        .unwrap_or(#default_val),
435                }
436            }
437
438            FieldKind::Num(ref ty_tokens) => match &parsed.default {
439                Some(Lit::Int(n)) => quote! {
440                    #field_ident: ::std::env::var(#var_name)
441                        .ok()
442                        .and_then(|v| v.parse::<#ty_tokens>().ok())
443                        .unwrap_or(#n as #ty_tokens),
444                },
445                Some(Lit::Float(f)) => quote! {
446                    #field_ident: ::std::env::var(#var_name)
447                        .ok()
448                        .and_then(|v| v.parse::<#ty_tokens>().ok())
449                        .unwrap_or(#f as #ty_tokens),
450                },
451                None => {
452                    let msg = format!(
453                        "required env var `{var_str}` is not set — add it to .env or the environment"
454                    );
455                    let bad_msg = format!("env var `{var_str}` must be a valid number");
456                    quote! {
457                        #field_ident: {
458                            let __raw = ::std::env::var(#var_name).unwrap_or_else(|_| panic!(#msg));
459                            __raw.parse::<#ty_tokens>().unwrap_or_else(|_| panic!(#bad_msg))
460                        },
461                    }
462                }
463                Some(other) => {
464                    return Err(syn::Error::new_spanned(
465                        other,
466                        "default for a numeric field must be a numeric literal",
467                    ))
468                }
469            },
470
471            FieldKind::OptStr => quote! {
472                #field_ident: ::std::env::var(#var_name).ok(),
473            },
474
475            FieldKind::OptBool => quote! {
476                #field_ident: ::std::env::var(#var_name)
477                    .ok()
478                    .and_then(|v| match v.to_lowercase().as_str() {
479                        "true" | "1" | "yes" | "on"  => ::std::option::Option::Some(true),
480                        "false" | "0" | "no" | "off" => ::std::option::Option::Some(false),
481                        _ => ::std::option::Option::None,
482                    }),
483            },
484
485            FieldKind::OptNum(ref ty_tokens) => quote! {
486                #field_ident: ::std::env::var(#var_name)
487                    .ok()
488                    .and_then(|v| v.parse::<#ty_tokens>().ok()),
489            },
490
491            FieldKind::Other => {
492                return Err(syn::Error::new_spanned(
493                    &field.ty,
494                    "#[derive(RokConfig)] supports String, bool, numeric types, and their Option<T> wrappers",
495                ))
496            }
497        };
498
499        field_inits.push(init);
500    }
501
502    let expanded = quote! {
503        impl ::rok_core::config::Configurable for #name {
504            fn key() -> &'static str {
505                #prefix_str
506            }
507        }
508
509        impl ::rok_core::config::FromEnv for #name {
510            fn from_env() -> Self {
511                Self {
512                    #(#field_inits)*
513                }
514            }
515        }
516
517        impl #name {
518            /// Load this config — tries `config/{key}.toml` first,
519            /// then falls back to environment variables.
520            pub fn load() -> Self {
521                ::rok_core::config::load_config::<Self>()
522                    .unwrap_or_else(|| ::rok_core::config::Config::load::<Self>())
523            }
524        }
525    };
526
527    Ok(expanded.into())
528}