Skip to main content

zeroclaw_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::{ToTokens, quote};
3use syn::{
4    Data, DeriveInput, Fields, GenericArgument, Lit, Meta, PathArguments, parse_macro_input,
5};
6
7/// Check if a type is a known compound container (Vec, HashMap, etc.)
8/// that should be skipped from property enumeration.
9fn is_compound_type(ty: &syn::Type) -> bool {
10    let syn::Type::Path(type_path) = ty else {
11        return false;
12    };
13    let Some(ident) = type_path.path.segments.last().map(|s| &s.ident) else {
14        return false;
15    };
16    ident == "Vec" || ident == "HashMap" || ident == "PathBuf"
17}
18
19/// Check if any `#[serde(...)]` attribute on the field contains `skip`.
20fn has_serde_skip(field: &syn::Field) -> bool {
21    for attr in &field.attrs {
22        if attr.path().is_ident("serde") {
23            // Parse the token stream inside the parens and look for `skip`
24            if let Ok(nested) = attr.parse_args_with(
25                syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
26            ) {
27                for meta in &nested {
28                    if meta.path().is_ident("skip") {
29                        return true;
30                    }
31                }
32            }
33        }
34    }
35    false
36}
37
38/// Derive macro that generates secret and property methods for config structs.
39///
40/// # Attributes
41///
42/// - `#[secret]` on a `String` or `Option<String>` field marks it as a secret.
43/// - `#[nested]` on a nested struct or `Option<StructWithSecrets>` field
44///   delegates secret discovery and setting to the child.
45/// - `#[prefix = "channels.matrix"]` on the struct sets the dotted path prefix.
46///
47/// # Generated methods
48///
49/// ## Secret methods (unchanged)
50/// - `secret_fields(&self) -> Vec<SecretFieldInfo>`
51/// - `set_secret(&mut self, name: &str, value: String) -> Result<()>`
52/// - `encrypt_secrets(&mut self, store: &SecretStore) -> Result<()>`
53/// - `decrypt_secrets(&mut self, store: &SecretStore) -> Result<()>`
54///
55/// ## Property methods (new)
56/// - `prop_fields(&self) -> Vec<PropFieldInfo>` — enumerate all fields
57/// - `get_prop(&self, name: &str) -> Result<String>` — get current value as string
58/// - `set_prop(&mut self, name: &str, value_str: &str) -> Result<()>` — parse string and set
59/// - `prop_is_secret(name: &str) -> bool` — static check
60/// - `init_defaults(&mut self, prefix: Option<&str>) -> Vec<&'static str>` — instantiate None nested sections
61#[proc_macro_derive(Configurable, attributes(secret, nested, prefix, serde))]
62pub fn derive_configurable(input: TokenStream) -> TokenStream {
63    let input = parse_macro_input!(input as DeriveInput);
64    let struct_name = &input.ident;
65
66    let prefix = extract_prefix(&input);
67    let category = derive_category(&prefix);
68
69    let fields = match &input.data {
70        Data::Struct(data) => match &data.fields {
71            Fields::Named(fields) => &fields.named,
72            _ => {
73                return syn::Error::new_spanned(
74                    &input,
75                    "Configurable only supports structs with named fields",
76                )
77                .to_compile_error()
78                .into();
79            }
80        },
81        _ => {
82            return syn::Error::new_spanned(&input, "Configurable can only be derived for structs")
83                .to_compile_error()
84                .into();
85        }
86    };
87
88    // ── Secret codegen accumulators (unchanged) ──
89    let mut secret_field_entries = Vec::new();
90    let mut set_arms = Vec::new();
91    let mut encrypt_ops = Vec::new();
92    let mut decrypt_ops = Vec::new();
93    let mut nested_collect = Vec::new();
94    let mut nested_set = Vec::new();
95    let mut nested_encrypt = Vec::new();
96    let mut nested_decrypt = Vec::new();
97
98    // ── Property codegen accumulators ──
99    let mut prop_field_entries = Vec::new();
100    let mut prop_names: Vec<String> = Vec::new();
101    let mut prop_kind_tokens = Vec::new();
102    let mut prop_is_option_flags = Vec::new();
103    let mut prop_is_secret_arms = Vec::new();
104    let mut nested_prop_fields = Vec::new();
105    let mut nested_get_prop = Vec::new();
106    let mut nested_set_prop = Vec::new();
107    let mut nested_prop_is_secret = Vec::new();
108    let mut init_defaults_ops = Vec::new();
109
110    for field in fields {
111        let field_ident = field.ident.as_ref().expect("Named field must have ident");
112        let is_secret = has_attr(field, "secret");
113        let is_nested = has_attr(field, "nested");
114        let serde_skip = has_serde_skip(field);
115
116        // ── Secret handling ──
117        if is_secret {
118            let field_name_kebab = snake_to_kebab(&field_ident.to_string());
119            let full_name = if prefix.is_empty() {
120                field_name_kebab.clone()
121            } else {
122                format!("{}.{}", prefix, field_name_kebab)
123            };
124
125            let is_option = is_option_type(&field.ty);
126            let is_vec_string = extract_vec_inner(&field.ty)
127                .map(|inner| inner.to_token_stream().to_string() == "String")
128                .unwrap_or(false);
129            let full_name_lit = &full_name;
130            let category_lit = &category;
131
132            if is_vec_string {
133                // Vec<String> with #[secret]: iterate elements for encrypt/decrypt
134                secret_field_entries.push(quote! {
135                    crate::config::SecretFieldInfo {
136                        name: #full_name_lit,
137                        category: #category_lit,
138                        is_set: !self.#field_ident.is_empty(),
139                    }
140                });
141                encrypt_ops.push(quote! {
142                    for element in &mut self.#field_ident {
143                        if !element.is_empty() && !crate::security::SecretStore::is_encrypted(element) {
144                            *element = store.encrypt(element)
145                                .with_context(|| format!("Failed to encrypt {}[]", #full_name_lit))?;
146                        }
147                    }
148                });
149                decrypt_ops.push(quote! {
150                    for element in &mut self.#field_ident {
151                        if crate::security::SecretStore::is_encrypted(element) {
152                            *element = store.decrypt(element)
153                                .with_context(|| format!("Failed to decrypt {}[]", #full_name_lit))?;
154                        }
155                    }
156                });
157            } else if is_option {
158                secret_field_entries.push(quote! {
159                    crate::config::SecretFieldInfo {
160                        name: #full_name_lit,
161                        category: #category_lit,
162                        is_set: self.#field_ident.as_ref().is_some_and(|v| !v.is_empty()),
163                    }
164                });
165                set_arms.push(quote! {
166                    #full_name_lit => { self.#field_ident = Some(value); Ok(()) }
167                });
168                encrypt_ops.push(quote! {
169                    if let Some(raw) = &self.#field_ident {
170                        if !crate::security::SecretStore::is_encrypted(raw) {
171                            self.#field_ident = Some(
172                                store.encrypt(raw)
173                                    .with_context(|| format!("Failed to encrypt {}", #full_name_lit))?
174                            );
175                        }
176                    }
177                });
178                decrypt_ops.push(quote! {
179                    if let Some(raw) = &self.#field_ident {
180                        if crate::security::SecretStore::is_encrypted(raw) {
181                            self.#field_ident = Some(
182                                store.decrypt(raw)
183                                    .with_context(|| format!("Failed to decrypt {}", #full_name_lit))?
184                            );
185                        }
186                    }
187                });
188            } else {
189                secret_field_entries.push(quote! {
190                    crate::config::SecretFieldInfo {
191                        name: #full_name_lit,
192                        category: #category_lit,
193                        is_set: !self.#field_ident.is_empty(),
194                    }
195                });
196                set_arms.push(quote! {
197                    #full_name_lit => { self.#field_ident = value; Ok(()) }
198                });
199                encrypt_ops.push(quote! {
200                    if !self.#field_ident.is_empty() && !crate::security::SecretStore::is_encrypted(&self.#field_ident) {
201                        self.#field_ident = store.encrypt(&self.#field_ident)
202                            .with_context(|| format!("Failed to encrypt {}", #full_name_lit))?;
203                    }
204                });
205                decrypt_ops.push(quote! {
206                    if crate::security::SecretStore::is_encrypted(&self.#field_ident) {
207                        self.#field_ident = store.decrypt(&self.#field_ident)
208                            .with_context(|| format!("Failed to decrypt {}", #full_name_lit))?;
209                    }
210                });
211            }
212        }
213
214        if is_nested {
215            // ── Nested delegation ──
216            let is_option = is_option_type(&field.ty);
217            let hashmap_value_ty = extract_hashmap_value_type(&field.ty);
218
219            if let Some(value_ty) = hashmap_value_ty {
220                // HashMap<String, T> with #[nested]: iterate values for secret ops
221                nested_collect.push(quote! {
222                    for inner in self.#field_ident.values() {
223                        fields.extend(inner.secret_fields());
224                    }
225                });
226                nested_set.push(quote! {
227                    for inner in self.#field_ident.values_mut() {
228                        if let Ok(()) = inner.set_secret(name, value.clone()) {
229                            return Ok(());
230                        }
231                    }
232                });
233                nested_encrypt.push(quote! {
234                    for inner in self.#field_ident.values_mut() {
235                        inner.encrypt_secrets(store)?;
236                    }
237                });
238                nested_decrypt.push(quote! {
239                    for inner in self.#field_ident.values_mut() {
240                        inner.decrypt_secrets(store)?;
241                    }
242                });
243                nested_prop_is_secret.push(quote! {
244                    if <#value_ty>::prop_is_secret(name) { return true; }
245                });
246
247                continue;
248            } else if is_option {
249                nested_collect.push(quote! {
250                    if let Some(inner) = &self.#field_ident {
251                        fields.extend(inner.secret_fields());
252                    }
253                });
254                nested_set.push(quote! {
255                    if let Some(inner) = &mut self.#field_ident {
256                        if let Ok(()) = inner.set_secret(name, value.clone()) {
257                            return Ok(());
258                        }
259                    }
260                });
261                nested_encrypt.push(quote! {
262                    if let Some(inner) = &mut self.#field_ident {
263                        inner.encrypt_secrets(store)?;
264                    }
265                });
266                nested_decrypt.push(quote! {
267                    if let Some(inner) = &mut self.#field_ident {
268                        inner.decrypt_secrets(store)?;
269                    }
270                });
271
272                // ── Nested property delegation (Option<T>) ──
273                nested_prop_fields.push(quote! {
274                    if let Some(inner) = &self.#field_ident {
275                        fields.extend(inner.prop_fields());
276                    }
277                });
278                nested_get_prop.push(quote! {
279                    if let Some(inner) = &self.#field_ident {
280                        if let Ok(val) = inner.get_prop(name) {
281                            return Ok(val);
282                        }
283                    }
284                });
285                nested_set_prop.push(quote! {
286                    if let Some(inner) = &mut self.#field_ident {
287                        if let Ok(()) = inner.set_prop(name, value_str) {
288                            return Ok(());
289                        }
290                    }
291                });
292                nested_prop_is_secret.push(quote! {
293                    // Extract inner type from Option for static dispatch
294                    // We need to know the inner type at compile time
295                });
296
297                // For Option<T> nested, extract inner type for Default::default()
298                if let Some(inner_ty) = extract_option_inner(&field.ty) {
299                    let inner_ty_tokens = quote! { #inner_ty };
300                    init_defaults_ops.push(quote! {
301                        if self.#field_ident.is_none() {
302                            let child_prefix = <#inner_ty_tokens>::configurable_prefix();
303                            let dominated = prefix.map_or(true, |p| {
304                                child_prefix.starts_with(p) || p.starts_with(child_prefix)
305                            });
306                            if dominated {
307                                let mut probe = <#inner_ty_tokens as Default>::default();
308                                let child_results = probe.init_defaults(prefix);
309                                initialized.push(child_prefix);
310                                initialized.extend(child_results);
311                                self.#field_ident = Some(probe);
312                            }
313                        } else if let Some(inner) = &mut self.#field_ident {
314                            initialized.extend(inner.init_defaults(prefix));
315                        }
316                    });
317
318                    // For prop_is_secret delegation on Option<T> nested, we need the inner type
319                    nested_prop_is_secret.pop(); // Remove the placeholder
320                    nested_prop_is_secret.push(quote! {
321                        if <#inner_ty_tokens>::prop_is_secret(name) {
322                            return true;
323                        }
324                    });
325                }
326            } else {
327                nested_collect.push(quote! {
328                    fields.extend(self.#field_ident.secret_fields());
329                });
330                nested_set.push(quote! {
331                    if let Ok(()) = self.#field_ident.set_secret(name, value.clone()) {
332                        return Ok(());
333                    }
334                });
335                nested_encrypt.push(quote! {
336                    self.#field_ident.encrypt_secrets(store)?;
337                });
338                nested_decrypt.push(quote! {
339                    self.#field_ident.decrypt_secrets(store)?;
340                });
341
342                // ── Nested property delegation (non-Option) ──
343                nested_prop_fields.push(quote! {
344                    fields.extend(self.#field_ident.prop_fields());
345                });
346                nested_get_prop.push(quote! {
347                    if let Ok(val) = self.#field_ident.get_prop(name) {
348                        return Ok(val);
349                    }
350                });
351                nested_set_prop.push(quote! {
352                    if let Ok(()) = self.#field_ident.set_prop(name, value_str) {
353                        return Ok(());
354                    }
355                });
356
357                // Get the field type for static method dispatch
358                let field_ty = &field.ty;
359                nested_prop_is_secret.push(quote! {
360                    if <#field_ty>::prop_is_secret(name) {
361                        return true;
362                    }
363                });
364
365                // init_defaults for non-Option nested: delegate
366                init_defaults_ops.push(quote! {
367                    initialized.extend(self.#field_ident.init_defaults(prefix));
368                });
369            }
370
371            continue; // nested fields handled above
372        }
373
374        // ── Property handling for non-nested, non-skip fields ──
375        if serde_skip {
376            continue;
377        }
378
379        // Unwrap Option<T> → T for type inspection
380        let is_option = is_option_type(&field.ty);
381        let inner_ty = extract_option_inner(&field.ty).unwrap_or(&field.ty);
382
383        // Skip compound types (Vec, HashMap, PathBuf)
384        if is_compound_type(inner_ty) {
385            continue;
386        }
387
388        let field_name_kebab = snake_to_kebab(&field_ident.to_string());
389        let serde_name = field_ident.to_string();
390        let full_name = if prefix.is_empty() {
391            field_name_kebab.clone()
392        } else {
393            format!("{}.{}", prefix, field_name_kebab)
394        };
395        let full_name_lit = &full_name;
396        let serde_name_lit = &serde_name;
397        let category_lit = &category;
398        let type_str = field.ty.to_token_stream().to_string().replace(' ', "");
399        let type_hint_lit = &type_str;
400
401        // PropKind resolved at compile time via HasPropKind trait.
402        // All field types must implement HasPropKind — scalars in traits.rs,
403        // config enums in schema.rs via impl_enum_prop_kind!.
404        let kind_token = quote! { <#inner_ty as crate::config::HasPropKind>::PROP_KIND };
405        let enum_variants_expr = quote! {
406            if <#inner_ty as crate::config::HasPropKind>::PROP_KIND == crate::config::PropKind::Enum {
407                Some(|| {
408                    crate::config::enum_variants::<#inner_ty>()
409                        .split(", ")
410                        .map(|s| s.to_string())
411                        .collect()
412                })
413            } else {
414                None
415            }
416        };
417
418        if is_secret {
419            prop_is_secret_arms.push(quote! { #full_name_lit => true, });
420        }
421
422        prop_names.push(full_name.clone());
423        prop_kind_tokens.push(kind_token.clone());
424        prop_is_option_flags.push(is_option);
425
426        prop_field_entries.push(quote! {
427            crate::config::make_prop_field(
428                __table.as_ref(),
429                #full_name_lit,
430                #serde_name_lit,
431                #category_lit,
432                #type_hint_lit,
433                #kind_token,
434                #is_secret,
435                #enum_variants_expr,
436            )
437        });
438    }
439
440    let prefix_lit = &prefix;
441
442    let expanded = quote! {
443        impl #struct_name {
444            /// Returns the `#[prefix]` value for this Configurable struct.
445            pub fn configurable_prefix() -> &'static str {
446                #prefix_lit
447            }
448
449            /// Returns metadata about all `#[secret]` fields on this struct and nested children.
450            pub fn secret_fields(&self) -> Vec<crate::config::SecretFieldInfo> {
451                let mut fields = vec![#(#secret_field_entries),*];
452                #(#nested_collect)*
453                fields
454            }
455
456            /// Encrypt all secret fields in place using the provided store.
457            pub fn encrypt_secrets(&mut self, store: &crate::security::SecretStore) -> anyhow::Result<()> {
458                use anyhow::Context;
459                #(#encrypt_ops)*
460                #(#nested_encrypt)*
461                Ok(())
462            }
463
464            /// Decrypt all secret fields in place using the provided store.
465            pub fn decrypt_secrets(&mut self, store: &crate::security::SecretStore) -> anyhow::Result<()> {
466                use anyhow::Context;
467                #(#decrypt_ops)*
468                #(#nested_decrypt)*
469                Ok(())
470            }
471
472            /// Set a secret field by its full dotted name, dispatching to nested children.
473            pub fn set_secret(&mut self, name: &str, value: String) -> anyhow::Result<()> {
474                // Try direct secret fields first
475                match name {
476                    #(#set_arms,)*
477                    _ => {
478                        // Try nested children
479                        #(#nested_set)*
480                        anyhow::bail!("Unknown secret '{}'", name)
481                    }
482                }
483            }
484
485            /// Returns metadata about all property fields on this struct and nested children.
486            pub fn prop_fields(&self) -> Vec<crate::config::PropFieldInfo> {
487                let __table = toml::Value::try_from(self)
488                    .ok()
489                    .and_then(|v| match v { toml::Value::Table(t) => Some(t), _ => None });
490                let mut fields = vec![#(#prop_field_entries),*];
491                #(#nested_prop_fields)*
492                fields
493            }
494
495            /// Get a property value by its full dotted name, returning it as a display string.
496            pub fn get_prop(&self, name: &str) -> anyhow::Result<String> {
497                #(#nested_get_prop)*
498                const KNOWN: &[&str] = &[#(#prop_names),*];
499                if !KNOWN.contains(&name) {
500                    anyhow::bail!("Unknown property '{}'", name);
501                }
502                crate::config::serde_get_prop(self, Self::configurable_prefix(), name, Self::prop_is_secret(name))
503            }
504
505            /// Set a property value by its full dotted name, parsing from string.
506            pub fn set_prop(&mut self, name: &str, value_str: &str) -> anyhow::Result<()> {
507                #(#nested_set_prop)*
508                const KNOWN: &[&str] = &[#(#prop_names),*];
509                const KINDS: &[crate::config::PropKind] = &[#(#prop_kind_tokens),*];
510                const IS_OPTION: &[bool] = &[#(#prop_is_option_flags),*];
511                let idx = KNOWN.iter().position(|&n| n == name)
512                    .ok_or_else(|| anyhow::anyhow!("Unknown property '{}'", name))?;
513                crate::config::serde_set_prop(self, Self::configurable_prefix(), name, value_str, KINDS[idx], IS_OPTION[idx])
514            }
515
516            /// Check if a property name refers to a secret field (static, no instance needed).
517            pub fn prop_is_secret(name: &str) -> bool {
518                match name {
519                    #(#prop_is_secret_arms)*
520                    _ => {
521                        #(#nested_prop_is_secret)*
522                        false
523                    }
524                }
525            }
526
527            /// Instantiate `None` nested sections whose prefix matches.
528            /// Returns the prefixes that were initialized.
529            pub fn init_defaults(&mut self, prefix: Option<&str>) -> Vec<&'static str> {
530                let mut initialized: Vec<&'static str> = Vec::new();
531                #(#init_defaults_ops)*
532                initialized
533            }
534        }
535    };
536
537    TokenStream::from(expanded)
538}
539
540fn derive_category(prefix: &str) -> String {
541    if prefix.is_empty() {
542        return "Core".to_string();
543    }
544    let first = prefix.split('.').next().unwrap_or("");
545    match first {
546        "channels" => "Channels".to_string(),
547        "tts" => "TTS".to_string(),
548        "transcription" => "Transcription".to_string(),
549        other => {
550            let mut chars = other.chars();
551            match chars.next() {
552                Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
553                None => "Core".to_string(),
554            }
555        }
556    }
557}
558
559fn extract_prefix(input: &DeriveInput) -> String {
560    for attr in &input.attrs {
561        if !attr.path().is_ident("prefix") {
562            continue;
563        }
564        let Meta::NameValue(nv) = &attr.meta else {
565            continue;
566        };
567        let syn::Expr::Lit(expr_lit) = &nv.value else {
568            continue;
569        };
570        let Lit::Str(lit_str) = &expr_lit.lit else {
571            continue;
572        };
573        return lit_str.value();
574    }
575    String::new()
576}
577
578fn has_attr(field: &syn::Field, name: &str) -> bool {
579    field.attrs.iter().any(|attr| attr.path().is_ident(name))
580}
581
582fn snake_to_kebab(s: &str) -> String {
583    s.replace('_', "-")
584}
585
586fn is_option_type(ty: &syn::Type) -> bool {
587    let syn::Type::Path(type_path) = ty else {
588        return false;
589    };
590    type_path
591        .path
592        .segments
593        .last()
594        .is_some_and(|s| s.ident == "Option")
595}
596
597/// Extract the Nth type argument from a generic type matching `expected_ident`.
598/// e.g. `extract_type_arg("Option", 0, ty)` returns `T` from `Option<T>`.
599fn extract_type_arg<'a>(
600    expected_ident: &str,
601    index: usize,
602    ty: &'a syn::Type,
603) -> Option<&'a syn::Type> {
604    let syn::Type::Path(type_path) = ty else {
605        return None;
606    };
607    let segment = type_path.path.segments.last()?;
608    if segment.ident != expected_ident {
609        return None;
610    }
611    let PathArguments::AngleBracketed(args) = &segment.arguments else {
612        return None;
613    };
614    args.args
615        .iter()
616        .filter_map(|a| {
617            if let GenericArgument::Type(t) = a {
618                Some(t)
619            } else {
620                None
621            }
622        })
623        .nth(index)
624}
625
626fn extract_option_inner(ty: &syn::Type) -> Option<&syn::Type> {
627    extract_type_arg("Option", 0, ty)
628}
629fn extract_vec_inner(ty: &syn::Type) -> Option<&syn::Type> {
630    extract_type_arg("Vec", 0, ty)
631}
632fn extract_hashmap_value_type(ty: &syn::Type) -> Option<&syn::Type> {
633    extract_type_arg("HashMap", 1, ty)
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use syn::parse_quote;
640
641    #[test]
642    fn snake_to_kebab_converts_underscores() {
643        assert_eq!(snake_to_kebab("access_token"), "access-token");
644        assert_eq!(snake_to_kebab("api_key"), "api-key");
645        assert_eq!(snake_to_kebab("bot_token"), "bot-token");
646        assert_eq!(snake_to_kebab("simple"), "simple");
647    }
648
649    #[test]
650    fn derive_category_from_prefix() {
651        assert_eq!(derive_category("channels.matrix"), "Channels");
652        assert_eq!(derive_category("channels.discord"), "Channels");
653        assert_eq!(derive_category("tts.openai"), "TTS");
654        assert_eq!(derive_category("tts.elevenlabs"), "TTS");
655        assert_eq!(derive_category("transcription"), "Transcription");
656        assert_eq!(derive_category("transcription.openai"), "Transcription");
657        assert_eq!(derive_category(""), "Core");
658    }
659
660    #[test]
661    fn has_serde_skip_detects_skip() {
662        let field: syn::Field = parse_quote! {
663            #[serde(skip)]
664            pub workspace_dir: String
665        };
666        assert!(has_serde_skip(&field));
667    }
668
669    #[test]
670    fn has_serde_skip_ignores_other_serde_attrs() {
671        let field: syn::Field = parse_quote! {
672            #[serde(default)]
673            pub enabled: bool
674        };
675        assert!(!has_serde_skip(&field));
676
677        let field: syn::Field = parse_quote! {
678            #[serde(default, skip_serializing_if = "Option::is_none")]
679            pub value: Option<String>
680        };
681        assert!(!has_serde_skip(&field));
682    }
683
684    #[test]
685    fn has_serde_skip_no_serde_attr() {
686        let field: syn::Field = parse_quote! {
687            pub name: String
688        };
689        assert!(!has_serde_skip(&field));
690    }
691
692    #[test]
693    fn has_serde_skip_with_other_attrs() {
694        let field: syn::Field = parse_quote! {
695            #[secret]
696            #[serde(skip)]
697            pub token: String
698        };
699        assert!(has_serde_skip(&field));
700    }
701}