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