vespera_macro 0.4.0

Procedural macros for Vespera - automatic route discovery and OpenAPI generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
//! Compile-time supplements emitted by `#[derive(Schema)]`.
//!
//! Every `#[derive(Schema)]` invocation produces two extra token
//! streams in addition to the metadata registration:
//!
//! 1. **A marker-trait impl** — `impl ::vespera::Schema for #Ident {}`
//!    (preserving the struct's generics).  The `Schema` trait is an
//!    intentionally empty marker defined in `vespera::lib.rs`; it
//!    coexists with the `vespera::Schema` derive macro because traits
//!    and macros live in separate namespaces.  This anchor lets the
//!    leaf-type assertions below resolve their `T: ::vespera::Schema`
//!    bound for every type that opted in via `#[derive(Schema)]`,
//!    whether the type lives in the user crate or in an external crate
//!    that the OpenAPI generator does not know about.
//!
//! 2. **Per-field leaf-type assertions** — for each field whose leaf
//!    type is NOT a recognized OpenAPI builtin (per
//!    [`is_builtin_openapi_type`]), NOT `serde_json::Value`, NOT a
//!    generic type parameter of the current struct, and NOT marked
//!    with `#[schema(any)]`, this module emits a const block:
//!
//!    ```ignore
//!    const _: () = {
//!        const fn __vespera_assert_schema<T: ::vespera::Schema>() {}
//!        let _ = __vespera_assert_schema::<#leaf_ty>;
//!    };
//!    ```
//!
//!    The assertion is `quote_spanned!`-ed at the field's span so the
//!    compile error points at the offending field — a forgotten
//!    `#[derive(Schema)]` on a custom type becomes a loud build break
//!    instead of a silent `{type:"object"}` in `openapi.json`.
//!
//! Both supplements are additive: they do not change the generated
//! OpenAPI byte for any non-opaque field.

use std::collections::HashSet;

use proc_macro2::TokenStream;
use quote::{quote, quote_spanned};
use syn::spanned::Spanned;
use syn::{GenericArgument, PathArguments, Type};

use crate::parser::schema::{is_builtin_openapi_type, schema_attrs::SchemaConstraints};

/// Emit BOTH the marker-trait impl AND the per-field leaf-type
/// assertions for `input` — the single entry point called from
/// `process_derive_schema`.  Returns an empty `TokenStream` only when
/// the input shape would otherwise miscompile (e.g. parse-failed
/// `#[schema(...)]` attrs on a field — surfaced upstream as a
/// `compile_error!`).
///
/// `field_constraints` is the slice of already-parsed `#[schema(...)]`
/// values produced by `process_derive_schema` in a single walk; it is
/// indexed pairwise with `fields_named.named` inside
/// `emit_field_assertions`. Passing the slice avoids re-running
/// `try_extract_schema_constraints` here (the parser had already been
/// invoked once per field for validation).
#[must_use]
pub fn emit_schema_supplements(
    input: &syn::DeriveInput,
    field_constraints: &[SchemaConstraints],
) -> TokenStream {
    let marker = emit_marker_impl(input);
    let assertions = emit_field_assertions(input, field_constraints);
    quote! {
        #marker
        #assertions
    }
}

/// Emit `impl ::vespera::Schema for #Ident {}`, preserving every
/// generic parameter, lifetime, and where clause on the input.  The
/// marker trait is empty, so the impl is unconditional — no synthetic
/// `where T: Schema` bound is added (that would over-constrain
/// existing generic structs).
fn emit_marker_impl(input: &syn::DeriveInput) -> TokenStream {
    let name = &input.ident;
    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
    quote! {
        #[automatically_derived]
        impl #impl_generics ::vespera::Schema for #name #ty_generics #where_clause {}
    }
}

/// Walk the struct's named fields and emit a `const _: () = { ... };`
/// block per non-exempt leaf type.  Exemptions:
///
/// - Field is marked `#[schema(any)]` (entire field is skipped).
/// - Leaf path's last segment is a builtin OpenAPI ident
///   (per [`is_builtin_openapi_type`]).
/// - Leaf is `serde_json::Value` (bare `Value` OR any path containing
///   a `serde_json` segment ending in `Value`).
/// - Leaf type's path references any generic parameter of the parent
///   struct — those can only be checked at the use site where the
///   generic is instantiated (the parent's marker impl is unconditional
///   so an unbound generic does not break the trait bound here).
///
/// Returns an empty stream for non-structs, non-named-field structs,
/// and structs where every leaf is exempt — keeping the existing
/// emitted bytes for those cases byte-identical.
///
/// `field_constraints` is the pre-parsed `#[schema(...)]` slice (one
/// entry per named field, in declaration order). `process_derive_schema`
/// already collected it during the per-field validation walk, so
/// indexing pairwise with `fields_named.named` here removes a
/// duplicate parse pass over every field's attrs.
fn emit_field_assertions(
    input: &syn::DeriveInput,
    field_constraints: &[SchemaConstraints],
) -> TokenStream {
    let syn::Data::Struct(data_struct) = &input.data else {
        return TokenStream::new();
    };
    let syn::Fields::Named(fields_named) = &data_struct.fields else {
        return TokenStream::new();
    };

    let generic_idents: HashSet<String> = input
        .generics
        .params
        .iter()
        .filter_map(|param| {
            if let syn::GenericParam::Type(t) = param {
                Some(t.ident.to_string())
            } else {
                None
            }
        })
        .collect();

    let mut blocks: Vec<TokenStream> = Vec::new();
    // `process_derive_schema` constructs `field_constraints` with exactly one
    // entry per named field, in declaration order. `zip` therefore yields the
    // full pairing here; when the caller forwards an empty slice (e.g. unit /
    // tuple structs short-circuit above never reach this loop) we degenerate
    // to zero iterations like the previous code.
    for (field, constraints) in fields_named.named.iter().zip(field_constraints) {
        // `#[schema(any)]` is the documented escape hatch — the field
        // explicitly opted out of type-driven schema generation, so
        // there's no leaf to assert.  Parse errors on `#[schema(...)]`
        // are surfaced upstream by `process_derive_schema`, so a field
        // that would have produced an `Err` here never makes it into
        // `field_constraints` (the early `to_compile_error` return
        // upstream supersedes the `continue` the previous code took).
        if constraints.is_any() {
            continue;
        }

        for (leaf, last_ident) in collect_leaf_custom_types(&field.ty) {
            if type_contains_generic(&leaf, &generic_idents) {
                continue;
            }
            if is_builtin_openapi_type(&last_ident) {
                continue;
            }
            if is_serde_json_value_leaf(&leaf) {
                continue;
            }
            // Span the entire `const _ : () = { ... };` block to the
            // field's span (the design pins the diagnostic to the
            // field, not just its type, so `field.span()` covers the
            // whole `name: Type` declaration).  The const item name is
            // `_` so multiple assertions per struct never collide.
            let field_span = field.span();
            blocks.push(quote_spanned! { field_span =>
                const _: () = {
                    const fn __vespera_assert_schema<T: ::vespera::Schema>() {}
                    let _ = __vespera_assert_schema::<#leaf>;
                };
            });
        }
    }

    quote! { #(#blocks)* }
}

/// Recursively unwrap the "transparent for schema" containers
/// (`Vec<_>`, `Option<_>`, `Box<_>`, `HashSet<_>`, `BTreeSet<_>`,
/// `HashMap<_, V>`, `BTreeMap<_, V>`) and return the LEAF type that
/// the assertion should fire on.  Maps return the VALUE type (the key
/// is the JSON object key, a string by convention).
///
/// Returns an empty `Vec` for type shapes the macro cannot assert on
/// (references, slices, tuples, function pointers, …): those already
/// fall through to `Object` in the generator and there's no scalar
/// "leaf" identifier to bind a bound to.
/// Each leaf is paired with its final path ident.  Both early returns below
/// reject the only shapes that lack one, so the caller never has to handle a
/// missing ident - the invariant lives here instead of as an unreachable
/// `else` branch at the call site.
fn collect_leaf_custom_types(ty: &Type) -> Vec<(Type, String)> {
    let Type::Path(type_path) = ty else {
        return Vec::new();
    };
    let Some(segment) = type_path.path.segments.last() else {
        return Vec::new();
    };

    // Compare on the `syn::Ident` directly (PartialEq<str>) so this
    // hot per-field leaf walker doesn't allocate an owned `String` per
    // call — same pattern `is_serde_json_value_leaf` below uses for the
    // `Value` check.
    if segment.ident == "Vec"
        || segment.ident == "Option"
        || segment.ident == "Box"
        || segment.ident == "HashSet"
        || segment.ident == "BTreeSet"
    {
        return first_generic_type_arg(segment)
            .map(collect_leaf_custom_types)
            .unwrap_or_default();
    }
    if segment.ident == "HashMap" || segment.ident == "BTreeMap" {
        return second_generic_type_arg(segment)
            .map(collect_leaf_custom_types)
            .unwrap_or_default();
    }
    vec![(ty.clone(), segment.ident.to_string())]
}

fn first_generic_type_arg(segment: &syn::PathSegment) -> Option<&Type> {
    let PathArguments::AngleBracketed(args) = &segment.arguments else {
        return None;
    };
    args.args.iter().find_map(|arg| {
        if let GenericArgument::Type(inner) = arg {
            Some(inner)
        } else {
            None
        }
    })
}

fn second_generic_type_arg(segment: &syn::PathSegment) -> Option<&Type> {
    let PathArguments::AngleBracketed(args) = &segment.arguments else {
        return None;
    };
    args.args
        .iter()
        .filter_map(|arg| {
            if let GenericArgument::Type(inner) = arg {
                Some(inner)
            } else {
                None
            }
        })
        .nth(1)
}

/// `true` when any path segment of `ty` (including inside nested
/// `<...>` arguments) names one of the current struct's generic type
/// parameters.  We cannot synthesise a trait bound for a generic
/// parameter from inside the derive — the marker impl is unconditional
/// `impl<T> Schema for Foo<T> {}` — so any leaf referring to `T` must
/// be skipped.
fn type_contains_generic(ty: &Type, generic_idents: &HashSet<String>) -> bool {
    match ty {
        Type::Path(type_path) => {
            for segment in &type_path.path.segments {
                if generic_idents.contains(&segment.ident.to_string()) {
                    return true;
                }
                if let PathArguments::AngleBracketed(args) = &segment.arguments {
                    for arg in &args.args {
                        if let GenericArgument::Type(inner) = arg
                            && type_contains_generic(inner, generic_idents)
                        {
                            return true;
                        }
                    }
                }
            }
            false
        }
        Type::Reference(r) => type_contains_generic(&r.elem, generic_idents),
        _ => false,
    }
}

/// `serde_json::Value` allowlist — bare `Value`, or any qualified path
/// whose final segment is `Value` and which contains a `serde_json`
/// segment somewhere (catches `serde_json::Value`, `::serde_json::Value`,
/// and `vespera::serde_json::Value` — vespera re-exports serde_json).
fn is_serde_json_value_leaf(ty: &Type) -> bool {
    let Type::Path(type_path) = ty else {
        return false;
    };
    let segments = &type_path.path.segments;
    // Reject early on the trivial last-segment check so non-`Value`
    // leaves (the overwhelming common case) pay zero allocation.
    // `syn::Ident: PartialEq<str>` is a direct byte compare — no
    // intermediate `String`.
    let Some(last) = segments.last() else {
        return false;
    };
    if last.ident != "Value" {
        return false;
    }
    // Bare `Value` — allowlisted (matches design literal "match the
    // last segment `Value`").
    if segments.len() == 1 {
        return true;
    }
    // Multi-segment path: require a `serde_json` segment anywhere so
    // we don't allowlist an unrelated custom `foo::Bar::Value`.  This
    // catches `serde_json::Value` (2-segment, the canonical case),
    // `::serde_json::Value` (3-segment with leading colon stripped),
    // and `vespera::serde_json::Value` (3-segment re-export path).
    segments.iter().any(|segment| segment.ident == "serde_json")
}

#[cfg(test)]
mod tests {
    use super::*;
    use quote::quote;
    use syn::parse_quote;

    fn parse_ty(src: &str) -> Type {
        syn::parse_str::<Type>(src).expect("valid type")
    }

    fn leaves_of(src: &str) -> Vec<String> {
        collect_leaf_custom_types(&parse_ty(src))
            .iter()
            .map(|(ty, _)| quote!(#ty).to_string().replace(' ', ""))
            .collect()
    }

    fn empty_path_type() -> Type {
        Type::Path(syn::TypePath {
            attrs: Vec::new(),
            qself: None,
            path: syn::Path {
                leading_colon: None,
                segments: syn::punctuated::Punctuated::new(),
            },
        })
    }

    /// Recompute the per-field `SchemaConstraints` slice the way
    /// `process_derive_schema` does in production code, so tests can
    /// drive `emit_field_assertions` (which now takes the slice as a
    /// parameter) without re-stating the parse pass at every call site.
    fn constraints_for(input: &syn::DeriveInput) -> Vec<SchemaConstraints> {
        use crate::parser::schema::schema_attrs::try_extract_schema_constraints;
        if let syn::Data::Struct(d) = &input.data
            && let syn::Fields::Named(f) = &d.fields
        {
            return f
                .named
                .iter()
                .map(|fld| try_extract_schema_constraints(&fld.attrs).unwrap_or_default())
                .collect();
        }
        Vec::new()
    }

    // ── collect_leaf_custom_types ──────────────────────────────────

    #[test]
    fn leaf_of_plain_path_is_itself() {
        assert_eq!(leaves_of("MyType"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_vec_unwraps_inner() {
        assert_eq!(leaves_of("Vec<MyType>"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_option_unwraps_inner() {
        assert_eq!(leaves_of("Option<MyType>"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_box_unwraps_inner() {
        assert_eq!(leaves_of("Box<MyType>"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_hashset_unwraps_inner() {
        assert_eq!(leaves_of("HashSet<MyType>"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_btreeset_unwraps_inner() {
        assert_eq!(leaves_of("BTreeSet<MyType>"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_hashmap_unwraps_value_not_key() {
        // Key is always String by JSON convention; only the value gets
        // an OpenAPI schema reference, so only the value gets asserted.
        assert_eq!(leaves_of("HashMap<String, MyType>"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_btreemap_unwraps_value_not_key() {
        assert_eq!(leaves_of("BTreeMap<String, MyType>"), vec!["MyType"]);
    }

    #[test]
    fn leaf_of_nested_wrappers() {
        assert_eq!(
            leaves_of("Vec<Option<Box<HashMap<String, MyType>>>>"),
            vec!["MyType"]
        );
    }

    #[test]
    fn leaf_of_non_path_type_is_empty() {
        // References, slices, tuples produce no leaf to assert on.
        assert!(collect_leaf_custom_types(&parse_ty("(i32, String)")).is_empty());
        assert!(collect_leaf_custom_types(&parse_ty("&MyType")).is_empty());
    }

    #[test]
    fn leaf_of_empty_wrapper_is_empty() {
        // `Vec` with no type args (degenerate) has no leaf.
        assert!(collect_leaf_custom_types(&parse_ty("Vec")).is_empty());
    }

    #[test]
    fn leaf_of_empty_path_is_empty() {
        assert!(collect_leaf_custom_types(&empty_path_type()).is_empty());
    }

    #[test]
    fn generic_argument_helpers_ignore_lifetimes_and_missing_arguments() {
        let vec_lifetime = parse_ty("Vec<'a>");
        let Type::Path(vec_path) = vec_lifetime else {
            panic!("fixture must be a path type");
        };
        let vec_segment = vec_path.path.segments.last().expect("Vec segment");

        let bare_map = parse_ty("HashMap");
        let Type::Path(bare_map_path) = bare_map else {
            panic!("fixture must be a path type");
        };
        let bare_map_segment = bare_map_path.path.segments.last().expect("HashMap segment");

        let lifetime_map = parse_ty("HashMap<'a, 'b>");
        let Type::Path(lifetime_map_path) = lifetime_map else {
            panic!("fixture must be a path type");
        };
        let lifetime_map_segment = lifetime_map_path
            .path
            .segments
            .last()
            .expect("HashMap segment");

        assert_eq!(first_generic_type_arg(vec_segment), None);
        assert_eq!(second_generic_type_arg(bare_map_segment), None);
        assert_eq!(second_generic_type_arg(lifetime_map_segment), None);
    }

    #[test]
    fn leaf_pairs_carry_the_final_path_ident() {
        let leaves = collect_leaf_custom_types(&parse_ty("Vec<Option<crate::models::User>>"));
        assert_eq!(leaves.len(), 1);
        assert_eq!(leaves[0].1, "User");
    }

    // ── is_serde_json_value_leaf ────────────────────────────────────

    #[test]
    fn bare_value_is_allowlisted() {
        assert!(is_serde_json_value_leaf(&parse_ty("Value")));
    }

    #[test]
    fn serde_json_value_is_allowlisted() {
        assert!(is_serde_json_value_leaf(&parse_ty("serde_json::Value")));
    }

    #[test]
    fn leading_colon_serde_json_value_is_allowlisted() {
        assert!(is_serde_json_value_leaf(&parse_ty("::serde_json::Value")));
    }

    #[test]
    fn vespera_reexport_serde_json_value_is_allowlisted() {
        assert!(is_serde_json_value_leaf(&parse_ty(
            "vespera::serde_json::Value"
        )));
    }

    #[test]
    fn unrelated_value_segment_is_not_allowlisted() {
        // `foo::bar::Value` is some user enum named Value — not the
        // serde_json sentinel, so the strict-schema check still fires.
        assert!(!is_serde_json_value_leaf(&parse_ty("foo::bar::Value")));
    }

    #[test]
    fn last_segment_not_value_is_not_allowlisted() {
        assert!(!is_serde_json_value_leaf(&parse_ty("serde_json::Map")));
    }

    #[test]
    fn serde_json_allowlist_rejects_non_path_and_empty_path_types() {
        assert!(!is_serde_json_value_leaf(&parse_ty("&Value")));
        assert!(!is_serde_json_value_leaf(&empty_path_type()));
    }

    // ── type_contains_generic ───────────────────────────────────────

    #[test]
    fn generic_param_directly() {
        let mut generics = HashSet::new();
        generics.insert("T".to_string());
        assert!(type_contains_generic(&parse_ty("T"), &generics));
    }

    #[test]
    fn generic_param_inside_vec() {
        let mut generics = HashSet::new();
        generics.insert("T".to_string());
        assert!(type_contains_generic(&parse_ty("Vec<T>"), &generics));
    }

    #[test]
    fn generic_param_inside_box_inside_option() {
        let mut generics = HashSet::new();
        generics.insert("T".to_string());
        assert!(type_contains_generic(
            &parse_ty("Option<Box<HashMap<String, T>>>"),
            &generics
        ));
    }

    #[test]
    fn no_generic_param_present() {
        let mut generics = HashSet::new();
        generics.insert("T".to_string());
        assert!(!type_contains_generic(&parse_ty("Vec<String>"), &generics));
        assert!(!type_contains_generic(
            &parse_ty("HashMap<String, MyType>"),
            &generics
        ));
    }

    #[test]
    fn empty_generics_set_never_matches() {
        let generics = HashSet::new();
        assert!(!type_contains_generic(&parse_ty("T"), &generics));
    }

    #[test]
    fn generic_detection_descends_through_references_and_rejects_other_types() {
        let generics = HashSet::from(["T".to_string()]);

        assert!(type_contains_generic(&parse_ty("&T"), &generics));
        assert!(!type_contains_generic(&parse_ty("(T, String)"), &generics));
    }

    // ── emit_marker_impl preserves generics ─────────────────────────

    #[test]
    fn marker_impl_for_plain_struct() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Plain { x: i32 }
        };
        let out = emit_marker_impl(&input).to_string();
        assert!(out.contains("impl :: vespera :: Schema for Plain"));
    }

    #[test]
    fn marker_impl_preserves_generics_and_bounds() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Container<T: Serialize> { value: T }
        };
        let out = emit_marker_impl(&input).to_string();
        assert!(out.contains("impl < T : Serialize > :: vespera :: Schema for Container < T >"));
    }

    #[test]
    fn marker_impl_for_enum() {
        let input: syn::DeriveInput = parse_quote! {
            pub enum E { A, B(i32) }
        };
        let out = emit_marker_impl(&input).to_string();
        assert!(out.contains("impl :: vespera :: Schema for E"));
    }

    // ── emit_field_assertions exemption surface ─────────────────────

    #[test]
    fn no_assertion_for_builtin_only_struct() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Plain {
                id: i32,
                name: String,
                tags: Vec<String>,
                map: HashMap<String, bool>,
            }
        };
        let out = emit_field_assertions(&input, &constraints_for(&input)).to_string();
        assert!(
            out.is_empty(),
            "builtins-only struct must emit zero assertions, got: {out}"
        );
    }

    #[test]
    fn assertion_for_unknown_custom_leaf() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Holder {
                inner: MyUnknown,
            }
        };
        let out = emit_field_assertions(&input, &constraints_for(&input)).to_string();
        assert!(
            out.contains("__vespera_assert_schema") && out.contains("MyUnknown"),
            "expected assertion on MyUnknown, got: {out}"
        );
    }

    #[test]
    fn no_assertion_for_serde_json_value() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Holder {
                raw: serde_json::Value,
                bare: Value,
                via: Option<Vec<serde_json::Value>>,
            }
        };
        let out = emit_field_assertions(&input, &constraints_for(&input)).to_string();
        assert!(
            out.is_empty(),
            "Value fields must not emit assertions, got: {out}"
        );
    }

    #[test]
    fn no_assertion_for_any_marked_field() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Holder {
                #[schema(any)]
                opaque: MyUnknown,
            }
        };
        let out = emit_field_assertions(&input, &constraints_for(&input)).to_string();
        assert!(
            out.is_empty(),
            "`#[schema(any)]` must skip the assertion, got: {out}"
        );
    }

    #[test]
    fn no_assertion_for_generic_parameter_leaf() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Generic<T> {
                value: T,
                vec: Vec<T>,
                map: HashMap<String, T>,
                name: String,
            }
        };
        let out = emit_field_assertions(&input, &constraints_for(&input)).to_string();
        assert!(
            out.is_empty(),
            "fields whose leaf is the struct's generic param must not emit assertions, got: {out}"
        );
    }

    #[test]
    fn assertion_per_unknown_field_individually() {
        let input: syn::DeriveInput = parse_quote! {
            pub struct Holder {
                a: AlphaType,
                b: Vec<BetaType>,
                c: HashMap<String, GammaType>,
                d: i32,
                #[schema(any)]
                e: WhateverType,
            }
        };
        let out = emit_field_assertions(&input, &constraints_for(&input)).to_string();
        // Three assertions (a, b, c) for the three non-exempt leaves.
        assert_eq!(
            out.matches("let _ = __vespera_assert_schema").count(),
            3,
            "expected one assertion per non-exempt field: {out}"
        );
        assert!(out.contains("AlphaType"));
        assert!(out.contains("BetaType"));
        assert!(out.contains("GammaType"));
        assert!(!out.contains("WhateverType"));
    }

    #[test]
    fn no_assertion_for_non_struct_kinds() {
        // Enums get the marker impl (separately) but the per-variant
        // field walk is intentionally out of scope for v1; their
        // variant fields fall back to silent `Object` as before.
        let input: syn::DeriveInput = parse_quote! {
            pub enum E { A(MyUnknown) }
        };
        assert!(
            emit_field_assertions(&input, &constraints_for(&input))
                .to_string()
                .is_empty()
        );

        let input: syn::DeriveInput = parse_quote! {
            pub struct Tuple(MyUnknown);
        };
        assert!(
            emit_field_assertions(&input, &constraints_for(&input))
                .to_string()
                .is_empty()
        );

        let input: syn::DeriveInput = parse_quote! {
            pub struct Unit;
        };
        assert!(
            emit_field_assertions(&input, &constraints_for(&input))
                .to_string()
                .is_empty()
        );
    }
}