Skip to main content

gen_macros/
lib.rs

1//! `gen-macros` — proc-macros that auto-implement the universal
2//! `gen_types::ecosystem` traits.
3//!
4//! Materializes the Pillar 12 directive (generation over composition):
5//! a new package-manager adapter declares its typed shapes and the
6//! macros emit the `Spec` / `QuirkRegistry` impls. The author writes
7//! N lines of typed data; the trait surface is mechanical.
8//!
9//! ```ignore
10//! #[derive(SpecShape, serde::Serialize, serde::Deserialize)]
11//! #[spec(args = "BuildRustCrateArgs", quirk = "CrateQuirk")]
12//! pub struct BuildSpec {
13//!     pub version: u32,
14//!     pub crates: indexmap::IndexMap<String, CrateSpec>,
15//!     pub root_crate: String,
16//!     pub workspace_members: Vec<String>,
17//! }
18//! ```
19//!
20//! emits:
21//!
22//! ```ignore
23//! impl gen_types::Spec for BuildSpec {
24//!     type Args = BuildRustCrateArgs;
25//!     type Quirk = CrateQuirk;
26//!     fn schema_version(&self) -> u32 { self.version }
27//!     fn root_key(&self) -> &str { self.root_crate.as_str() }
28//!     fn member_keys(&self) -> Vec<&str> { self.workspace_members.iter().map(String::as_str).collect() }
29//!     fn args_for(&self, key: &str) -> Option<&Self::Args> {
30//!         self.crates.get(key).map(|c| &c.build_rust_crate_args)
31//!     }
32//!     fn quirks_for(&self, key: &str) -> &[Self::Quirk] {
33//!         self.crates.get(key).map(|c| c.quirks.as_slice()).unwrap_or(&[])
34//!     }
35//! }
36//! ```
37
38use proc_macro::TokenStream;
39use quote::quote;
40use syn::{Data, DeriveInput, Fields, Lit, Meta, parse_macro_input};
41
42/// `#[derive(SpecShape)]` — auto-implement `gen_types::Spec` on a
43/// struct whose fields follow the conventional gen build-spec shape:
44///
45/// - `version: u32`
46/// - `root_crate: String` (or `root_key: String`, opt-in via attr)
47/// - `workspace_members: Vec<String>`
48/// - `crates: IndexMap<String, T>` where `T` carries
49///   `build_rust_crate_args: Args` + `quirks: Vec<Quirk>`
50///
51/// Required attribute:
52/// `#[spec(args = "<ArgsTypeName>", quirk = "<QuirkTypeName>")]`
53///
54/// Optional attributes:
55/// `#[spec(args_field = "build_args")]` (default: `build_rust_crate_args`)
56/// `#[spec(root_field = "root_key")]`   (default: `root_crate`)
57/// `#[spec(members_field = "members")]` (default: `workspace_members`)
58/// `#[spec(crates_field = "packages")]` (default: `crates`)
59#[proc_macro_derive(SpecShape, attributes(spec))]
60pub fn derive_spec_shape(input: TokenStream) -> TokenStream {
61    let input = parse_macro_input!(input as DeriveInput);
62    let name = &input.ident;
63
64    let mut args_type: Option<String> = None;
65    let mut quirk_type: Option<String> = None;
66    let mut args_field = "build_rust_crate_args".to_string();
67    let mut root_field = "root_crate".to_string();
68    let mut members_field = "workspace_members".to_string();
69    let mut crates_field = "crates".to_string();
70
71    for attr in &input.attrs {
72        if !attr.path().is_ident("spec") {
73            continue;
74        }
75        let Meta::List(list) = &attr.meta else {
76            continue;
77        };
78        let _ = list.parse_nested_meta(|meta| {
79            let Some(ident) = meta.path.get_ident() else {
80                return Ok(());
81            };
82            let value: Lit = meta.value()?.parse()?;
83            let Lit::Str(s) = value else {
84                return Ok(());
85            };
86            let v = s.value();
87            match ident.to_string().as_str() {
88                "args" => args_type = Some(v),
89                "quirk" => quirk_type = Some(v),
90                "args_field" => args_field = v,
91                "root_field" => root_field = v,
92                "members_field" => members_field = v,
93                "crates_field" => crates_field = v,
94                _ => {}
95            }
96            Ok(())
97        });
98    }
99
100    let args_type = match args_type {
101        Some(t) => syn::parse_str::<syn::Type>(&t).expect("invalid `args` type"),
102        None => {
103            return TokenStream::from(quote! {
104                compile_error!("SpecShape requires `#[spec(args = \"<TypeName>\", quirk = \"<TypeName>\")]`");
105            });
106        }
107    };
108    let quirk_type = match quirk_type {
109        Some(t) => syn::parse_str::<syn::Type>(&t).expect("invalid `quirk` type"),
110        None => {
111            return TokenStream::from(quote! {
112                compile_error!("SpecShape requires `#[spec(args = \"<TypeName>\", quirk = \"<TypeName>\")]`");
113            });
114        }
115    };
116
117    let args_field_ident = syn::Ident::new(&args_field, proc_macro2::Span::call_site());
118    let root_field_ident = syn::Ident::new(&root_field, proc_macro2::Span::call_site());
119    let members_field_ident = syn::Ident::new(&members_field, proc_macro2::Span::call_site());
120    let crates_field_ident = syn::Ident::new(&crates_field, proc_macro2::Span::call_site());
121
122    let expanded = quote! {
123        impl ::gen_types::Spec for #name {
124            type Args = #args_type;
125            type Quirk = #quirk_type;
126
127            fn schema_version(&self) -> u32 {
128                self.version
129            }
130
131            fn root_key(&self) -> &str {
132                self.#root_field_ident.as_str()
133            }
134
135            fn member_keys(&self) -> ::std::vec::Vec<&str> {
136                self.#members_field_ident.iter().map(::std::string::String::as_str).collect()
137            }
138
139            fn args_for(&self, key: &str) -> ::std::option::Option<&Self::Args> {
140                self.#crates_field_ident.get(key).map(|c| &c.#args_field_ident)
141            }
142
143            fn quirks_for(&self, key: &str) -> &[Self::Quirk] {
144                self.#crates_field_ident
145                    .get(key)
146                    .map(|c| c.quirks.as_slice())
147                    .unwrap_or(&[])
148            }
149        }
150    };
151
152    TokenStream::from(expanded)
153}
154
155/// `#[derive(QuirkRegistry)]` — auto-implement
156/// `gen_types::QuirkRegistry` on a marker struct that points at the
157/// real registry function.
158///
159/// Required attribute:
160/// `#[quirks(enum_name = "<QuirkEnumName>", registry_fn = "module::path::to::registry")]`
161///
162/// The `registry_fn` must be a `pub fn() -> Vec<(&'static str, Vec<Quirk>)>`
163/// the macro can call.
164#[proc_macro_derive(QuirkRegistry, attributes(quirks))]
165pub fn derive_quirk_registry(input: TokenStream) -> TokenStream {
166    let input = parse_macro_input!(input as DeriveInput);
167    let name = &input.ident;
168
169    let mut enum_name: Option<String> = None;
170    let mut registry_fn: Option<String> = None;
171    for attr in &input.attrs {
172        if !attr.path().is_ident("quirks") {
173            continue;
174        }
175        let Meta::List(list) = &attr.meta else {
176            continue;
177        };
178        let _ = list.parse_nested_meta(|meta| {
179            let Some(ident) = meta.path.get_ident() else {
180                return Ok(());
181            };
182            let value: Lit = meta.value()?.parse()?;
183            let Lit::Str(s) = value else {
184                return Ok(());
185            };
186            let v = s.value();
187            match ident.to_string().as_str() {
188                "enum_name" => enum_name = Some(v),
189                "registry_fn" => registry_fn = Some(v),
190                _ => {}
191            }
192            Ok(())
193        });
194    }
195    let enum_ty = match enum_name {
196        Some(t) => syn::parse_str::<syn::Type>(&t).expect("invalid `enum_name`"),
197        None => {
198            return TokenStream::from(quote! {
199                compile_error!("QuirkRegistry requires `#[quirks(enum_name = \"<EnumName>\", registry_fn = \"<path>\")]`");
200            });
201        }
202    };
203    let reg_path = match registry_fn {
204        Some(t) => syn::parse_str::<syn::Path>(&t).expect("invalid `registry_fn`"),
205        None => {
206            return TokenStream::from(quote! {
207                compile_error!("QuirkRegistry requires `#[quirks(enum_name = \"<EnumName>\", registry_fn = \"<path>\")]`");
208            });
209        }
210    };
211
212    let expanded = quote! {
213        impl ::gen_types::QuirkRegistry for #name {
214            type Quirk = #enum_ty;
215
216            fn registry() -> ::std::vec::Vec<(&'static str, ::std::vec::Vec<Self::Quirk>)> {
217                #reg_path()
218            }
219        }
220    };
221
222    TokenStream::from(expanded)
223}
224
225/// `#[derive(TypedDispatcher)]` — auto-implement
226/// `gen_types::TypedDispatcher` on a Rust enum whose serde tag is
227/// `#[serde(tag = "kind", rename_all = "kebab-case")]`.
228///
229/// The macro observes the enum's variants and emits a trait impl
230/// reflecting the variant universe (kebab-case tags + per-variant
231/// field names). Substrate emitters consume the reflection to
232/// generate:
233///
234/// - the Nix `helpers = { ... }` table skeleton for the matching
235///   `substrate/lib/build/<eco>/quirk-apply.nix`;
236/// - the Lisp catalog entry naming the dispatcher;
237/// - a coverage test asserting every variant has a consumer arm.
238///
239/// Only unit variants and named-field struct variants are supported
240/// (the serde-tagged-enum shape pleme-io uses universally). Tuple
241/// variants raise a compile error.
242#[proc_macro_derive(TypedDispatcher)]
243pub fn derive_typed_dispatcher(input: TokenStream) -> TokenStream {
244    let input = parse_macro_input!(input as DeriveInput);
245    let name = &input.ident;
246
247    let Data::Enum(data) = &input.data else {
248        return TokenStream::from(quote! {
249            compile_error!("#[derive(TypedDispatcher)] only works on enums");
250        });
251    };
252
253    let mut kind_entries: Vec<proc_macro2::TokenStream> = Vec::new();
254    let mut field_entries: Vec<proc_macro2::TokenStream> = Vec::new();
255
256    for variant in &data.variants {
257        let tag = to_kebab_case(&variant.ident.to_string());
258        let fields = match &variant.fields {
259            Fields::Named(named) => named
260                .named
261                .iter()
262                .filter_map(|f| f.ident.as_ref().map(std::string::ToString::to_string))
263                .collect::<Vec<_>>(),
264            Fields::Unit => Vec::new(),
265            Fields::Unnamed(_) => {
266                let msg = format!(
267                    "#[derive(TypedDispatcher)] variant `{}` uses tuple fields; only named-field and unit variants are supported (matches the serde-tagged-enum shape pleme-io requires)",
268                    variant.ident
269                );
270                return TokenStream::from(quote! {
271                    compile_error!(#msg);
272                });
273            }
274        };
275
276        kind_entries.push(quote! { #tag });
277        let field_strs: Vec<proc_macro2::TokenStream> =
278            fields.iter().map(|f| quote! { #f }).collect();
279        field_entries.push(quote! {
280            (#tag, ::std::vec![ #( #field_strs ),* ])
281        });
282    }
283
284    let expanded = quote! {
285        impl ::gen_types::TypedDispatcher for #name {
286            fn variant_kinds() -> ::std::vec::Vec<&'static str> {
287                ::std::vec![ #( #kind_entries ),* ]
288            }
289
290            fn variant_fields() -> ::std::vec::Vec<(&'static str, ::std::vec::Vec<&'static str>)> {
291                ::std::vec![ #( #field_entries ),* ]
292            }
293        }
294    };
295
296    TokenStream::from(expanded)
297}
298
299// ── Discriminant + IsVariant — typed-reflection derive surface ───
300//
301// These two derives are the substrate-wide derive surface for typed
302// enums. They live in
303// gen-macros (next to TypedDispatcher) so consumers in any pleme-io
304// crate that already depends on gen-platform can reach for them
305// without adding a fresh derive crate.
306//
307// Discriminant emits `pub const fn <method>(&self) -> &'static str`
308// returning the variant name as a stable lowercase / kebab-case /
309// snake_case / title-case identifier.
310//
311// IsVariant emits `pub const fn is_<variant>(&self) -> bool`
312// per variant.
313//
314// Both support per-variant `#[discriminant(name = "...")]` /
315// `#[is_variant(name = "...")]` overrides for cases where the
316// auto-derived name doesn't match the historical wire format.
317
318#[derive(Clone, Copy)]
319enum DiscriminantCase {
320    Kebab,
321    Snake,
322    Lower,
323    Title,
324}
325
326impl DiscriminantCase {
327    fn apply(self, s: &str) -> String {
328        match self {
329            DiscriminantCase::Kebab => to_kebab_case(s),
330            DiscriminantCase::Snake => discriminant_to_snake(s),
331            DiscriminantCase::Lower => s.to_ascii_lowercase(),
332            DiscriminantCase::Title => s.to_string(),
333        }
334    }
335
336    fn parse(s: &str) -> Option<Self> {
337        match s {
338            "kebab" | "kebab-case" => Some(DiscriminantCase::Kebab),
339            "snake" | "snake_case" => Some(DiscriminantCase::Snake),
340            "lower" | "lowercase" => Some(DiscriminantCase::Lower),
341            "title" | "Title" | "TitleCase" => Some(DiscriminantCase::Title),
342            _ => None,
343        }
344    }
345}
346
347fn discriminant_to_snake(s: &str) -> String {
348    let mut out = String::with_capacity(s.len() + 4);
349    for (i, c) in s.chars().enumerate() {
350        if c.is_ascii_uppercase() {
351            if i > 0 {
352                out.push('_');
353            }
354            out.push(c.to_ascii_lowercase());
355        } else {
356            out.push(c);
357        }
358    }
359    out
360}
361
362fn discriminant_variant_pattern(v: &syn::Variant) -> proc_macro2::TokenStream {
363    let name = &v.ident;
364    match &v.fields {
365        Fields::Unit => quote! { Self::#name },
366        Fields::Unnamed(_) => quote! { Self::#name(..) },
367        Fields::Named(_) => quote! { Self::#name { .. } },
368    }
369}
370
371fn discriminant_variant_explicit_name(v: &syn::Variant) -> Option<String> {
372    for attr in &v.attrs {
373        if !attr.path().is_ident("discriminant") {
374            continue;
375        }
376        let mut out = None;
377        let _ = attr.parse_nested_meta(|meta| {
378            if meta.path.is_ident("name") {
379                let value = meta.value()?;
380                let s: syn::LitStr = value.parse()?;
381                out = Some(s.value());
382            }
383            Ok(())
384        });
385        if out.is_some() {
386            return out;
387        }
388    }
389    None
390}
391
392/// `#[derive(Discriminant)]` — auto-implement
393/// `pub const fn <method>(&self) -> &'static str` returning the
394/// variant name as a stable case-folded identifier.
395///
396/// # Attributes
397///
398/// - `#[discriminant(method = "kind")]` — method name (default
399///   `"discriminant"`)
400/// - `#[discriminant(case = "kebab" | "snake" | "lower" | "title")]`
401///   — variant-name case transformation (default `"kebab"`)
402/// - `#[discriminant(also_display)]` — also emit `impl Display`
403///   delegating to the method (writes the variant string to the
404///   formatter). Eliminates the boilerplate Display impl that
405///   recurs across the substrate for typed enums where Display
406///   IS the discriminant.
407/// - Per-variant `#[discriminant(name = "explicit-name")]` overrides
408///   the auto-derived name (used when the wire format pre-dates the
409///   rule).
410///
411/// Compounding: pairs naturally with `#[derive(IsVariant)]` (predicate
412/// methods) and `#[derive(TypedDispatcher)]` (variant → consumer arm
413/// dispatch). All three target the same closed-variant-universe shape
414/// the pleme-io substrate uses everywhere.
415#[proc_macro_derive(Discriminant, attributes(discriminant))]
416pub fn derive_discriminant(input: TokenStream) -> TokenStream {
417    let input = parse_macro_input!(input as DeriveInput);
418    let enum_name = input.ident.clone();
419
420    let Data::Enum(de) = input.data.clone() else {
421        return syn::Error::new_spanned(
422            &enum_name,
423            "#[derive(Discriminant)] is only valid on enums",
424        )
425        .to_compile_error()
426        .into();
427    };
428
429    let mut method = "discriminant".to_string();
430    let mut case = DiscriminantCase::Kebab;
431    let mut also_display = false;
432    for attr in &input.attrs {
433        if !attr.path().is_ident("discriminant") {
434            continue;
435        }
436        let _ = attr.parse_nested_meta(|meta| {
437            if meta.path.is_ident("method") {
438                let value = meta.value()?;
439                let s: syn::LitStr = value.parse()?;
440                method = s.value();
441            } else if meta.path.is_ident("case") {
442                let value = meta.value()?;
443                let s: syn::LitStr = value.parse()?;
444                if let Some(c) = DiscriminantCase::parse(&s.value()) {
445                    case = c;
446                }
447            } else if meta.path.is_ident("also_display") {
448                also_display = true;
449            }
450            Ok(())
451        });
452    }
453    let method_ident = syn::Ident::new(&method, proc_macro2::Span::call_site());
454
455    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
456
457    let arms: Vec<proc_macro2::TokenStream> = de
458        .variants
459        .iter()
460        .map(|v| {
461            let pattern = discriminant_variant_pattern(v);
462            let name_str = discriminant_variant_explicit_name(v)
463                .unwrap_or_else(|| case.apply(&v.ident.to_string()));
464            quote! { #pattern => #name_str }
465        })
466        .collect();
467
468    let display_impl = if also_display {
469        quote! {
470            impl #impl_generics ::core::fmt::Display for #enum_name #ty_generics #where_clause {
471                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
472                    f.write_str(self.#method_ident())
473                }
474            }
475        }
476    } else {
477        quote! {}
478    };
479
480    let expanded = quote! {
481        impl #impl_generics #enum_name #ty_generics #where_clause {
482            /// Stable variant discriminant — auto-generated by
483            /// `#[derive(Discriminant)]`. The string IS the wire
484            /// identifier for metrics labels / audit-log tags /
485            /// rate-limit keys; renaming an existing variant is a
486            /// breaking change.
487            pub const fn #method_ident(&self) -> &'static str {
488                match self {
489                    #(#arms),*
490                }
491            }
492        }
493        #display_impl
494    };
495
496    expanded.into()
497}
498
499/// `#[derive(FromStrKind)]` — the inverse of Discriminant. Parses
500/// a string back to a variant using the same case-folded variant
501/// name. Only unit variants are supported (data variants need
502/// caller-supplied payloads — out of scope for a string-only parse).
503///
504/// # Attributes
505///
506/// - `#[from_str_kind(case = "kebab" | "snake" | "lower" | "title")]`
507///   — case transform matching the wire format (default `"kebab"`)
508/// - Per-variant `#[from_str_kind(name = "explicit")]` — match a
509///   specific wire string for this variant (overrides case transform)
510/// - `#[from_str_kind(error = "MyEnumParseError")]` — name of the
511///   generated error type (default `<EnumName>ParseError`)
512///
513/// Pairs with Discriminant: when both derives are on the same enum
514/// with the same case transform, `s.parse() -> Ok(v); v.discriminant() == s`
515/// — a typed round-trip.
516#[proc_macro_derive(FromStrKind, attributes(from_str_kind))]
517pub fn derive_from_str_kind(input: TokenStream) -> TokenStream {
518    let input = parse_macro_input!(input as DeriveInput);
519    let enum_name = input.ident.clone();
520
521    let Data::Enum(de) = input.data.clone() else {
522        return syn::Error::new_spanned(
523            &enum_name,
524            "#[derive(FromStrKind)] is only valid on enums",
525        )
526        .to_compile_error()
527        .into();
528    };
529
530    let mut case = DiscriminantCase::Kebab;
531    let mut error_name = format!("{enum_name}ParseError");
532    for attr in &input.attrs {
533        if !attr.path().is_ident("from_str_kind") {
534            continue;
535        }
536        let _ = attr.parse_nested_meta(|meta| {
537            if meta.path.is_ident("case") {
538                let value = meta.value()?;
539                let s: syn::LitStr = value.parse()?;
540                if let Some(c) = DiscriminantCase::parse(&s.value()) {
541                    case = c;
542                }
543            } else if meta.path.is_ident("error") {
544                let value = meta.value()?;
545                let s: syn::LitStr = value.parse()?;
546                error_name = s.value();
547            }
548            Ok(())
549        });
550    }
551    let error_ident = syn::Ident::new(&error_name, proc_macro2::Span::call_site());
552
553    let mut arms: Vec<proc_macro2::TokenStream> = Vec::new();
554    let mut known_strings: Vec<String> = Vec::new();
555    for v in &de.variants {
556        if !matches!(v.fields, Fields::Unit) {
557            return syn::Error::new_spanned(
558                &v.ident,
559                "#[derive(FromStrKind)] requires all variants to be unit variants (no data payloads)",
560            )
561            .to_compile_error()
562            .into();
563        }
564        let v_ident = &v.ident;
565        let explicit = v.attrs.iter().find_map(|attr| {
566            if !attr.path().is_ident("from_str_kind") {
567                return None;
568            }
569            let mut out = None;
570            let _ = attr.parse_nested_meta(|meta| {
571                if meta.path.is_ident("name") {
572                    let value = meta.value()?;
573                    let s: syn::LitStr = value.parse()?;
574                    out = Some(s.value());
575                }
576                Ok(())
577            });
578            out
579        });
580        let name_str = explicit.unwrap_or_else(|| case.apply(&v_ident.to_string()));
581        known_strings.push(name_str.clone());
582        arms.push(quote! { #name_str => Ok(Self::#v_ident) });
583    }
584
585    let known_list = known_strings.join(" | ");
586    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
587
588    let expanded = quote! {
589        /// Auto-generated parse error for the matching `FromStrKind` impl.
590        #[derive(Debug, Clone, PartialEq, Eq)]
591        pub struct #error_ident {
592            pub input: ::std::string::String,
593        }
594
595        impl ::core::fmt::Display for #error_ident {
596            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
597                write!(
598                    f,
599                    "unknown variant {input:?}; expected one of: {known}",
600                    input = self.input,
601                    known = #known_list,
602                )
603            }
604        }
605
606        impl ::std::error::Error for #error_ident {}
607
608        impl #impl_generics ::core::str::FromStr for #enum_name #ty_generics #where_clause {
609            type Err = #error_ident;
610            fn from_str(s: &str) -> ::core::result::Result<Self, Self::Err> {
611                match s {
612                    #(#arms),*,
613                    other => Err(#error_ident { input: other.to_string() }),
614                }
615            }
616        }
617    };
618
619    expanded.into()
620}
621
622fn is_variant_method_name(v: &syn::Variant) -> syn::Ident {
623    let explicit = v.attrs.iter().find_map(|attr| {
624        if !attr.path().is_ident("is_variant") {
625            return None;
626        }
627        let mut out = None;
628        let _ = attr.parse_nested_meta(|meta| {
629            if meta.path.is_ident("name") {
630                let value = meta.value()?;
631                let s: syn::LitStr = value.parse()?;
632                out = Some(s.value());
633            }
634            Ok(())
635        });
636        out
637    });
638    let snake = explicit.unwrap_or_else(|| discriminant_to_snake(&v.ident.to_string()));
639    syn::Ident::new(&format!("is_{snake}"), proc_macro2::Span::call_site())
640}
641
642/// `#[derive(IsVariant)]` — auto-implement `pub const fn is_<variant>(&self) -> bool`
643/// for every variant.
644///
645/// # Attributes
646///
647/// - Per-variant `#[is_variant(name = "explicit")]` overrides the
648///   auto-derived method-name suffix (default is the snake-cased
649///   variant identifier).
650///
651/// Compounding: pairs with `#[derive(Discriminant)]` for variant→name
652/// reflection and `#[derive(TypedDispatcher)]` for variant→consumer
653/// dispatch.
654#[proc_macro_derive(IsVariant, attributes(is_variant))]
655pub fn derive_is_variant(input: TokenStream) -> TokenStream {
656    let input = parse_macro_input!(input as DeriveInput);
657    let enum_name = input.ident.clone();
658
659    let Data::Enum(de) = input.data.clone() else {
660        return syn::Error::new_spanned(&enum_name, "#[derive(IsVariant)] is only valid on enums")
661            .to_compile_error()
662            .into();
663    };
664
665    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
666
667    let methods: Vec<proc_macro2::TokenStream> = de
668        .variants
669        .iter()
670        .map(|v| {
671            let pattern = discriminant_variant_pattern(v);
672            let method_name = is_variant_method_name(v);
673            quote! {
674                pub const fn #method_name(&self) -> bool {
675                    matches!(self, #pattern)
676                }
677            }
678        })
679        .collect();
680
681    let expanded = quote! {
682        impl #impl_generics #enum_name #ty_generics #where_clause {
683            #(#methods)*
684        }
685    };
686
687    expanded.into()
688}
689
690/// `#[derive(BackendError)]` — auto-implement a trait with the
691/// shape:
692///
693/// ```ignore
694/// pub trait BackendError {
695///     fn is_retryable(&self) -> bool;
696///     fn is_auth_failure(&self) -> bool { false }
697///     fn kind(&self) -> &'static str;
698/// }
699/// ```
700///
701/// The derive emits:
702///   - `is_retryable`  — `true` for variants tagged `#[backend_error(transient)]`
703///   - `is_auth_failure` — `true` for variants tagged `#[backend_error(auth)]`
704///   - `kind` — delegates to `self.discriminant()` (requires
705///     `#[derive(Discriminant)]` to be on the same enum with method
706///     `discriminant` OR the consumer overrides via `kind_method`)
707///
708/// # Attributes
709///
710/// - `#[backend_error(trait_path = "::path::to::BackendError")]` —
711///   fully-qualified trait path. Defaults to unqualified
712///   `BackendError` — consumer must `use the_trait::BackendError` in
713///   scope.
714/// - `#[backend_error(kind_method = "kind")]` — name of the
715///   `&'static str`-returning method to delegate `kind()` to (default
716///   `"discriminant"` — matches the default Discriminant method name).
717/// - Per-variant `#[backend_error(transient)]` — variant is transient
718///   (caller should retry).
719/// - Per-variant `#[backend_error(auth)]` — variant is an auth
720///   failure (HTTP 401/403 maps).
721/// - Per-variant `#[backend_error(permanent)]` — variant is permanent
722///   (caller must not retry). Default for unattributed variants.
723///
724/// # Round-trip with Discriminant
725///
726/// Pairs with Discriminant to deliver the BackendError contract in
727/// two derives:
728///
729/// ```ignore
730/// use magma_converge::BackendError;  // trait in scope
731///
732/// #[derive(Debug, thiserror::Error, gen_platform::Discriminant, gen_platform::BackendError)]
733/// #[discriminant(method = "discriminant", case = "snake")]
734/// enum BlobStoreError {
735///     #[error("not found at {path:?}")]
736///     NotFound { path: String },
737///
738///     #[error("permission denied at {path:?}")]
739///     #[backend_error(auth)]
740///     PermissionDenied { path: String },
741///
742///     #[error("transient at {path:?}")]
743///     #[backend_error(transient)]
744///     Transient { path: String },
745///
746///     #[error("permanent at {path:?}")]
747///     Permanent { path: String },
748/// }
749///
750/// // Auto-generated:
751/// //   impl BackendError for BlobStoreError {
752/// //       fn is_retryable(&self) -> bool { matches!(self, Self::Transient { .. }) }
753/// //       fn is_auth_failure(&self) -> bool { matches!(self, Self::PermissionDenied { .. }) }
754/// //       fn kind(&self) -> &'static str { self.discriminant() }
755/// //   }
756/// ```
757#[proc_macro_derive(BackendError, attributes(backend_error))]
758pub fn derive_backend_error(input: TokenStream) -> TokenStream {
759    let input = parse_macro_input!(input as DeriveInput);
760    let enum_name = input.ident.clone();
761
762    let Data::Enum(de) = input.data.clone() else {
763        return syn::Error::new_spanned(
764            &enum_name,
765            "#[derive(BackendError)] is only valid on enums",
766        )
767        .to_compile_error()
768        .into();
769    };
770
771    let mut trait_path: syn::Path = syn::parse_quote!(BackendError);
772    let mut kind_method = "discriminant".to_string();
773    for attr in &input.attrs {
774        if !attr.path().is_ident("backend_error") {
775            continue;
776        }
777        let _ = attr.parse_nested_meta(|meta| {
778            if meta.path.is_ident("trait_path") {
779                let value = meta.value()?;
780                let s: syn::LitStr = value.parse()?;
781                if let Ok(p) = syn::parse_str::<syn::Path>(&s.value()) {
782                    trait_path = p;
783                }
784            } else if meta.path.is_ident("kind_method") {
785                let value = meta.value()?;
786                let s: syn::LitStr = value.parse()?;
787                kind_method = s.value();
788            }
789            Ok(())
790        });
791    }
792    let kind_method_ident = syn::Ident::new(&kind_method, proc_macro2::Span::call_site());
793
794    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
795
796    let mut transient_patterns: Vec<proc_macro2::TokenStream> = Vec::new();
797    let mut auth_patterns: Vec<proc_macro2::TokenStream> = Vec::new();
798    for v in &de.variants {
799        let mut tags: std::collections::HashSet<String> = std::collections::HashSet::new();
800        for attr in &v.attrs {
801            if !attr.path().is_ident("backend_error") {
802                continue;
803            }
804            let _ = attr.parse_nested_meta(|meta| {
805                if let Some(ident) = meta.path.get_ident() {
806                    tags.insert(ident.to_string());
807                }
808                Ok(())
809            });
810        }
811        let pattern = discriminant_variant_pattern(v);
812        if tags.contains("transient") {
813            transient_patterns.push(pattern.clone());
814        }
815        if tags.contains("auth") {
816            auth_patterns.push(pattern.clone());
817        }
818    }
819
820    let is_retryable_body = if transient_patterns.is_empty() {
821        quote! { false }
822    } else {
823        quote! { matches!(self, #(#transient_patterns)|*) }
824    };
825    let is_auth_failure_body = if auth_patterns.is_empty() {
826        quote! { false }
827    } else {
828        quote! { matches!(self, #(#auth_patterns)|*) }
829    };
830
831    let expanded = quote! {
832        impl #impl_generics #trait_path for #enum_name #ty_generics #where_clause {
833            fn is_retryable(&self) -> bool {
834                #is_retryable_body
835            }
836
837            fn is_auth_failure(&self) -> bool {
838                #is_auth_failure_body
839            }
840
841            fn kind(&self) -> &'static str {
842                self.#kind_method_ident()
843            }
844        }
845    };
846
847    expanded.into()
848}
849
850/// `#[derive(OutcomeLattice)]` — auto-implement an OutcomeLattice
851/// trait from per-variant severity attributes.
852///
853/// Expected trait shape:
854///
855/// ```ignore
856/// pub trait OutcomeLattice: Clone + PartialEq {
857///     fn severity(&self) -> u32;
858///     fn baseline() -> Self;
859///     fn worst(&self, other: &Self) -> Self { ... }
860///     fn best(&self, other: &Self) -> Self { ... }
861/// }
862/// ```
863///
864/// The derive emits `severity()` (from per-variant attrs) +
865/// `baseline()` (returning the single `#[outcome(baseline)]`-tagged
866/// unit variant). `worst` + `best` come from the trait's defaults.
867///
868/// # Attributes
869///
870/// - `#[outcome_lattice(trait_path = "::path::to::OutcomeLattice")]`
871///   — fully-qualified trait path (default unqualified
872///   `OutcomeLattice` — consumer must `use the_trait::OutcomeLattice`
873///   in scope).
874/// - Per-variant `#[outcome(severity = N)]` — severity for this
875///   variant (u32; default 0 if unspecified).
876/// - Per-variant `#[outcome(baseline)]` — marks the unit variant
877///   that `baseline()` returns. Exactly ONE variant must carry this;
878///   it must be a unit variant.
879///
880/// # Example
881///
882/// ```ignore
883/// use magma_converge::outcome::OutcomeLattice;
884///
885/// #[derive(Clone, PartialEq, gen_platform::OutcomeLattice)]
886/// enum ReadyState {
887///     #[outcome(severity = 0, baseline)]
888///     Ready,
889///     #[outcome(severity = 1)]
890///     Unknown,
891///     #[outcome(severity = 2)]
892///     InProgress { reason: String },
893///     #[outcome(severity = 3)]
894///     Failed { reason: String },
895/// }
896///
897/// // Auto-generated:
898/// //   impl OutcomeLattice for ReadyState {
899/// //       fn severity(&self) -> u32 { match self { ... } }
900/// //       fn baseline() -> Self { Self::Ready }
901/// //   }
902/// ```
903#[proc_macro_derive(OutcomeLattice, attributes(outcome_lattice, outcome))]
904pub fn derive_outcome_lattice(input: TokenStream) -> TokenStream {
905    let input = parse_macro_input!(input as DeriveInput);
906    let enum_name = input.ident.clone();
907
908    let Data::Enum(de) = input.data.clone() else {
909        return syn::Error::new_spanned(
910            &enum_name,
911            "#[derive(OutcomeLattice)] is only valid on enums",
912        )
913        .to_compile_error()
914        .into();
915    };
916
917    let mut trait_path: syn::Path = syn::parse_quote!(OutcomeLattice);
918    for attr in &input.attrs {
919        if !attr.path().is_ident("outcome_lattice") {
920            continue;
921        }
922        let _ = attr.parse_nested_meta(|meta| {
923            if meta.path.is_ident("trait_path") {
924                let value = meta.value()?;
925                let s: syn::LitStr = value.parse()?;
926                if let Ok(p) = syn::parse_str::<syn::Path>(&s.value()) {
927                    trait_path = p;
928                }
929            }
930            Ok(())
931        });
932    }
933
934    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
935
936    let mut severity_arms: Vec<proc_macro2::TokenStream> = Vec::new();
937    let mut baseline_variant: Option<syn::Ident> = None;
938    for v in &de.variants {
939        let v_ident = &v.ident;
940        let pattern = discriminant_variant_pattern(v);
941        let mut sev: u32 = 0;
942        let mut is_baseline = false;
943        for attr in &v.attrs {
944            if !attr.path().is_ident("outcome") {
945                continue;
946            }
947            let _ = attr.parse_nested_meta(|meta| {
948                if meta.path.is_ident("severity") {
949                    let value = meta.value()?;
950                    let lit: syn::LitInt = value.parse()?;
951                    sev = lit.base10_parse::<u32>().unwrap_or(0);
952                } else if meta.path.is_ident("baseline") {
953                    is_baseline = true;
954                }
955                Ok(())
956            });
957        }
958        if is_baseline {
959            if !matches!(v.fields, Fields::Unit) {
960                return syn::Error::new_spanned(
961                    v_ident,
962                    "#[outcome(baseline)] requires a unit variant",
963                )
964                .to_compile_error()
965                .into();
966            }
967            if baseline_variant.is_some() {
968                return syn::Error::new_spanned(
969                    v_ident,
970                    "exactly one variant may carry #[outcome(baseline)]",
971                )
972                .to_compile_error()
973                .into();
974            }
975            baseline_variant = Some(v_ident.clone());
976        }
977        let lit = syn::LitInt::new(&sev.to_string(), proc_macro2::Span::call_site());
978        severity_arms.push(quote! { #pattern => #lit });
979    }
980
981    let Some(baseline_ident) = baseline_variant else {
982        return syn::Error::new_spanned(
983            &enum_name,
984            "exactly one variant must carry #[outcome(baseline)] to derive OutcomeLattice",
985        )
986        .to_compile_error()
987        .into();
988    };
989
990    let expanded = quote! {
991        impl #impl_generics #trait_path for #enum_name #ty_generics #where_clause {
992            fn severity(&self) -> u32 {
993                match self {
994                    #(#severity_arms),*
995                }
996            }
997
998            fn baseline() -> Self {
999                Self::#baseline_ident
1000            }
1001        }
1002    };
1003
1004    expanded.into()
1005}
1006
1007/// Convert PascalCase variant identifiers to kebab-case serde tags.
1008/// Mirrors `#[serde(rename_all = "kebab-case")]` semantics via
1009/// heck-style word boundaries: lower→upper and digit→upper both
1010/// trigger a hyphen. `Wasm32Wasi` → `wasm32-wasi`.
1011fn to_kebab_case(s: &str) -> String {
1012    let mut out = String::with_capacity(s.len() + 4);
1013    let mut prev_lower = false;
1014    let mut prev_digit = false;
1015    for ch in s.chars() {
1016        if ch.is_ascii_uppercase() {
1017            if prev_lower || prev_digit {
1018                out.push('-');
1019            }
1020            for c in ch.to_lowercase() {
1021                out.push(c);
1022            }
1023            prev_lower = false;
1024            prev_digit = false;
1025        } else if ch.is_ascii_digit() {
1026            out.push(ch);
1027            prev_lower = false;
1028            prev_digit = true;
1029        } else {
1030            out.push(ch);
1031            prev_lower = true;
1032            prev_digit = false;
1033        }
1034    }
1035    out
1036}
1037
1038// ── #[fsm(label = "...")] attribute macro ──────────────────────────
1039//
1040// Bundles the canonical typed-FSM-enum boilerplate that every typed
1041// dispatcher class across pleme-io ships:
1042//
1043//   #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1044//   #[derive(TypedDispatcher, Discriminant, IsVariant)]
1045//   #[serde(tag = "kind", rename_all = "kebab-case")]
1046//   pub enum Foo { ... }
1047//   gen_platform::register_dispatcher!("eco.foo", Foo);
1048//
1049// becomes:
1050//
1051//   #[gen_macros::fsm(label = "eco.foo")]
1052//   pub enum Foo { ... }
1053//
1054// Roughly 8 lines → 1 line per typed-FSM, fleet-wide. Same shape,
1055// no manual catalog wiring drift.
1056
1057/// Attribute macro that bundles the typed-FSM derive quintet +
1058/// serde tag + catalog registration. Use on a closed enum:
1059///
1060/// ```ignore
1061/// #[gen_macros::fsm(label = "gen.cargo.lock-lifecycle-state")]
1062/// pub enum LockLifecycleState {
1063///     Unlocked { current_lock_hash: String },
1064///     Locked   { spec_hash: String, lock_hash: String },
1065///     Drifted  { committed_lock_hash: String, current_lock_hash: String },
1066///     MissingLock,
1067/// }
1068/// ```
1069///
1070/// Expands to:
1071///
1072/// ```ignore
1073/// #[derive(Clone, Debug, PartialEq, Eq,
1074///          serde::Serialize, serde::Deserialize,
1075///          gen_macros::TypedDispatcher,
1076///          gen_macros::Discriminant,
1077///          gen_macros::IsVariant)]
1078/// #[serde(tag = "kind", rename_all = "kebab-case")]
1079/// pub enum LockLifecycleState { /* ... */ }
1080///
1081/// gen_platform::register_dispatcher!(
1082///     "gen.cargo.lock-lifecycle-state",
1083///     LockLifecycleState
1084/// );
1085/// ```
1086///
1087/// The consumer crate must depend on `gen_macros`, `gen_platform`,
1088/// and `serde` with the `derive` feature.
1089#[proc_macro_attribute]
1090pub fn fsm(args: TokenStream, item: TokenStream) -> TokenStream {
1091    let args = parse_macro_input!(args as FsmArgs);
1092    let item = parse_macro_input!(item as syn::ItemEnum);
1093    let name = &item.ident;
1094    let label = &args.label;
1095
1096    quote! {
1097        #[derive(
1098            ::core::clone::Clone,
1099            ::core::fmt::Debug,
1100            ::core::cmp::PartialEq,
1101            ::core::cmp::Eq,
1102            ::serde::Serialize,
1103            ::serde::Deserialize,
1104            ::gen_macros::TypedDispatcher,
1105            ::gen_macros::Discriminant,
1106            ::gen_macros::IsVariant,
1107        )]
1108        #[serde(tag = "kind", rename_all = "kebab-case")]
1109        #item
1110
1111        ::gen_platform::register_dispatcher!(#label, #name);
1112    }
1113    .into()
1114}
1115
1116struct FsmArgs {
1117    label: String,
1118}
1119
1120impl syn::parse::Parse for FsmArgs {
1121    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1122        let kw: syn::Ident = input.parse()?;
1123        if kw != "label" {
1124            return Err(syn::Error::new(
1125                kw.span(),
1126                "expected `label = \"<catalog-label>\"`",
1127            ));
1128        }
1129        input.parse::<syn::Token![=]>()?;
1130        let lit: syn::LitStr = input.parse()?;
1131        Ok(Self { label: lit.value() })
1132    }
1133}