Skip to main content

tracing_wide_macros/
lib.rs

1//! Proc macros for tracing-wide.
2//!
3//! - `event!(Foo { a, b })` — constructs a `Message`, dispatches it to registered
4//!   subscribers, then records it to tracing.
5//! - `#[message(msg = "...")]` — marks a struct as an emittable message:
6//!   implements `Message`/`MessageBehaviour`, fills the inherent consts,
7//!   generates the recording, and asserts every field type implements `Field`.
8
9use proc_macro::TokenStream;
10use proc_macro2::TokenStream as TokenStream2;
11use quote::{ToTokens, format_ident, quote, quote_spanned};
12use syn::{
13    Attribute, Data, DeriveInput, Error, Expr, ExprLit, Fields, Ident, Lit, LitStr, Meta,
14    MetaNameValue, Token, Type, parse::Parser, parse_macro_input, punctuated::Punctuated,
15};
16
17/// A `#[deprecated]` harvested from a `#[message]` struct or one of its
18/// fields: `None` when absent, `Some("true")` when no reason is given,
19/// otherwise the reason — `since` is not captured. rustc validates and
20/// enforces the attribute itself (producers warn natively), so harvesting is
21/// best-effort presence + reason. In tokens position it renders as the
22/// `Option<&'static str>` a descriptor's `deprecated` field expects.
23struct Deprecation(Option<String>);
24
25/// `///` doc comments harvested from a struct or field. In tokens position it
26/// renders as the `Option<&'static str>` a descriptor's `doc` field expects.
27struct Docs(Option<String>);
28
29/// One named field of a `#[message]` struct — everything the expansion needs
30/// from it: the catalogue descriptor data, the recording ident, the ambient
31/// join candidacy, and the type for the `Field` assertion. In tokens position
32/// it renders as its catalogue `FieldDescriptor` literal.
33struct MessageField {
34    deprecated: Deprecation,
35    doc: Docs,
36    ident: Ident,
37    meta: MetaPairs,
38    ty: Type,
39}
40
41/// Arbitrary `key = <literal>` metadata pairs (message- or field-level). In
42/// tokens position it renders as the `&[(&str, &str)]` slice literal a
43/// descriptor's `meta` field expects.
44#[derive(Default)]
45struct MetaPairs(Vec<(String, String)>);
46
47/// The `tags = [...]` routing labels, sorted + deduped + lowercased at
48/// expansion so the emitted `&[&str]` is canonical and cheap to compare. In
49/// tokens position it renders as the slice literal `&[ "a", "b" ]`.
50#[derive(Default)]
51struct Tags(Vec<String>);
52
53impl Deprecation {
54    /// The reason from the first `#[deprecated]` attribute, if any: handles
55    /// the bare, `= "reason"`, and `(since = ..., note = ...)` forms; a form
56    /// without a reason yields `"true"`.
57    fn harvest(attrs: &[Attribute]) -> Self {
58        let reason = |a: &Attribute| {
59            let reason = match &a.meta {
60                Meta::Path(_) => String::new(),
61                Meta::NameValue(nv) => match &nv.value {
62                    Expr::Lit(ExprLit {
63                        lit: Lit::Str(s), ..
64                    }) => s.value(),
65                    _ => String::new(),
66                },
67                Meta::List(_) => {
68                    let mut note = String::new();
69                    let _ = a.parse_nested_meta(|m| {
70                        let s: LitStr = m.value()?.parse()?;
71                        if m.path.is_ident("note") {
72                            note = s.value();
73                        }
74                        Ok(())
75                    });
76                    note
77                }
78            };
79
80            if reason.is_empty() {
81                "true".to_string()
82            } else {
83                reason
84            }
85        };
86
87        Deprecation(
88            attrs
89                .iter()
90                .find(|a| a.path().is_ident("deprecated"))
91                .map(reason),
92        )
93    }
94}
95
96impl Docs {
97    /// Concatenate `#[doc = "..."]` (i.e. `///`) lines into one string, or `None`.
98    fn harvest(attrs: &[Attribute]) -> Self {
99        let lines: Vec<String> = attrs
100            .iter()
101            .filter(|a| a.path().is_ident("doc"))
102            .filter_map(|a| match &a.meta {
103                Meta::NameValue(nv) => match &nv.value {
104                    Expr::Lit(ExprLit {
105                        lit: Lit::Str(s), ..
106                    }) => Some(s.value().trim().to_string()),
107                    _ => None,
108                },
109                _ => None,
110            })
111            .collect();
112
113        Docs((!lines.is_empty()).then(|| lines.join("\n")))
114    }
115}
116
117impl MessageField {
118    /// The `T` if the field is syntactically `Option<T>` (also
119    /// `option::Option<T>` etc.) — syntactic, like every derive that
120    /// special-cases `Option`. Drives which fields the ambient join may fill.
121    fn ambient_inner(&self) -> Option<&Type> {
122        let Type::Path(tp) = &self.ty else {
123            return None;
124        };
125
126        if tp.qself.is_some() {
127            return None;
128        }
129
130        let seg = tp.path.segments.last()?;
131
132        if seg.ident != "Option" {
133            return None;
134        }
135
136        let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
137            return None;
138        };
139
140        if args.args.len() != 1 {
141            return None;
142        }
143
144        match args.args.first()? {
145            syn::GenericArgument::Type(t) => Some(t),
146            _ => None,
147        }
148    }
149
150    fn parse(field: &syn::Field) -> syn::Result<Self> {
151        let ident = field.ident.clone().expect("named field");
152
153        // tracing's macros merge a field named `message` into the event's
154        // message text, silently losing the key.
155        if ident == "message" {
156            return Err(Error::new_spanned(
157                &ident,
158                "a message field must not be named `message`; \
159                 tracing reserves it for the event text",
160            ));
161        }
162
163        let mut meta = MetaPairs::default();
164
165        for a in &field.attrs {
166            if a.path().is_ident("field") {
167                let kvs =
168                    a.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)?;
169
170                for kv in &kvs {
171                    meta.push(kv)?;
172                }
173            }
174        }
175
176        Ok(MessageField {
177            deprecated: Deprecation::harvest(&field.attrs),
178            doc: Docs::harvest(&field.attrs),
179            ident,
180            meta,
181            ty: field.ty.clone(),
182        })
183    }
184}
185
186impl MetaPairs {
187    /// Accept one `key = <literal>` pair: the key must be an identifier, the
188    /// value a literal (str/int/bool/float) — stringified, so the catalogue
189    /// stores one uniform type.
190    fn push(&mut self, kv: &MetaNameValue) -> syn::Result<()> {
191        let Some(key) = kv.path.get_ident() else {
192            return Err(Error::new_spanned(
193                &kv.path,
194                "metadata keys must be identifiers",
195            ));
196        };
197
198        if self.0.iter().any(|(k, _)| key == k) {
199            return Err(Error::new_spanned(
200                key,
201                format!("duplicate metadata key `{key}`"),
202            ));
203        }
204
205        let value = match &kv.value {
206            Expr::Lit(ExprLit {
207                lit: Lit::Str(s), ..
208            }) => s.value(),
209            Expr::Lit(ExprLit {
210                lit: Lit::Int(i), ..
211            }) => i.base10_digits().to_string(),
212            Expr::Lit(ExprLit {
213                lit: Lit::Float(f), ..
214            }) => f.base10_digits().to_string(),
215            Expr::Lit(ExprLit {
216                lit: Lit::Bool(b), ..
217            }) => b.value.to_string(),
218            other => {
219                return Err(Error::new_spanned(
220                    other,
221                    "metadata values must be literals (str/int/bool/float)",
222                ));
223            }
224        };
225
226        self.0.push((key.to_string(), value));
227
228        Ok(())
229    }
230}
231
232impl Tags {
233    /// Parse `["a", "b", …]`: each element a non-empty, lowercase string
234    /// literal. Crate-prefix namespacing is deliberately *not* enforced —
235    /// `Origin` already carries the originating crate.
236    fn parse(value: &Expr) -> syn::Result<Self> {
237        let Expr::Array(array) = value else {
238            return Err(Error::new_spanned(
239                value,
240                "`tags` must be an array of string literals, e.g. `tags = [\"analytics\"]`",
241            ));
242        };
243
244        let mut tags = Vec::with_capacity(array.elems.len());
245
246        for elem in &array.elems {
247            let Expr::Lit(ExprLit {
248                lit: Lit::Str(s), ..
249            }) = elem
250            else {
251                return Err(Error::new_spanned(
252                    elem,
253                    "each tag must be a string literal",
254                ));
255            };
256
257            let tag = s.value();
258
259            if tag.is_empty() {
260                return Err(Error::new_spanned(s, "a tag must not be empty"));
261            }
262
263            if tag != tag.to_lowercase() {
264                return Err(Error::new_spanned(
265                    s,
266                    format!("tags must be lowercase; use `{}`", tag.to_lowercase()),
267                ));
268            }
269
270            tags.push(tag);
271        }
272
273        tags.sort_unstable();
274        tags.dedup();
275
276        Ok(Tags(tags))
277    }
278}
279
280impl ToTokens for Deprecation {
281    fn to_tokens(&self, tokens: &mut TokenStream2) {
282        tokens.extend(option_str(&self.0));
283    }
284}
285
286impl ToTokens for Docs {
287    fn to_tokens(&self, tokens: &mut TokenStream2) {
288        tokens.extend(option_str(&self.0));
289    }
290}
291
292impl ToTokens for MessageField {
293    fn to_tokens(&self, tokens: &mut TokenStream2) {
294        let MessageField {
295            deprecated,
296            doc,
297            ident,
298            meta,
299            ty,
300        } = self;
301
302        tokens.extend(quote! {
303            ::tracing_wide::catalogue::FieldDescriptor {
304                deprecated: #deprecated,
305                doc: #doc,
306                meta: #meta,
307                name: ::core::stringify!(#ident),
308                r#type: ::core::stringify!(#ty),
309            }
310        });
311    }
312}
313
314impl ToTokens for MetaPairs {
315    fn to_tokens(&self, tokens: &mut TokenStream2) {
316        let keys = self.0.iter().map(|(k, _)| k);
317        let vals = self.0.iter().map(|(_, v)| v);
318        tokens.extend(quote! { &[ #( (#keys, #vals) ),* ] });
319    }
320}
321
322impl ToTokens for Tags {
323    fn to_tokens(&self, tokens: &mut TokenStream2) {
324        let tags = self.0.iter();
325
326        tokens.extend(quote! { &[ #( #tags ),* ] });
327    }
328}
329
330/// Function-like macro: construct a `Message` and record it.
331///
332/// `event!(Started { service, attempt })` evaluates the expression (typically a
333/// struct literal whose field shorthand pulls in locals and arguments in scope),
334/// checks it is a `Message`, and records it to the current tracing subscriber.
335/// For spans, use `tracing::instrument` — tracing-wide deliberately ships no
336/// span macro of its own.
337#[proc_macro]
338pub fn event(input: TokenStream) -> TokenStream {
339    let expr = parse_macro_input!(input as Expr);
340
341    quote! {{
342        // `mut` is only exercised by the ambient join shim.
343        #[allow(unused_mut)]
344        let mut __tracing_wide_msg = #expr;
345        ::tracing_wide::__private::MessageBehaviour::join_ambient(&mut __tracing_wide_msg);
346        ::tracing_wide::__private::MessageBehaviour::emit(&__tracing_wide_msg);
347    }}
348    .into()
349}
350
351/// The `#[message]` expansion in `proc_macro2` terms, so it is unit-testable
352/// and its output is pretty-printable.
353fn expand_message(attr: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
354    let mut input: DeriveInput = syn::parse2(item)?;
355    let name = input.ident.clone();
356
357    // The generated impls and the catalogue's `TypeId::of` need one concrete
358    // `'static` type; reject generics up front instead of leaking E0107s from
359    // the expansion.
360    if !input.generics.params.is_empty() {
361        return Err(Error::new_spanned(
362            &input.generics,
363            "#[message] does not support generic parameters \
364             (a message must be a concrete `'static` type)",
365        ));
366    }
367
368    let mut msg: Option<String> = None;
369    let mut level: Option<String> = None;
370    let mut tags = Tags::default();
371    let mut msg_meta = MetaPairs::default();
372
373    let metas = Punctuated::<Meta, Token![,]>::parse_terminated.parse2(attr)?;
374
375    for m in metas {
376        match m {
377            Meta::Path(p) => {
378                return Err(Error::new_spanned(
379                    &p,
380                    "expected `key = value`; `#[message]` takes no bare flags \
381                     (serialization is enabled by `#[derive(Serialize)]`)",
382                ));
383            }
384            Meta::NameValue(nv) if nv.path.is_ident("msg") => {
385                if let Expr::Lit(ExprLit {
386                    lit: Lit::Str(s), ..
387                }) = &nv.value
388                {
389                    msg = Some(s.value());
390                } else {
391                    return Err(Error::new_spanned(
392                        &nv.value,
393                        "`msg` must be a string literal",
394                    ));
395                }
396            }
397            Meta::NameValue(nv) if nv.path.is_ident("level") => {
398                let lvl = match &nv.value {
399                    Expr::Path(p) if p.path.get_ident().is_some() => {
400                        p.path.get_ident().unwrap().to_string()
401                    }
402                    Expr::Lit(ExprLit {
403                        lit: Lit::Str(s), ..
404                    }) => s.value(),
405                    _ => {
406                        return Err(Error::new_spanned(
407                            &nv.value,
408                            "`level` must be one of trace/debug/info/warn/error",
409                        ));
410                    }
411                };
412                level = Some(lvl);
413            }
414            Meta::NameValue(nv) if nv.path.is_ident("tags") => {
415                tags = Tags::parse(&nv.value)?;
416            }
417            Meta::NameValue(nv) => msg_meta.push(&nv)?,
418            Meta::List(l) => {
419                return Err(Error::new_spanned(
420                    &l.path,
421                    "expected `key = value` or a bare flag, not a list",
422                ));
423            }
424        }
425    }
426    let msg = msg.unwrap_or_else(|| name.to_string());
427
428    // tracing's macros treat the trailing literal as a format string; escape
429    // braces so a `msg` containing `{`/`}` records verbatim instead of
430    // triggering format-argument capture (rendering un-escapes them, so the
431    // recorded text still matches `MSG`).
432    let msg_record = msg.replace('{', "{{").replace('}', "}}");
433
434    let msg_doc = Docs::harvest(&input.attrs);
435    let msg_deprecation = Deprecation::harvest(&input.attrs);
436
437    // The deprecation warning belongs at producer construction sites; exempt
438    // the generated impls, which must keep naming the type.
439    let allow_deprecated = if msg_deprecation.0.is_some() {
440        quote! { #[allow(deprecated)] }
441    } else {
442        quote! {}
443    };
444
445    let (level_const, level_macro) = match level.as_deref().unwrap_or("info") {
446        "trace" => (format_ident!("TRACE"), format_ident!("trace")),
447        "debug" => (format_ident!("DEBUG"), format_ident!("debug")),
448        "info" => (format_ident!("INFO"), format_ident!("info")),
449        "warn" => (format_ident!("WARN"), format_ident!("warn")),
450        "error" => (format_ident!("ERROR"), format_ident!("error")),
451        other => {
452            return Err(Error::new_spanned(
453                &name,
454                format!("unknown level `{other}` (expected trace/debug/info/warn/error)"),
455            ));
456        }
457    };
458
459    let fields = match &input.data {
460        Data::Struct(s) => match &s.fields {
461            Fields::Named(named) => named
462                .named
463                .iter()
464                .map(MessageField::parse)
465                .collect::<syn::Result<Vec<_>>>()?,
466            _ => {
467                return Err(Error::new_spanned(
468                    &name,
469                    "#[message] requires named fields",
470                ));
471            }
472        },
473        _ => {
474            return Err(Error::new_spanned(
475                &name,
476                "#[message] can only be applied to structs",
477            ));
478        }
479    };
480
481    // `#[field(...)]` is a helper attribute the compiler doesn't know; strip it
482    // before re-emitting the struct.
483    if let Data::Struct(s) = &mut input.data
484        && let Fields::Named(named) = &mut s.fields
485    {
486        for f in &mut named.named {
487            f.attrs.retain(|a| !a.path().is_ident("field"));
488        }
489    }
490
491    let idents: Vec<&Ident> = fields.iter().map(|f| &f.ident).collect();
492
493    let types: Vec<&Type> = fields.iter().map(|f| &f.ty).collect();
494
495    let ambient = fields.iter().filter_map(|f| {
496        f.ambient_inner().map(|inner| {
497            let ident = &f.ident;
498            quote! { (#ident, #inner) }
499        })
500    });
501
502    // Location builtins at the struct's span resolve to the definition site,
503    // filled by rustc while compiling the defining crate (the proc-macro can't
504    // read spans on stable).
505    let origin = quote_spanned! { name.span() =>
506        ::tracing_wide::Origin {
507            column: ::core::column!(),
508            file: ::core::file!(),
509            krate: ::core::env!("CARGO_PKG_NAME"),
510            line: ::core::line!(),
511            module: ::core::module_path!(),
512        }
513    };
514
515    Ok(quote! {
516        #input
517
518        #allow_deprecated
519        impl #name {
520            /// Severity of this event type (drives the tracing macro below).
521            pub const LEVEL: ::tracing_wide::__private::tracing::Level =
522                ::tracing_wide::__private::tracing::Level::#level_const;
523
524            /// Constant, static message text for this event type.
525            pub const MSG: &'static str = #msg;
526
527            /// Where this event type is defined — automatic provenance.
528            pub const ORIGIN: ::tracing_wide::Origin = #origin;
529
530            /// Sorted, deduped, lowercased routing tags for this event type.
531            pub const TAGS: &'static [&'static str] = #tags;
532        }
533
534        #allow_deprecated
535        impl ::tracing_wide::Message for #name {
536            fn as_any(&self) -> &dyn ::core::any::Any { self }
537            ::tracing_wide::__message_facet_method! {}
538            ::tracing_wide::__message_serialize_method! {}
539            fn level(&self) -> ::tracing_wide::__private::tracing::Level { Self::LEVEL }
540            fn msg(&self) -> &'static str { Self::MSG }
541            fn origin(&self) -> &'static ::tracing_wide::Origin { &Self::ORIGIN }
542            fn tags(&self) -> &'static [&'static str] { Self::TAGS }
543        }
544
545        #[doc(hidden)]
546        #allow_deprecated
547        impl ::tracing_wide::__private::Sealed for #name {}
548
549        ::tracing_wide::__register_message! {
550            ::tracing_wide::catalogue::MessageDescriptor {
551                deprecated: #msg_deprecation,
552                doc: #msg_doc,
553                fields: &[ #( #fields ),* ],
554                level: ::tracing_wide::catalogue::LevelName::#level_const,
555                meta: #msg_meta,
556                msg: #msg,
557                origin: #name::ORIGIN,
558                tags: #name::TAGS,
559                type_id: ::core::any::TypeId::of::<#name>(),
560            }
561        }
562
563        #[doc(hidden)]
564        #allow_deprecated
565        impl ::tracing_wide::__private::MessageBehaviour for #name {
566            ::tracing_wide::__message_ambient_method! {
567                #( #ambient ),*
568            }
569
570            // Deprecated fields keep recording (consumers mid-migration still
571            // read them); only the producer's construction should warn.
572            #[allow(deprecated)]
573            fn record(&self) {
574                ::tracing_wide::__private::tracing::#level_macro!( #( #idents = &self.#idents, )* #msg_record );
575            }
576        }
577
578        const _: fn() = || {
579            fn __tracing_wide_assert_field<T: ::tracing_wide::Field>() {}
580            #( __tracing_wide_assert_field::<#types>(); )*
581        };
582    })
583}
584
585/// Attribute macro: mark a struct as a `Message`.
586///
587/// Implements the (pseudo-sealed) `Message`/`MessageBehaviour` traits, fills
588/// the inherent `MSG` const from `#[message(msg = "...")]` (defaulting to the
589/// struct name), generates the tracing recording, and asserts every field type
590/// is a `Field`. The point is to keep the message static and put all variance
591/// in the typed fields.
592///
593/// A struct-level `#[deprecated]` is honored like a field-level one: producers
594/// get rustc's native warning, the note lands in the catalogue descriptor, and
595/// the generated impls are exempted.
596#[proc_macro_attribute]
597pub fn message(attr: TokenStream, item: TokenStream) -> TokenStream {
598    expand_message(attr.into(), item.into())
599        .unwrap_or_else(Error::into_compile_error)
600        .into()
601}
602
603/// Render an `Option<String>` as `Option<&'static str>` tokens — the shared
604/// shape of the descriptors' `doc` and `deprecated` fields.
605fn option_str(value: &Option<String>) -> TokenStream2 {
606    match value {
607        Some(s) => quote! { ::core::option::Option::Some(#s) },
608        None => quote! { ::core::option::Option::None },
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use quote::quote;
616
617    #[test]
618    fn message_expansion_pretty_prints() {
619        let attr = quote! { msg = "hi", level = warn, owner = "x" };
620        let item = quote! {
621            /// A demo event.
622            struct Demo {
623                a: usize,
624                #[field(unit = "ms")]
625                b: usize,
626            }
627        };
628        let expanded = expand_message(attr, item).expect("expansion succeeds");
629        let file = syn::parse2::<syn::File>(expanded).expect("output is valid Rust");
630        let pretty = prettyplease::unparse(&file);
631
632        assert!(pretty.contains("impl ::tracing_wide::Message for Demo"));
633        assert!(pretty.contains("pub const MSG"));
634        println!("{pretty}");
635    }
636
637    #[test]
638    fn message_harvests_deprecation() {
639        let pretty = |item: TokenStream2| {
640            let expanded = expand_message(quote! { msg = "m" }, item).expect("expansion succeeds");
641            prettyplease::unparse(&syn::parse2::<syn::File>(expanded).unwrap())
642        };
643
644        let noted = pretty(quote! { #[deprecated = "use `n`"] struct M { a: usize } });
645        assert!(noted.contains(r#"Some("use `n`")"#), "{noted}");
646        assert!(noted.contains("#[allow(deprecated)]\nimpl"), "{noted}");
647
648        let bare = pretty(quote! { #[deprecated] struct M { a: usize } });
649        assert!(bare.contains(r#"Some("true")"#), "{bare}");
650
651        let meta =
652            pretty(quote! { #[deprecated(since = "0.2", note = "gone")] struct M { a: usize } });
653        assert!(meta.contains(r#"Some("gone")"#), "{meta}");
654
655        let field = pretty(quote! { struct M { #[deprecated = "old"] a: usize } });
656        assert!(field.contains(r#"Some("old")"#), "{field}");
657        assert!(!field.contains("#[allow(deprecated)]\nimpl"), "{field}");
658
659        // Macro-invocation contents print as raw tokens (spaced); normalize
660        // whitespace before matching.
661        let plain = pretty(quote! { struct M { a: usize } });
662        let flat: String = plain.chars().filter(|c| !c.is_whitespace()).collect();
663        assert!(
664            flat.contains("deprecated:::core::option::Option::None"),
665            "{plain}"
666        );
667        assert!(!plain.contains("#[allow(deprecated)]\nimpl"), "{plain}");
668    }
669
670    #[test]
671    fn message_rejects_non_literal_meta() {
672        let err = expand_message(
673            quote! { owner = some_path },
674            quote! { struct M { a: usize } },
675        )
676        .unwrap_err();
677        assert!(err.to_string().contains("must be literals"));
678    }
679
680    #[test]
681    fn message_rejects_bare_flag() {
682        let err =
683            expand_message(quote! { serialize }, quote! { struct M { a: usize } }).unwrap_err();
684        assert!(err.to_string().contains("takes no bare flags"));
685    }
686
687    #[test]
688    fn message_rejects_non_struct() {
689        let err = expand_message(quote! {}, quote! { enum E { A } }).unwrap_err();
690        assert!(err.to_string().contains("can only be applied to structs"));
691    }
692
693    #[test]
694    fn message_rejects_unnamed_fields() {
695        let err = expand_message(quote! {}, quote! { struct T(usize); }).unwrap_err();
696        assert!(err.to_string().contains("requires named fields"));
697    }
698
699    #[test]
700    fn message_rejects_meta_list() {
701        let err =
702            expand_message(quote! { owner(x) }, quote! { struct M { a: usize } }).unwrap_err();
703        assert!(err.to_string().contains("not a list"));
704    }
705
706    #[test]
707    fn message_rejects_duplicate_meta_key() {
708        let err = expand_message(
709            quote! { owner = "a", owner = "b" },
710            quote! { struct M { a: usize } },
711        )
712        .unwrap_err();
713        assert!(err.to_string().contains("duplicate metadata key `owner`"));
714
715        let err = expand_message(
716            quote! {},
717            quote! { struct M { #[field(unit = "ms", unit = "s")] a: usize } },
718        )
719        .unwrap_err();
720        assert!(err.to_string().contains("duplicate metadata key `unit`"));
721    }
722
723    #[test]
724    fn message_rejects_field_named_message() {
725        let err = expand_message(quote! {}, quote! { struct M { message: usize } }).unwrap_err();
726        assert!(err.to_string().contains("must not be named `message`"));
727    }
728
729    #[test]
730    fn message_rejects_generic_params() {
731        let cases = [
732            quote! { struct M<T> { a: T } },
733            quote! { struct M<'a> { a: &'a str } },
734            quote! { struct M<const N: usize> { a: usize } },
735        ];
736
737        for item in cases {
738            let err = expand_message(quote! {}, item).unwrap_err();
739            assert!(err.to_string().contains("generic parameters"));
740        }
741    }
742
743    #[test]
744    fn message_escapes_braces_in_recorded_msg() {
745        let expanded = expand_message(
746            quote! { msg = "rate {limit} hit" },
747            quote! { struct M { a: usize } },
748        )
749        .expect("expansion succeeds");
750        let pretty = prettyplease::unparse(&syn::parse2::<syn::File>(expanded).unwrap());
751
752        // The const keeps the text verbatim; only the tracing handoff (a
753        // format string position) sees the escaped form.
754        assert!(pretty.contains(r#""rate {limit} hit""#), "{pretty}");
755        assert!(pretty.contains(r#""rate {{limit}} hit""#), "{pretty}");
756    }
757
758    #[test]
759    fn message_rejects_non_ident_meta_key() {
760        let err =
761            expand_message(quote! { foo::bar = 1 }, quote! { struct M { a: usize } }).unwrap_err();
762        assert!(err.to_string().contains("must be identifiers"));
763    }
764
765    #[test]
766    fn message_rejects_non_string_msg() {
767        let err = expand_message(quote! { msg = 5 }, quote! { struct M { a: usize } }).unwrap_err();
768        assert!(err.to_string().contains("must be a string literal"));
769    }
770
771    #[test]
772    fn message_rejects_unknown_level() {
773        let err =
774            expand_message(quote! { level = bogus }, quote! { struct M { a: usize } }).unwrap_err();
775        assert!(err.to_string().contains("unknown level"));
776    }
777
778    #[test]
779    fn message_sorts_and_dedups_tags() {
780        let expanded = expand_message(
781            quote! { msg = "m", tags = ["b", "a", "a"] },
782            quote! { struct M { x: usize } },
783        )
784        .expect("expansion succeeds");
785        let file = syn::parse2::<syn::File>(expanded).expect("output is valid Rust");
786        let pretty = prettyplease::unparse(&file);
787        assert!(pretty.contains("pub const TAGS"));
788        assert!(
789            pretty.contains(r#"["a", "b"]"#),
790            "tags sorted+deduped: {pretty}"
791        );
792    }
793
794    #[test]
795    fn message_emits_origin_const() {
796        let expanded =
797            expand_message(quote! { msg = "m" }, quote! { struct M { x: usize } }).unwrap();
798        let pretty = prettyplease::unparse(&syn::parse2::<syn::File>(expanded).unwrap());
799        assert!(pretty.contains("pub const ORIGIN"));
800        assert!(pretty.contains("CARGO_PKG_NAME"));
801        assert!(pretty.contains("pub const TAGS"));
802    }
803
804    #[test]
805    fn message_rejects_non_lowercase_tag() {
806        let err = expand_message(
807            quote! { tags = ["Security"] },
808            quote! { struct M { a: usize } },
809        )
810        .unwrap_err();
811        let msg = err.to_string();
812        assert!(msg.contains("lowercase"), "{msg}");
813        assert!(msg.contains("security"), "{msg}");
814    }
815
816    #[test]
817    fn message_rejects_empty_tag() {
818        let err =
819            expand_message(quote! { tags = [""] }, quote! { struct M { a: usize } }).unwrap_err();
820        assert!(err.to_string().contains("must not be empty"));
821    }
822
823    #[test]
824    fn message_rejects_non_string_tag() {
825        let err =
826            expand_message(quote! { tags = [1] }, quote! { struct M { a: usize } }).unwrap_err();
827        assert!(err.to_string().contains("string literal"));
828    }
829
830    #[test]
831    fn message_rejects_non_array_tags() {
832        let err = expand_message(
833            quote! { tags = "security" },
834            quote! { struct M { a: usize } },
835        )
836        .unwrap_err();
837        assert!(err.to_string().contains("array of string literals"));
838    }
839}