autumn-macros 0.9.0

Proc macros for the Autumn web framework
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
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
//! `#[derive(OpenApiSchema)]` — a standalone derive that gives a plain struct or
//! unit-variant enum a field-accurate `OpenApiSchema` impl (issue #1972).
//!
//! Before this derive, the only automatic `OpenApiSchema` impls came from
//! `#[model]` codegen and the primitive macro impls, so any other handler-arg
//! struct (a `Query<T>` param struct or a non-`#[model]` `Json<T>` body) had to
//! carry a hand-written impl plus an `OpenApiConfig::register_schema` call — or
//! its `OpenAPI` / MCP `inputSchema` degraded to a generic
//! `{"type":"object","title":"X"}` placeholder.
//!
//! For structs this mirrors the schema `#[model]` already generates
//! (`autumn_macros_support::schema::emit_schema_fn_body_full`): each field becomes a JSON-schema
//! property and every non-`Option` field is `required`. For enums it emits the
//! closed-set form (`{"type":"string","enum":[…]}`) that serde's default
//! externally-tagged representation produces for unit variants — the shape a
//! client generator turns into a TypeScript string union or a Rust enum.
//!
//! Either way the derive submits the schema into the compile-time
//! `DerivedSchemaDescriptor` inventory that the spec/MCP back-fill loops
//! consult, so a referenced type resolves to its real schema with no manual
//! registration.

use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields};

/// Name of the derive helper attribute.
const HELPER: &str = "openapi_schema";

/// Entry point for the derive.
///
/// Reads `#[openapi_schema(crate = "...")]` and builds the generated paths from
/// that crate name. A derive cannot take arguments, so a helper attribute
/// carries the override (issue #2565). The derive does not output the item, so
/// the helper attribute needs no removal.
pub fn derive_openapi_schema(input: TokenStream) -> TokenStream {
    let input = match syn::parse2::<DeriveInput>(input) {
        Ok(input) => input,
        Err(e) => return e.to_compile_error(),
    };
    if let Err(err) = reject_misplaced_helper(&input) {
        return err;
    }
    let crate_override = match helper_crate_override(&input.attrs) {
        Ok(name) => name,
        Err(err) => return err,
    };
    let _guard = autumn_macros_support::crate_path::set_target(crate_override.as_deref());
    autumn_macros_support::crate_path::finalize(expand(&input))
}

/// Read the `crate = "..."` value from the `#[openapi_schema(...)]` attributes.
///
/// Fails on a bare attribute, an unknown argument, a bad value, or a second
/// override. A typo must not silently fall back to automatic resolution.
fn helper_crate_override(attrs: &[syn::Attribute]) -> Result<Option<String>, TokenStream> {
    let mut found: Option<String> = None;
    for attr in attrs.iter().filter(|a| a.path().is_ident(HELPER)) {
        let syn::Meta::List(list) = &attr.meta else {
            return Err(helper_error(attr));
        };
        let (name, rest) =
            autumn_macros_support::crate_path::extract_crate_override(list.tokens.clone())?;
        if name.is_none() || !rest.is_empty() || found.is_some() {
            return Err(helper_error(attr));
        }
        found = name;
    }
    Ok(found)
}

/// Refuse the helper on a field or variant. Only the container is read, so
/// any other place would be ignored without a warning.
fn reject_misplaced_helper(input: &DeriveInput) -> Result<(), TokenStream> {
    let nested: Vec<&syn::Attribute> = match &input.data {
        Data::Struct(d) => d.fields.iter().flat_map(|f| &f.attrs).collect(),
        Data::Enum(d) => d
            .variants
            .iter()
            .flat_map(|v| v.attrs.iter().chain(v.fields.iter().flat_map(|f| &f.attrs)))
            .collect(),
        Data::Union(d) => d.fields.named.iter().flat_map(|f| &f.attrs).collect(),
    };
    if let Some(attr) = nested.iter().find(|a| a.path().is_ident(HELPER)) {
        return Err(syn::Error::new_spanned(
            attr,
            "`#[openapi_schema]` is valid on the struct or enum only",
        )
        .to_compile_error());
    }
    Ok(())
}

fn helper_error(attr: &syn::Attribute) -> TokenStream {
    syn::Error::new_spanned(
        attr,
        "expected one `#[openapi_schema(crate = \"...\")]` attribute",
    )
    .to_compile_error()
}

fn expand(input: &DeriveInput) -> TokenStream {
    let name = &input.ident;

    // The emitted impl uses the bare type name for both `schema_name()` and the
    // inventory descriptor, and the descriptor's `schema` field is a plain
    // `fn() -> Value` — none of which can carry generic parameters. Reject
    // generics with a clear message rather than emitting an impl that fails to
    // compile downstream.
    if !input.generics.params.is_empty() {
        return syn::Error::new_spanned(
            &input.generics,
            "#[derive(OpenApiSchema)] does not support generic types",
        )
        .to_compile_error();
    }

    let schema_body = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(named) => {
                if let Err(e) = reject_undescribable_struct(input, named) {
                    return e.to_compile_error();
                }
                // The emitters expect `&[&&Field]` (they were written against
                // the `Vec<&&Field>` the model macro collects); build that
                // shape here, minus the fields serde never puts on the wire.
                let field_refs: Vec<&syn::Field> = named
                    .named
                    .iter()
                    .filter(|f| {
                        autumn_macros_support::schema::serde_bare_word(&f.attrs, &["skip"])
                            .is_none()
                    })
                    .collect();
                let field_ref_refs: Vec<&&syn::Field> = field_refs.iter().collect();
                // Honor a container `#[serde(rename_all = "...")]` — the split
                // form is refused above, so this side is the only side.
                let rename_all_rule =
                    autumn_macros_support::schema::serde_rename_all_serialize_rule(&input.attrs);
                // A container `#[serde(default)]` (bare or `= "path"`) lets
                // EVERY field be absent from a request, filled from the struct
                // default. Nothing is required then. Safe in both directions:
                // a response still carries every field, so a client that does
                // not demand them is not misled, while a request client is no
                // longer forced to send what the handler does not need.
                let container_default =
                    autumn_macros_support::schema::has_serde_default(&input.attrs);
                let body = autumn_macros_support::schema::emit_schema_fn_body_full(
                    &field_ref_refs,
                    container_default,
                    &[],
                    rename_all_rule.as_deref(),
                    // A `#[serde(default)]` field may be omitted from a request
                    // and is always present in a response, so "not required" is
                    // true of both directions — no conflict, unlike the
                    // directional attributes refused above.
                    &|f: &syn::Field| autumn_macros_support::schema::has_serde_default(&f.attrs),
                );
                // `#[serde(deny_unknown_fields)]` makes deserialization REJECT
                // any key not listed above. Without `additionalProperties:
                // false` the schema invites a client to send extras that the
                // handler then 400s on. Describable rather than refusable —
                // JSON Schema says exactly this — so it is emitted, not
                // rejected.
                //
                // Sound in both directions: the attribute constrains input, and
                // a response built from this struct never carries a key outside
                // the listed set either, so the closed object is true of the
                // serialize side as well.
                if autumn_macros_support::schema::serde_bare_word(
                    &input.attrs,
                    &["deny_unknown_fields"],
                )
                .is_some()
                {
                    quote! {{
                        let mut __autumn_closed = { #body };
                        if let Some(__autumn_obj) = __autumn_closed.as_object_mut() {
                            __autumn_obj.insert(
                                "additionalProperties".to_owned(),
                                ::autumn_web::reexports::serde_json::json!(false),
                            );
                        }
                        __autumn_closed
                    }}
                } else {
                    body
                }
            }
            _ => {
                return syn::Error::new_spanned(
                    input,
                    "#[derive(OpenApiSchema)] is only supported on structs with named fields",
                )
                .to_compile_error();
            }
        },
        Data::Enum(data) => match enum_schema_body(input, data) {
            Ok(body) => body,
            Err(e) => return e.to_compile_error(),
        },
        Data::Union(_) => {
            return syn::Error::new_spanned(
                input,
                "#[derive(OpenApiSchema)] is only supported on structs and enums",
            )
            .to_compile_error();
        }
    };

    quote! {
        impl ::autumn_web::openapi::OpenApiSchema for #name {
            fn schema_name() -> &'static str {
                ::core::stringify!(#name)
            }
            fn schema() -> ::autumn_web::reexports::serde_json::Value {
                #schema_body
            }
        }

        // Advertise the derived schema by name so the OpenAPI/MCP schema
        // back-fill resolves it instead of the generic object placeholder.
        ::autumn_web::reexports::inventory::submit! {
            ::autumn_web::openapi::DerivedSchemaDescriptor {
                name: ::core::stringify!(#name),
                identity: ::autumn_web::openapi::type_name_of::<#name>,
                schema: <#name as ::autumn_web::openapi::OpenApiSchema>::schema,
            }
        }
    }
}

/// Emit the `schema()` body for a unit-variant enum: the closed string set
/// serde's default representation puts on the wire.
///
/// Data-carrying variants are rejected rather than approximated. Serde's
/// externally-tagged form for those is a `oneOf` of single-key wrapper objects
/// whose exact shape also depends on `#[serde(tag/content/untagged)]`, so
/// guessing would advertise a contract the handler does not actually accept —
/// worse than the placeholder, because it is confidently wrong. The error names
/// the hand-written escape hatch instead.
fn enum_schema_body(
    input: &DeriveInput,
    data: &syn::DataEnum,
) -> syn::Result<proc_macro2::TokenStream> {
    reject_undescribable_enum(input, data)?;

    let rename_all_rule =
        autumn_macros_support::schema::serde_rename_all_serialize_rule(&input.attrs);
    let values: Vec<String> = data
        .variants
        .iter()
        .filter(|v| !autumn_macros_support::schema::variant_is_serde_skipped(v))
        .map(|v| {
            let raw = v.ident.to_string();
            let raw = raw.strip_prefix("r#").unwrap_or(&raw).to_owned();
            // Precedence mirrors serde: a variant-level `#[serde(rename)]` wins
            // over the container `#[serde(rename_all)]`, which wins over the
            // raw identifier.
            autumn_macros_support::schema::variant_serde_serialize_rename(v)
                .or_else(|| {
                    rename_all_rule.as_deref().and_then(|rule| {
                        autumn_macros_support::schema::apply_serde_rename_all_rule_to_variant(
                            rule, &raw,
                        )
                    })
                })
                .unwrap_or(raw)
        })
        .collect();

    if values.is_empty() {
        return Err(syn::Error::new_spanned(
            input,
            "#[derive(OpenApiSchema)] needs at least one non-skipped variant to advertise",
        ));
    }

    Ok(quote! {
        ::autumn_web::reexports::serde_json::json!({
            "type": "string",
            "enum": [#(#values),*],
        })
    })
}

/// Refuse every struct shape whose real wire form the derive cannot describe.
///
/// Written as one audit of serde's attribute surface rather than a rule per
/// report. Seven review rounds on issue #802 landed fixes scoped to whichever
/// attribute was named, and each time an adjacent one was still wrong; the
/// generalisation is that an object schema is only the truth when every field
/// appears under a single symmetric name and the container is not re-shaped.
///
/// | Attribute | What serde does | Why an object schema is wrong |
/// |---|---|---|
/// | `transparent` | writes the inner value | there is no object at all |
/// | `into` / `from` / `try_from` | routes through another type | shape is that type's |
/// | `tag` / `untagged` | re-tags the container | adds or removes an object level |
/// | `flatten` (field) | merges the field's keys upward | the field is not a nested property |
/// | `with` / `serialize_with` / `deserialize_with` (field) | any wire shape | the Rust type does not describe it |
/// | `alias` (field) | accepts a second name | one schema advertises one |
/// | `skip_serializing` / `skip_deserializing` (field) | one direction only | one schema serves both |
/// | split `rename_all` / `rename` | two different names | one schema advertises one |
///
/// Accepted and handled by the caller rather than refused: `skip` (absent both
/// ways, so the field is dropped), `default` (omissible on input, present on
/// output — "not required" is true either way), and a symmetric `rename_all` /
/// `rename`.
///
/// Refuse `#[serde(skip_serializing_if = "…")]` where omission is not valid in
/// BOTH directions.
///
/// Split out of [`reject_undescribable_struct`] for length; the reasoning is
/// inline below because it is the whole of the rule.
fn reject_undescribable_conditional_skip(
    field: &syn::Field,
    container_attrs: &[syn::Attribute],
) -> syn::Result<()> {
    // Direction-dependent for the same reason, and describable only when
    // something makes omission valid on the REQUEST side too. A field or
    // container `#[serde(default)]` does: deserialization fills the field
    // in, serialization may omit it, and a not-`required` property is then
    // accurate in both directions.
    //
    // Being spelled `Option<T>` does NOT, and used to be accepted here.
    // That premise is only true for `std`'s `Option`, which a proc macro
    // cannot verify — it sees the tokens as written, so an application's own
    // `domain::Option<T>` reads identically and serde requires it on the
    // request side. The two possible guesses are both wrong for that type:
    // marking it `required` lets a response omit what the schema demands,
    // marking it optional lets a client omit what serde rejects. It is not
    // describable by one schema, so it is refused rather than guessed.
    //
    // The cost falls on `Option<T>` + `skip_serializing_if` with no
    // `#[serde(default)]`, which used to compile. The fix is that one
    // attribute, and it is a NO-OP for a real `Option` — serde already
    // fills a missing one with `None` — so the suggestion is correct
    // whichever of the two the type turns out to be.
    if autumn_macros_support::schema::field_has_skip_serializing_if(field)
        && !autumn_macros_support::schema::has_serde_default(&field.attrs)
        && !autumn_macros_support::schema::has_serde_default(container_attrs)
    {
        return Err(syn::Error::new_spanned(
            field,
            "#[derive(OpenApiSchema)] cannot describe a field with \
             `#[serde(skip_serializing_if = ...)]` and no `#[serde(default)]`: that \
             attribute governs serialization only, so a response may omit the field \
             while serde still rejects a request that does. Being spelled `Option<T>` \
             is not enough — this macro sees only the tokens, and a type of your own \
             named `Option` reads the same but is required on the way in. Add \
             `#[serde(default)]` (a no-op for a real `Option<T>`, which serde already \
             fills with `None`), or write the `OpenApiSchema` impl by hand and register \
             it with `OpenApiConfig::register_schema`.",
        ));
    }
    Ok(())
}

fn reject_undescribable_struct(input: &DeriveInput, named: &syn::FieldsNamed) -> syn::Result<()> {
    // ── Container ────────────────────────────────────────────────────
    if let Some(word) =
        autumn_macros_support::schema::serde_bare_word(&input.attrs, &["transparent", "untagged"])
    {
        return Err(syn::Error::new_spanned(
            input,
            format!(
                "#[derive(OpenApiSchema)] cannot describe `#[serde({word})]`: serde does not \
                 put an object with these fields on the wire, so the derived schema would \
                 advertise a shape the handler neither accepts nor returns. Write the \
                 `OpenApiSchema` impl by hand and register it with \
                 `OpenApiConfig::register_schema`."
            ),
        ));
    }
    if let Some(key) = autumn_macros_support::schema::serde_valued_key(
        &input.attrs,
        &["into", "from", "try_from", "tag"],
    ) {
        return Err(syn::Error::new_spanned(
            input,
            format!(
                "#[derive(OpenApiSchema)] cannot describe `#[serde({key} = ...)]`: it re-shapes \
                 what reaches the wire, so an object schema built from these fields would be \
                 wrong. Write the `OpenApiSchema` impl by hand and register it with \
                 `OpenApiConfig::register_schema`."
            ),
        ));
    }
    if let Some(key) = autumn_macros_support::schema::serde_split_rename(&input.attrs, "rename_all")
    {
        return Err(syn::Error::new_spanned(input, split_rename_message(key)));
    }

    // ── Fields ───────────────────────────────────────────────────────
    for field in &named.named {
        if let Some(word) =
            autumn_macros_support::schema::serde_bare_word(&field.attrs, &["flatten"])
        {
            return Err(syn::Error::new_spanned(
                field,
                format!(
                    "#[derive(OpenApiSchema)] cannot describe `#[serde({word})]`: serde merges \
                     this field's keys into the containing object, while the derived schema \
                     would publish it as a nested property — a generated client would send a \
                     nesting the handler does not accept and expect one the server never \
                     emits. Write the `OpenApiSchema` impl by hand and register it with \
                     `OpenApiConfig::register_schema`."
                ),
            ));
        }
        // A (de)serialization adapter can put ANY shape on the wire — an `i64`
        // amount written as a string is the common one — so the field's Rust
        // type stops describing it. All three spellings are rejected, not just
        // the serialize half: this schema serves requests AND responses, so an
        // adapter on either side can make it wrong for that side. `with` sets
        // both at once.
        if let Some(key) = autumn_macros_support::schema::serde_valued_key(
            &field.attrs,
            &["with", "serialize_with", "deserialize_with"],
        ) {
            return Err(syn::Error::new_spanned(
                field,
                format!(
                    "#[derive(OpenApiSchema)] cannot describe a field with \
                     `#[serde({key} = ...)]`: the adapter decides what actually reaches the \
                     wire, so a schema built from the field's Rust type would advertise a \
                     shape serde neither writes nor accepts. Write the `OpenApiSchema` impl \
                     by hand and register it with `OpenApiConfig::register_schema`."
                ),
            ));
        }
        if let Some(word) = autumn_macros_support::schema::variant_directional_skip_on_field(field)
        {
            return Err(syn::Error::new_spanned(
                field,
                format!(
                    "#[derive(OpenApiSchema)] cannot describe a field skipped in only one serde \
                     direction (`#[serde({word})]`): one schema covers both requests and \
                     responses. Use `#[serde(skip)]` if the field should not appear at all, or \
                     write the `OpenApiSchema` impl by hand and register it with \
                     `OpenApiConfig::register_schema`."
                ),
            ));
        }
        reject_undescribable_conditional_skip(field, &input.attrs)?;
        // Same deserialize-only widening as on a variant, one level down.
        // serde accepts an object carrying ONLY the alias, while the emitted
        // schema names the canonical property and marks it `required` — so a
        // validator rejects input the handler takes. `#[serde(deny_unknown_fields)]`
        // sharpens it into a contradiction: the alias key is then forbidden as
        // an additional property AND the canonical one demanded, so no request
        // satisfies the schema and the handler at once.
        if autumn_macros_support::schema::has_serde_alias(&field.attrs) {
            return Err(syn::Error::new_spanned(
                field,
                "#[derive(OpenApiSchema)] cannot describe a field with `#[serde(alias = \"…\")]`: \
                 the alias is accepted when deserializing but never written when serializing, so \
                 one property name cannot be right for both requests and responses — a validator \
                 would reject a request the handler accepts. Use `#[serde(rename = \"…\")]` if the \
                 wire name should change in both directions, or write the `OpenApiSchema` impl by \
                 hand and register it with `OpenApiConfig::register_schema`.",
            ));
        }
        if let Some(key) = autumn_macros_support::schema::serde_split_rename(&field.attrs, "rename")
        {
            return Err(syn::Error::new_spanned(field, split_rename_message(key)));
        }
    }

    Ok(())
}

/// The shared diagnostic for a split `rename_all` / `rename` whose sides differ.
fn split_rename_message(key: &str) -> String {
    format!(
        "#[derive(OpenApiSchema)] cannot describe a split `#[serde({key}(serialize = ..., \
         deserialize = ...))]` whose two sides differ: one schema is advertised for both \
         requests and responses, so a client generated from the serialize spelling would send \
         a value the handler's `Deserialize` rejects. Use a symmetric `{key} = \"...\"`, or \
         write the `OpenApiSchema` impl by hand and register it with \
         `OpenApiConfig::register_schema`."
    )
}

/// Refuse every enum shape whose real wire form the derive cannot describe.
///
/// Split out of [`enum_schema_body`] to keep that function inside the
/// line budget, and because these four checks are one idea: a JSON string enum
/// is only the truth when serde's default representation applies to unit
/// variants with a single, symmetric spelling. Anything else is refused rather
/// than approximated — a confidently wrong contract is worse than no derive,
/// since a generated client acts on it.
/// Refuse a variant carrying `#[serde(untagged)]`.
///
/// serde accepts `untagged` at the VARIANT level as well as the container
/// level, and it means the same thing for that one variant: a unit variant so
/// marked serializes as `null`, not as its name. The container-level check
/// above reads `input.attrs` only, so a mixed enum — ordinary variants plus one
/// untagged catch-all — slipped through and published every variant as a
/// string, including the one that is `null` on the wire (issue #802).
fn reject_untagged_variants(data: &syn::DataEnum) -> syn::Result<()> {
    if let Some(variant) = data
        .variants
        .iter()
        .find(|v| autumn_macros_support::schema::serde_bare_word(&v.attrs, &["untagged"]).is_some())
    {
        return Err(syn::Error::new_spanned(
            variant,
            "#[derive(OpenApiSchema)] cannot describe a variant marked `#[serde(untagged)]`: \
             serde writes it as `null` rather than as its name, so the closed string set the \
             derive would publish is wrong for this variant and a client decodes the wrong \
             type. Write the `OpenApiSchema` impl by hand and register it with \
             `OpenApiConfig::register_schema`.",
        ));
    }
    Ok(())
}

/// Refuse a unit variant carrying `#[serde(alias = "…")]`.
///
/// Split out of [`reject_undescribable_enum`] to keep that function within the
/// crate's line budget; it is one rule, checked in one place.
fn reject_aliased_variants(data: &syn::DataEnum) -> syn::Result<()> {
    // `#[serde(other)]` is the SAME deserialize-only widening as `alias`, one
    // keyword over: the marked variant becomes the catch-all, so serde accepts
    // ANY unrecognised spelling for it. A closed `enum` array listing only the
    // catch-all's own name therefore rejects, on a request, every value the
    // handler happily takes.
    //
    // Checked alongside `alias` rather than in its own pass because they are one
    // rule — "the deserialize side accepts more than the serialize side writes"
    // — and splitting it is how this crate keeps shipping half a guard.
    if let Some(variant) = data
        .variants
        .iter()
        .find(|v| autumn_macros_support::schema::serde_bare_word(&v.attrs, &["other"]).is_some())
    {
        return Err(syn::Error::new_spanned(
            variant,
            "#[derive(OpenApiSchema)] cannot describe a variant marked \
             `#[serde(other)]`: it is serde's catch-all, so deserialization accepts any \
             unrecognised spelling while serialization only ever writes this variant's own \
             name — a closed string set would reject requests the handler accepts. Write the \
             `OpenApiSchema` impl by hand and register it with \
             `OpenApiConfig::register_schema`.",
        ));
    }

    // `#[serde(alias = "…")]` is a DESERIALIZE-only widening: the alias is
    // accepted on input and never written on output. A closed string set built
    // from the canonical spellings is therefore too narrow for a request (an
    // OpenAPI validator rejects a value the handler happily takes) and listing
    // the aliases would make it too wide for a response (advertising values the
    // server never emits). Same asymmetry as a directional skip, same answer.
    if let Some(variant) = data
        .variants
        .iter()
        .find(|v| autumn_macros_support::schema::variant_has_serde_alias(v))
    {
        return Err(syn::Error::new_spanned(
            variant,
            "#[derive(OpenApiSchema)] cannot describe a variant with `#[serde(alias = \"…\")]`: \
             the alias is accepted when deserializing but never written when serializing, so one \
             string set cannot be right for both requests and responses — listing it advertises \
             a value the server never emits, omitting it rejects one the handler accepts. Use \
             `#[serde(rename = \"…\")]` if the wire spelling should change in both directions, \
             or write the `OpenApiSchema` impl by hand and register it with \
             `OpenApiConfig::register_schema`.",
        ));
    }
    Ok(())
}

fn reject_undescribable_enum(input: &DeriveInput, data: &syn::DataEnum) -> syn::Result<()> {
    if let Some(variant) = data
        .variants
        .iter()
        .find(|v| !matches!(v.fields, Fields::Unit))
    {
        return Err(syn::Error::new_spanned(
            variant,
            "#[derive(OpenApiSchema)] supports only enums whose variants are all unit variants \
             (they map to a JSON string enum). For a data-carrying enum, write the \
             `OpenApiSchema` impl by hand and register it with \
             `OpenApiConfig::register_schema`.",
        ));
    }

    // All-unit is not sufficient: a non-default container representation
    // changes what a unit variant serializes to, so the string-enum schema
    // below would be confidently wrong. `#[serde(tag = "t")]` makes each
    // variant the object `{"t":"Variant"}`, and `#[serde(untagged)]` makes it
    // `null` — a generated client built from the string enum would send a bare
    // string to a handler that accepts neither. Refuse rather than guess, on
    // the same reasoning that refuses data-carrying variants.
    if let Some(repr) = autumn_macros_support::schema::serde_enum_representation(&input.attrs) {
        // `untagged` is a bare word; `tag` / `content` take a value. Render each
        // the way it is actually written, so the diagnostic quotes real syntax.
        let (written, becomes) = match repr {
            "untagged" => ("#[serde(untagged)]", "`null`"),
            "tag" => ("#[serde(tag = \"...\")]", "an object"),
            "content" => ("#[serde(content = \"...\")]", "an object"),
            // Conversion attributes route the value through another type
            // entirely, so the variant names never reach the wire at all.
            "into" => (
                "#[serde(into = \"...\")]",
                "whatever the conversion type serializes as",
            ),
            "from" => (
                "#[serde(from = \"...\")]",
                "whatever the conversion type serializes as",
            ),
            _ => (
                "#[serde(try_from = \"...\")]",
                "whatever the conversion type serializes as",
            ),
        };
        return Err(syn::Error::new_spanned(
            input,
            format!(
                "#[derive(OpenApiSchema)] supports only serde's default (externally tagged) \
                 enum representation; `{written}` makes a unit variant serialize as {becomes}, \
                 not the JSON string the derived schema would advertise — a generated client \
                 would send a contract the handler does not accept. Write the `OpenApiSchema` \
                 impl by hand and register it with `OpenApiConfig::register_schema`."
            ),
        ));
    }

    // A variant present in only one serde direction has no correct rendering in
    // a document that describes both. Refuse rather than publish a set that is
    // right for responses and wrong for requests (or the reverse).
    if let Some(variant) = data
        .variants
        .iter()
        .find(|v| autumn_macros_support::schema::variant_directional_skip(v).is_some())
    {
        let attribute = autumn_macros_support::schema::variant_directional_skip(variant)
            .expect("the find predicate just matched it");
        let consequence = if attribute == "skip_deserializing" {
            "it is still serialized, so advertising it would tell a client it may send a \
             value serde rejects as an unknown variant"
        } else {
            "it is still accepted on input, so omitting it would deny a value the handler takes"
        };
        return Err(syn::Error::new_spanned(
            variant,
            format!(
                "#[derive(OpenApiSchema)] cannot describe a variant skipped in only one serde \
                 direction: one schema covers both requests and responses, and with \
                 `#[serde({attribute})]` {consequence}. Use `#[serde(skip)]` if the variant \
                 should not appear at all, or write the `OpenApiSchema` impl by hand and \
                 register it with `OpenApiConfig::register_schema`."
            ),
        ));
    }

    reject_aliased_variants(data)?;
    reject_untagged_variants(data)?;

    // A split rename is the same asymmetry as a directional skip: one schema,
    // two disagreeing wire spellings. Advertising the serialize side would have
    // a generated client send `in_progress` to a handler whose `Deserialize`
    // accepts `inProgress`.
    let split = autumn_macros_support::schema::serde_split_rename(&input.attrs, "rename_all")
        .map(|key| (key, None))
        .or_else(|| {
            data.variants.iter().find_map(|v| {
                autumn_macros_support::schema::serde_split_rename(&v.attrs, "rename")
                    .map(|key| (key, Some(v)))
            })
        });
    if let Some((key, variant)) = split {
        let message = format!(
            "#[derive(OpenApiSchema)] cannot describe a split `#[serde({key}(serialize = ..., \
             deserialize = ...))]` whose two sides differ: one schema is advertised for both \
             requests and responses, so a client generated from the serialize spelling would \
             send a value the handler's `Deserialize` rejects. Use a symmetric \
             `{key} = \"...\"`, or write the `OpenApiSchema` impl by hand and register it with \
             `OpenApiConfig::register_schema`."
        );
        return Err(variant.map_or_else(
            || syn::Error::new_spanned(input, message.clone()),
            |v| syn::Error::new_spanned(v, message.clone()),
        ));
    }
    Ok(())
}

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

    fn derive_str(input: TokenStream) -> String {
        derive_openapi_schema(input).to_string()
    }

    /// The helper attribute roots the generated code at the named crate.
    #[test]
    fn crate_override_roots_the_impl_and_inventory_at_the_named_crate() {
        let out = derive_str(quote! {
            #[openapi_schema(crate = "web_new")]
            struct Params { q: String }
        });
        assert!(
            out.contains(":: web_new :: openapi :: OpenApiSchema"),
            "{out}"
        );
        assert!(
            out.contains(":: web_new :: openapi :: DerivedSchemaDescriptor"),
            "{out}"
        );
        assert!(!out.contains(":: autumn_web"), "leaked default path: {out}");
    }

    /// Enums take the same override.
    #[test]
    fn crate_override_applies_to_enums() {
        let out = derive_str(quote! {
            #[openapi_schema(crate = "web_new")]
            enum Status { Open, Closed }
        });
        assert!(
            out.contains(":: web_new :: openapi :: OpenApiSchema"),
            "{out}"
        );
        assert!(!out.contains(":: autumn_web"), "{out}");
    }

    /// No helper attribute: automatic resolution (the default in unit tests).
    #[test]
    fn no_override_keeps_the_default_root() {
        let out = derive_str(quote! { struct Params { q: String } });
        assert!(
            out.contains(":: autumn_web :: openapi :: OpenApiSchema"),
            "{out}"
        );
    }

    /// An override must not leak into the next expansion.
    #[test]
    fn crate_override_does_not_leak_to_the_next_expansion() {
        let _ = derive_str(quote! {
            #[openapi_schema(crate = "web_new")]
            struct A { q: String }
        });
        assert_eq!(
            autumn_macros_support::crate_path::current_target(),
            "autumn_web"
        );
    }

    fn err_of(input: TokenStream) -> String {
        let out = derive_str(input);
        assert!(out.contains("compile_error"), "expected an error: {out}");
        out
    }

    /// The helper-attribute error, not some other error.
    fn helper_err_of(input: TokenStream) {
        let out = err_of(input);
        assert!(out.contains("expected one"), "{out}");
    }

    #[test]
    fn a_non_string_override_is_refused() {
        err_of(quote! {
            #[openapi_schema(crate = web_new)]
            struct A { q: String }
        });
    }

    #[test]
    fn an_invalid_identifier_override_is_refused() {
        err_of(quote! {
            #[openapi_schema(crate = "not an ident")]
            struct A { q: String }
        });
    }

    /// Only `crate` is known. A typo must fail, not be dropped.
    #[test]
    fn an_unknown_helper_argument_is_refused() {
        helper_err_of(quote! {
            #[openapi_schema(krate = "web_new")]
            struct A { q: String }
        });
    }

    #[test]
    fn an_extra_argument_after_the_override_is_refused() {
        helper_err_of(quote! {
            #[openapi_schema(crate = "a", foo)]
            struct A { q: String }
        });
    }

    #[test]
    fn a_crate_key_without_a_value_is_refused() {
        err_of(quote! {
            #[openapi_schema(crate)]
            struct A { q: String }
        });
    }

    /// A valid override does not hide the usual shape errors.
    #[test]
    fn an_override_on_a_tuple_struct_still_fails_for_the_shape() {
        let out = err_of(quote! {
            #[openapi_schema(crate = "web_new")]
            struct A(String);
        });
        assert!(out.contains("named fields"), "{out}");
    }

    #[test]
    fn the_helper_on_a_field_is_refused() {
        let out = err_of(quote! {
            struct A { #[openapi_schema(crate = "web_new")] q: String }
        });
        assert!(out.contains("struct or enum only"), "{out}");
    }

    #[test]
    fn the_helper_on_a_variant_is_refused() {
        let out = err_of(quote! {
            enum A { #[openapi_schema(crate = "web_new")] Open }
        });
        assert!(out.contains("struct or enum only"), "{out}");
    }

    #[test]
    fn the_helper_on_a_union_field_is_refused() {
        let out = err_of(quote! {
            union U { #[openapi_schema(crate = "web_new")] a: u32, b: f32 }
        });
        assert!(out.contains("struct or enum only"), "{out}");
    }

    #[test]
    fn a_union_is_refused_for_its_shape() {
        let out = err_of(quote! { union U { a: u32, b: f32 } });
        assert!(out.contains("structs and enums"), "{out}");
    }

    #[test]
    fn a_generic_type_is_refused_with_an_override() {
        let out = err_of(quote! {
            #[openapi_schema(crate = "web_new")]
            struct G<T> { q: T }
        });
        assert!(out.contains("generic"), "{out}");
    }

    #[test]
    fn input_that_is_not_an_item_is_refused() {
        let out = derive_str(quote! { not an item });
        assert!(out.contains("compile_error"), "{out}");
    }

    #[test]
    fn the_helper_on_a_tuple_field_is_refused() {
        let out = err_of(quote! {
            struct T(#[openapi_schema(crate = "web_new")] String);
        });
        assert!(out.contains("struct or enum only"), "{out}");
    }

    #[test]
    fn a_bare_helper_attribute_is_refused() {
        helper_err_of(quote! {
            #[openapi_schema]
            struct A { q: String }
        });
    }

    #[test]
    fn a_duplicate_override_is_refused() {
        helper_err_of(quote! {
            #[openapi_schema(crate = "a")]
            #[openapi_schema(crate = "b")]
            struct A { q: String }
        });
    }

    /// Run the struct audit over a fixture, returning the rejection message.
    fn audit(input: &DeriveInput) -> Result<(), String> {
        let Data::Struct(data) = &input.data else {
            panic!("fixture must be a struct");
        };
        let Fields::Named(named) = &data.fields else {
            panic!("fixture must have named fields");
        };
        reject_undescribable_struct(input, named).map_err(|e| e.to_string())
    }

    /// A serialization adapter decides what actually reaches the wire, so the
    /// field's Rust type stops describing it. Rejected rather than guessed:
    /// publishing the Rust shape would be confidently wrong.
    #[test]
    fn a_field_serialize_adapter_is_refused() {
        let err = audit(&parse_quote! {
            struct Money {
                #[serde(serialize_with = "as_string")]
                amount: i64,
            }
        })
        .expect_err("a serialize adapter must be refused");
        assert!(
            err.contains("serialize_with"),
            "the message must name the attribute it refused: {err}"
        );
    }

    /// `with` sets both directions at once, so it is wrong for both.
    #[test]
    fn a_field_with_adapter_is_refused() {
        let err = audit(&parse_quote! {
            struct Money {
                #[serde(with = "string_money")]
                amount: i64,
            }
        })
        .expect_err("a `with` adapter must be refused");
        assert!(err.contains("with"), "{err}");
    }

    /// The deserialize half is refused too, and deliberately: this one schema
    /// serves requests as well as responses, so an adapter on either side makes
    /// it wrong for that side. (`#[model]`'s read schema, which describes only a
    /// response, keeps `deserialize_with` for exactly that reason.)
    #[test]
    fn a_field_deserialize_adapter_is_refused_too() {
        let err = audit(&parse_quote! {
            struct Money {
                #[serde(deserialize_with = "lenient")]
                amount: i64,
            }
        })
        .expect_err("a deserialize adapter must be refused");
        assert!(err.contains("deserialize_with"), "{err}");
    }

    /// The guard must not fire on an ordinary struct — over-rejection would
    /// break every existing derive.
    #[test]
    fn a_plain_struct_still_passes() {
        audit(&parse_quote! {
            struct Plain {
                id: i64,
                #[serde(rename = "displayName")]
                name: String,
                tags: Option<Vec<String>>,
            }
        })
        .expect("a plain struct must still be describable");
    }

    /// `skip_serializing_if` alone IS direction-dependent: the response may
    /// omit the field while serde still demands it on a request.
    #[test]
    fn skip_serializing_if_alone_is_still_refused() {
        let err = audit(&parse_quote! {
            struct Row {
                #[serde(skip_serializing_if = "String::is_empty")]
                tags: String,
            }
        })
        .expect_err("a bare skip_serializing_if on a non-Option field must be refused");
        assert!(err.contains("skip_serializing_if"), "{err}");
    }

    /// Adding a field `#[serde(default)]` makes omission valid in BOTH
    /// directions, so the type becomes describable and must not be refused.
    #[test]
    fn a_field_default_rescues_skip_serializing_if() {
        audit(&parse_quote! {
            struct Row {
                #[serde(default, skip_serializing_if = "String::is_empty")]
                tags: String,
            }
        })
        .expect("default + skip_serializing_if is describable");
    }

    /// The valued spelling counts too.
    #[test]
    fn a_valued_field_default_rescues_skip_serializing_if() {
        audit(&parse_quote! {
            struct Row {
                #[serde(default = "empty_tags", skip_serializing_if = "String::is_empty")]
                tags: String,
            }
        })
        .expect("default = \"path\" + skip_serializing_if is describable");
    }

    /// A CONTAINER default covers every field, so it rescues them too.
    #[test]
    fn a_container_default_rescues_skip_serializing_if() {
        audit(&parse_quote! {
            #[serde(default)]
            struct Row {
                #[serde(skip_serializing_if = "String::is_empty")]
                tags: String,
            }
        })
        .expect("a container default makes every field omissible on input");
    }

    /// A FIELD alias is the same deserialize-only widening as a variant alias.
    /// Missing this while catching the variant form was the fourth instance on
    /// this branch of a rule applied at one level of the syntax tree and not
    /// the other, so both now share one predicate.
    #[test]
    fn a_field_alias_is_refused() {
        let err = audit(&parse_quote! {
            struct Row {
                #[serde(alias = "legacy_name")]
                name: String,
            }
        })
        .expect_err("an aliased field must be refused");
        assert!(err.contains("alias"), "{err}");
    }

    /// Same parser-robustness guarantee the variant scan has.
    #[test]
    fn a_field_alias_after_a_list_valued_attribute_is_still_seen() {
        let err = audit(&parse_quote! {
            struct Row {
                #[serde(bound(deserialize = "T: Clone"), alias = "legacy_name")]
                name: String,
            }
        })
        .expect_err("a field alias behind a list-valued sibling must still be found");
        assert!(err.contains("alias"), "{err}");
    }

    /// A symmetric `rename` on a field must still be describable.
    #[test]
    fn a_renamed_field_is_still_describable() {
        audit(&parse_quote! {
            struct Row {
                #[serde(rename = "displayName")]
                name: String,
            }
        })
        .expect("a symmetric field rename must stay describable");
    }

    fn audit_enum(input: &DeriveInput) -> Result<(), String> {
        let Data::Enum(data) = &input.data else {
            panic!("fixture must be an enum");
        };
        reject_undescribable_enum(input, data).map_err(|e| e.to_string())
    }

    /// `alias` widens what deserialization ACCEPTS without changing what
    /// serialization writes, so no single string set is right for both
    /// directions.
    #[test]
    fn a_variant_alias_is_refused() {
        let err = audit_enum(&parse_quote! {
            enum Status {
                Active,
                #[serde(alias = "legacy")]
                Retired,
            }
        })
        .expect_err("an aliased variant must be refused");
        assert!(err.contains("alias"), "{err}");
    }

    /// The scan must survive a sibling list-valued attribute — the parser bug
    /// class that has bitten this crate before, where an unconsumed `bound(..)`
    /// aborts the walk and the attribute after it reads as absent.
    #[test]
    fn an_alias_after_a_list_valued_attribute_is_still_seen() {
        let err = audit_enum(&parse_quote! {
            #[serde(bound(deserialize = "T: Clone"))]
            enum Status {
                Active,
                #[serde(bound(deserialize = "T: Clone"), alias = "legacy")]
                Retired,
            }
        })
        .expect_err("an alias behind a list-valued sibling must still be found");
        assert!(err.contains("alias"), "{err}");
    }

    /// serde honours `untagged` at the VARIANT level too, where it makes that
    /// one variant serialize as `null` instead of its name. The container-level
    /// guard reads `input.attrs` and never saw it.
    #[test]
    fn a_variant_level_untagged_is_refused() {
        let err = audit_enum(&parse_quote! {
            enum Status {
                Active,
                Retired,
                #[serde(untagged)]
                Unknown,
            }
        })
        .expect_err("a variant-level untagged must be refused");
        assert!(err.contains("untagged"), "{err}");
    }

    /// `#[serde(other)]` is the catch-all: deserialization accepts ANY
    /// unrecognised spelling for that variant, so a closed string set rejects
    /// requests the handler takes. Same deserialize-only widening as `alias`.
    #[test]
    fn a_catch_all_variant_is_refused() {
        let err = audit_enum(&parse_quote! {
            enum Status {
                Active,
                Retired,
                #[serde(other)]
                Unknown,
            }
        })
        .expect_err("a #[serde(other)] catch-all must be refused");
        assert!(err.contains("other"), "{err}");
    }

    /// The container-level form must still be caught — the new variant scan is
    /// an addition, not a replacement.
    #[test]
    fn a_container_level_untagged_is_still_refused() {
        let err = audit_enum(&parse_quote! {
            #[serde(untagged)]
            enum Status {
                Active,
                Retired,
            }
        })
        .expect_err("a container-level untagged must still be refused");
        assert!(err.contains("untagged"), "{err}");
    }

    /// A plain unit enum is the whole point of the derive and must survive both
    /// scans untouched.
    #[test]
    fn a_plain_unit_enum_is_still_describable() {
        audit_enum(&parse_quote! {
            enum Status {
                Active,
                Retired,
            }
        })
        .expect("a plain unit enum must stay describable");
    }

    /// `rename` changes BOTH directions, so it stays describable — the guard
    /// must not swallow the ordinary case.
    #[test]
    fn a_renamed_variant_is_still_describable() {
        audit_enum(&parse_quote! {
            enum Status {
                Active,
                #[serde(rename = "retired")]
                Retired,
            }
        })
        .expect("a symmetric rename must still be describable");
    }

    /// A neighbouring serde key that merely *contains* an adapter name is not
    /// one: the scan matches whole keys.
    #[test]
    fn a_lookalike_key_is_not_an_adapter() {
        audit(&parse_quote! {
            struct Plain {
                #[serde(default = "with_default")]
                amount: i64,
            }
        })
        .expect("`default = \"with_default\"` is not an adapter");
    }
}