use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{Data, DataStruct, DeriveInput, Fields, FieldsNamed};
use super::{named_fields, reject_duplicate_names, unit_struct, wire_names};
fn name_of(declaration: TokenStream2, attribute: &str) -> syn::Result<String> {
let input = item(declaration);
let fields = named_fields(&input, "QueryParams").expect("a struct with named fields");
let mut names = wire_names(&input, fields, attribute)?;
assert_eq!(names.len(), 1, "one field, one name");
Ok(names.remove(0))
}
fn item(declaration: TokenStream2) -> DeriveInput {
syn::parse2(declaration).expect("an item")
}
fn named(declaration: TokenStream2) -> FieldsNamed {
match item(declaration).data {
Data::Struct(DataStruct {
fields: Fields::Named(fields),
..
}) => fields,
_ => panic!("a struct with named fields"),
}
}
#[test]
fn a_wire_name_prefers_the_kynos_rename_then_serdes_then_rename_all_then_the_identifier() {
for rename_all in [false, true] {
for kynos in [None, Some("from_kynos")] {
for serde in [None, Some("from_serde")] {
let container = rename_all.then(|| quote!(#[serde(rename_all = "camelCase")]));
let kynos_attribute = kynos.map(|name| quote!(#[param(rename = #name)]));
let serde_attribute = serde.map(|name| quote!(#[serde(rename = #name)]));
let declaration = quote! {
#container
struct Holder {
#kynos_attribute
#serde_attribute
user_id: u64
}
};
let expected =
kynos
.or(serde)
.unwrap_or(if rename_all { "userId" } else { "user_id" });
assert_eq!(
name_of(declaration, "param").expect("a wire name"),
expected,
"rename_all: {rename_all}, kynos: {kynos:?}, serde: {serde:?}"
);
}
}
}
}
#[test]
fn rename_all_reaches_every_location_attribute() {
for attribute in ["param", "header", "cookie"] {
let declaration = quote! {
#[serde(rename_all = "kebab-case")]
struct Holder {
x_request_id: String
}
};
assert_eq!(
name_of(declaration, attribute).expect("a wire name"),
"x-request-id",
"{attribute}"
);
}
}
#[test]
fn an_alias_is_refused_whatever_else_names_the_field() {
for (shape, attributes) in [
("alone", quote!(#[serde(alias = "userId")])),
(
"beside the Kynos rename",
quote!(#[param(rename = "id")] #[serde(alias = "userId")]),
),
(
"beside serde's rename",
quote!(#[serde(rename = "id", alias = "userId")]),
),
] {
let declaration = quote! {
struct Holder {
#attributes
user_id: u64
}
};
let error = name_of(declaration, "param").expect_err(shape);
assert!(
error.to_string().contains("a second wire name"),
"{shape}: {error}"
);
}
}
#[test]
fn an_unrecognized_key_does_not_swallow_the_keys_after_it() {
for (shape, skipped) in [
("a named value", quote!(unknown = 1)),
("a parenthesized group", quote!(unknown(a, b))),
("a bare path", quote!(unknown)),
] {
let declaration = quote! {
struct Holder {
#[param(#skipped, rename = "chosen")]
user_id: u64
}
};
assert_eq!(
name_of(declaration, "param").expect("a wire name"),
"chosen",
"{shape} must be stepped over, not consumed"
);
}
}
#[test]
fn an_unrecognized_key_alone_is_not_an_error() {
let declaration = quote! {
struct Holder {
#[param(unknown = 1)]
user_id: u64
}
};
assert_eq!(
name_of(declaration, "param").expect("a wire name"),
"user_id"
);
}
#[test]
fn only_the_named_attribute_is_consulted() {
let declaration = quote! {
struct Holder {
#[header(rename = "X-Other")]
user_id: u64
}
};
assert_eq!(
name_of(declaration, "param").expect("a wire name"),
"user_id"
);
}
#[test]
fn named_fields_accepts_a_struct_with_named_fields() {
let input = item(quote!(
struct Query {
page: u32,
}
));
assert!(named_fields(&input, "QueryParams").is_ok());
}
#[test]
fn a_unit_struct_is_accepted_however_it_is_spelled() {
for declaration in [
quote!(
struct Users;
),
quote!(
struct Users {}
),
] {
let input = item(declaration);
assert!(unit_struct(&input, "Tag", "names a group of operations").is_ok());
}
}
#[test]
fn distinct_names_are_not_duplicates() {
let fields = named(quote!(
struct Query {
page: u32,
size: u32,
}
));
let names = ["page".to_owned(), "size".to_owned()];
assert!(reject_duplicate_names(&fields, &names, "parameter").is_ok());
}
#[test]
fn a_duplicate_names_the_field_that_claimed_it_first() {
let fields = named(quote!(
struct Query {
page: u32,
offset: u32,
}
));
let names = ["cursor".to_owned(), "cursor".to_owned()];
let error = reject_duplicate_names(&fields, &names, "parameter")
.expect_err("two fields on one wire name must be refused");
let reported = error.to_string();
assert!(reported.contains("two fields declare the parameter `cursor`"));
assert!(reported.contains("the first is `page`"));
}
#[expect(clippy::too_many_lines)]
fn cases() -> Vec<(&'static str, syn::Result<()>, &'static str)> {
fn shape(input: &DeriveInput) -> syn::Result<()> {
named_fields(input, "QueryParams").map(|_| ())
}
fn unit(input: &DeriveInput) -> syn::Result<()> {
unit_struct(input, "Tag", "names a group of operations")
}
fn named_by(declaration: TokenStream2) -> syn::Result<()> {
name_of(declaration, "param").map(|_| ())
}
let duplicate = named(quote!(
struct Query {
page: u32,
offset: u32,
}
));
vec![
(
"named fields asked of a tuple struct",
shape(&item(quote!(
struct Query(u32);
))),
"needs a struct with named fields",
),
(
"named fields asked of an enum",
shape(&item(quote!(
enum Query {
A,
}
))),
"which an enum is not",
),
(
"named fields asked of a union",
shape(&item(quote!(
union Query {
a: u32,
}
))),
"cannot describe a union",
),
(
"a unit struct asked of a struct with fields",
unit(&item(quote!(
struct Users {
name: String,
}
))),
"carries no fields",
),
(
"a unit struct asked of an enum",
unit(&item(quote!(
enum Users {
A,
}
))),
"must be a unit struct",
),
(
"a unit struct asked of a union",
unit(&item(quote!(
union Users {
a: u32,
}
))),
"must be a unit struct",
),
(
"serde's split rename, which gives one field two wire names",
named_by(quote!(
struct Holder {
#[serde(rename(serialize = "a", deserialize = "b"))]
user_id: u64,
}
)),
"two wire names",
),
(
"serde's alias, which gives one field a second wire name",
named_by(quote!(
struct Holder {
#[serde(alias = "userId")]
user_id: u64,
}
)),
"a second wire name",
),
(
"serde's split rename_all, which gives every field two wire names",
named_by(quote!(
#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
struct Holder {
user_id: u64,
}
)),
"split `rename_all`",
),
(
"two fields on one wire name",
reject_duplicate_names(
&duplicate,
&["cursor".to_owned(), "cursor".to_owned()],
"parameter",
),
"two fields declare the",
),
]
}
#[test]
fn each_case_raises_the_diagnostic_it_names() {
for (description, outcome, expected) in cases() {
let Err(error) = outcome else {
panic!("{description} must be rejected");
};
let reported = error.to_string();
assert!(
reported.contains(expected),
"{description}: expected a diagnostic containing {expected:?}, got {reported:?}"
);
}
}
#[test]
fn every_shared_diagnostic_has_a_case() {
const SOURCE: &str = include_str!("../common.rs");
let implementation = SOURCE
.split_once("\n#[cfg(test)]")
.map_or(SOURCE, |(before, _)| before);
let sites = implementation.matches("syn::Error::new(").count()
+ implementation.matches("meta.error(").count();
assert_eq!(
cases().len(),
sites,
"`common.rs` raises {sites} diagnostic(s) and {} have a case",
cases().len()
);
}