Skip to main content

ergo_sbe/
config.rs

1//! Code generation configuration ([`GenerationConfig`]).
2//!
3//! # Conversion: pick **one** style per selector
4//!
5//! | API | When to use | Generated decode | Generated encode |
6//! |-----|-------------|------------------|------------------|
7//! | [`GenerationConfig::with_conversion`] | Pluggable adapters; no forced crate dep | `dec.price_as::<T>()?` | `enc.price_from(&t)?` |
8//! | [`GenerationConfig::with_domain_type`] | One canonical app type | `dec.try_price()? -> path::Type` | `enc.try_price(value)?` |
9//!
10//! `with_domain_type` **implies** conversion for that selector. Do **not** also
11//! call `with_conversion` for the same selector.
12//!
13//! ```rust
14//! use ergo_sbe::{GenerationConfig, ConversionSelector};
15//!
16//! // A — generic / pluggable (you implement TryFromSbe / TryToSbe)
17//! let _a = GenerationConfig::new("msgs")
18//!     .with_conversion(ConversionSelector::named_type("Decimal"));
19//!
20//! // B — concrete Rust type (Generated impl is the default)
21//! let _b = GenerationConfig::new("msgs")
22//!     .with_domain_type(
23//!         ConversionSelector::named_type("Decimal"),
24//!         "rust_decimal::Decimal",
25//!     );
26//! ```
27//!
28//! # Other features (generated surface)
29//!
30//! | Builder | What generated code looks like |
31//! |---------|--------------------------------|
32//! | [`with_domain_objects`](GenerationConfig::with_domain_objects) | `CarDomain` DTOs; pass [`DomainVarData`] for var-data shape |
33//! | [`with_shared_module`](GenerationConfig::with_shared_module) | Multi-schema: shared types in one module, `pub use super::common::*` |
34//! | [`with_external_sbe_rt`](GenerationConfig::with_external_sbe_rt) | `pub use path::sbe_rt as sbe_rt` instead of inlining runtime |
35//! | [`with_error_from_impls`](GenerationConfig::with_error_from_impls) | `From<EncodeError> for YourError` so `?` works |
36//! | [`with_keyword_append_token`](GenerationConfig::with_keyword_append_token) | Schema field `type` → `type_` (default `"_"`) |
37//! | [`with_deprecated_attrs`](GenerationConfig::with_deprecated_attrs) | `#[deprecated]` on schema-deprecated items |
38
39/// Selects which fields receive conversion / domain-type methods.
40///
41/// When several selectors could match the same field, precedence is:
42/// 1. Exact `"Message.field"` path ([`ConversionSelector::FieldPath`])
43/// 2. SBE `semanticType` ([`ConversionSelector::SemanticType`])
44/// 3. Named type ([`ConversionSelector::NamedType`])
45///
46/// ```rust
47/// use ergo_sbe::ConversionSelector;
48///
49/// let _ = ConversionSelector::named_type("Decimal");
50/// let _ = ConversionSelector::semantic_type("UTCTimestamp");
51/// let _ = ConversionSelector::field_path("Quote.price");
52/// ```
53#[derive(Clone, Debug, Eq, PartialEq, Hash)]
54pub enum ConversionSelector {
55    /// Match one field by path, e.g. `"Car.serialNumber"` or `"Quote.price"`.
56    FieldPath(String),
57    /// Match all fields with this SBE `semanticType` attribute (e.g. `"UTCTimestamp"`).
58    SemanticType(String),
59    /// Match all fields whose type name is this (composite, enum, set, alias).
60    ///
61    /// Example: `"Decimal"` matches every field of composite type `Decimal`.
62    NamedType(String),
63}
64
65impl ConversionSelector {
66    /// Select by SBE `semanticType` (e.g. `"UTCTimestamp"`, `"Price"`).
67    #[must_use]
68    pub fn semantic_type(name: impl Into<String>) -> Self {
69        Self::SemanticType(name.into())
70    }
71
72    /// Select by named SBE type (composite / enum / set / alias), e.g. `"Decimal"`.
73    #[must_use]
74    pub fn named_type(name: impl Into<String>) -> Self {
75        Self::NamedType(name.into())
76    }
77
78    /// Select by exact `"MessageName.fieldName"` path.
79    #[must_use]
80    pub fn field_path(path: impl Into<String>) -> Self {
81        Self::FieldPath(path.into())
82    }
83}
84
85/// How owned domain DTO `<data>` / var-data fields are typed.
86///
87/// Passed to [`GenerationConfig::with_domain_objects`]. Wire is always
88/// length-prefixed **bytes**; this only chooses the **owned** DTO field type.
89///
90/// | Variant | DTO field | Feature | Invalid UTF-8 |
91/// |---------|-----------|---------|---------------|
92/// | [`Bytes`](DomainVarData::Bytes) | `Vec<u8>` | — | n/a |
93/// | [`Strings`](DomainVarData::Strings) | `String` | — | **`InvalidUtf8` error** |
94/// | `CompactStrings` | `CompactString` | `compact_str` | **`InvalidUtf8` error** |
95/// | `SmolStrings` | `SmolStr` | `smol_str` | **`InvalidUtf8` error** |
96/// | `BytesCrate` | `bytes::Bytes` | `bytes` | n/a |
97#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
98pub enum DomainVarData {
99    /// Byte-exact var-data (`Vec<u8>`) — binary tails or lossless re-encode.
100    #[default]
101    Bytes,
102    /// Text-friendly var-data (`String`). Invalid UTF-8 returns
103    /// `DecodeError::InvalidUtf8` (strict).
104    Strings,
105    /// Inline short strings (`compact_str::CompactString`, ≤24 bytes on stack).
106    /// Requires feature `compact_str`.
107    #[cfg(feature = "compact_str")]
108    CompactStrings,
109    /// O(1)-clone strings (`smol_str::SmolStr`). Requires feature `smol_str`.
110    #[cfg(feature = "smol_str")]
111    SmolStrings,
112    /// Zero-copy shared buffer (`bytes::Bytes`). Requires feature `bytes`.
113    #[cfg(feature = "bytes")]
114    BytesCrate,
115}
116
117/// Who writes the `TryFromSbe`/`TryToSbe` impl for a built-in
118/// [`GenerationConfig::with_domain_type`] mapping (`bool` /
119/// `rust_decimal::Decimal` / `chrono::DateTime<Utc>`).
120///
121/// Only matters for those three well-known type paths — any other
122/// `rust_type` never gets an auto-generated impl regardless of this setting,
123/// since ergo-sbe has no built-in conversion logic to offer for it.
124#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
125pub enum DomainImpl {
126    /// ergo-sbe writes the impl for you (default).
127    #[default]
128    Generated,
129    /// You write `impl TryFromSbe<Wire>` / `impl TryToSbe<Wire>` yourself —
130    /// e.g. a custom rounding rule, or null/validation behaviour the
131    /// built-in impl doesn't match. ergo-sbe still generates the concrete
132    /// `try_price(...)?` / `try_price()?` signatures that call it.
133    Manual,
134}
135
136/// Generated-code surface presets.
137///
138/// Individual knobs (`with_display_debug`, …) still override after
139/// [`GenerationConfig::profile`].
140#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
141pub enum GenerationProfile {
142    /// Display/Debug, field meta, multi-template dispatch, and all conveniences
143    /// that the boolean knobs enable by default.
144    #[default]
145    Full,
146    /// Byte codec + typed stages + exact sizing only. Omits Display/Debug,
147    /// meta-attribute constants, and `AnyMessage`/`FrameCursor` dispatch.
148    /// Domain DTOs and conversions stay off unless re-enabled explicitly.
149    Lean,
150}
151
152// ── Hook types ────────────────────────────────────────────────────────────
153
154/// Kinds of generated items a hook can observe.
155#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
156pub enum ItemKind {
157    /// An SBE enum type.
158    Enum,
159    /// An SBE bitset type.
160    Set,
161    /// An SBE composite type.
162    Composite,
163    /// A message decoder (flyweight over `&[u8]`).
164    MessageDecoder,
165    /// A message encoder (writes into `&mut [u8]`).
166    MessageEncoder,
167    /// A domain DTO struct.
168    DomainStruct,
169}
170
171/// One enum variant for hook introspection.
172#[derive(Clone, Debug)]
173pub struct EnumVariantInfo {
174    /// Variant name in PascalCase (e.g. "Ok", "Error").
175    pub name: String,
176    /// Variant name in snake_case (e.g. "ok", "error").
177    pub snake_name: String,
178    /// Raw name from the schema (e.g. "Ok", "hasPrice"). Use for serde labels.
179    pub label: String,
180    /// Wire discriminant value. Widened to `i128` so `uint64` discriminants
181    /// above `i64::MAX` are represented faithfully rather than wrapping negative.
182    pub value: i128,
183    /// Schema description, if present.
184    pub description: Option<String>,
185}
186
187/// One bitset choice for hook introspection.
188#[derive(Clone, Debug)]
189pub struct SetChoiceInfo {
190    /// Choice name in PascalCase (e.g. "HasPrice").
191    pub name: String,
192    /// Choice name in snake_case (e.g. "has_price"). Use for accessor calls.
193    pub snake_name: String,
194    /// Raw name from the schema (e.g. "hasPrice"). Use for serde labels.
195    pub label: String,
196    /// Zero-based bit position in the bitset.
197    pub bit_position: u8,
198    /// Schema description, if present.
199    pub description: Option<String>,
200}
201
202/// One field for hook introspection.
203#[derive(Clone, Debug)]
204pub struct FieldInfo {
205    /// Field name in snake_case.
206    pub name: String,
207    /// Rust type (e.g. "i64", "u8", "EventCode").
208    pub rust_type: String,
209    /// Byte offset from the message body start, when this is a fixed
210    /// scalar/array/composite/enum/set field. `None` for groups and
211    /// var-data fields, which have no single wire offset.
212    pub offset: Option<usize>,
213    /// Schema version this field was introduced in (0 = always present).
214    pub since_version: u16,
215    /// SBE `semanticType` attribute, if set.
216    pub semantic_type: Option<String>,
217    /// SBE presence: `"required"`, `"optional"`, or `"constant"`.
218    pub presence: &'static str,
219    /// Null sentinel value (optional fields only).
220    pub null_value: Option<u64>,
221    /// Whether the field is schema-deprecated.
222    pub deprecated: bool,
223    /// Schema description on the field, if present.
224    pub description: Option<String>,
225}
226
227/// Per-item context passed to hooks.
228///
229/// Every variant carries a `schema` reference for full IR access
230/// when the structured fields aren't enough.
231///
232/// Pattern-match on the variant to access item-specific data
233/// (variants, choices, fields). Use [`quote::quote!`] in your
234/// hook body to return tokens appended after the generated item.
235// manual Debug/Clone because &Schema in every variant makes derive unhappy
236#[derive(Clone)]
237pub enum ItemContext<'a> {
238    /// An SBE enum after codegen of its Rust type.
239    Enum {
240        /// Full schema IR for advanced hooks.
241        schema: &'a crate::Schema,
242        /// Enum type name (PascalCase).
243        name: String,
244        /// Wire encoding type (e.g. `"u8"`).
245        encoding_type: String,
246        /// Variants in schema order.
247        variants: Vec<EnumVariantInfo>,
248    },
249    /// An SBE bitset after codegen.
250    Set {
251        /// Full schema IR for advanced hooks.
252        schema: &'a crate::Schema,
253        /// Set type name (PascalCase).
254        name: String,
255        /// Wire encoding type (e.g. `"u8"`).
256        encoding_type: String,
257        /// Bit choices in schema order.
258        choices: Vec<SetChoiceInfo>,
259    },
260    /// An SBE composite after codegen.
261    Composite {
262        /// Full schema IR for advanced hooks.
263        schema: &'a crate::Schema,
264        /// Composite type name (PascalCase).
265        name: String,
266        /// Member fields in wire order.
267        fields: Vec<FieldInfo>,
268    },
269    /// A message decoder flyweight.
270    MessageDecoder {
271        /// Full schema IR for advanced hooks.
272        schema: &'a crate::Schema,
273        /// Message name (PascalCase).
274        name: String,
275        /// SBE template id.
276        template_id: u16,
277        /// Compiled fixed block length in bytes.
278        block_length: usize,
279        /// Fixed/group/data fields visible on this message.
280        fields: Vec<FieldInfo>,
281    },
282    /// A message encoder stage root.
283    MessageEncoder {
284        /// Full schema IR for advanced hooks.
285        schema: &'a crate::Schema,
286        /// Message name (PascalCase).
287        name: String,
288        /// SBE template id.
289        template_id: u16,
290        /// Compiled fixed block length in bytes.
291        block_length: usize,
292        /// Fixed/group/data fields visible on this message.
293        fields: Vec<FieldInfo>,
294    },
295    /// An owned domain DTO (`*Domain`) when domain objects are enabled.
296    DomainStruct {
297        /// Full schema IR for advanced hooks.
298        schema: &'a crate::Schema,
299        /// DTO type name (PascalCase).
300        name: String,
301        /// Fields on the DTO (including groups/var-data as owned types).
302        fields: Vec<FieldInfo>,
303    },
304}
305
306impl std::fmt::Debug for ItemContext<'_> {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        let (kind, name) = match self {
309            Self::Enum { name, .. } => ("Enum", name.as_str()),
310            Self::Set { name, .. } => ("Set", name.as_str()),
311            Self::Composite { name, .. } => ("Composite", name.as_str()),
312            Self::MessageDecoder { name, .. } => ("MessageDecoder", name.as_str()),
313            Self::MessageEncoder { name, .. } => ("MessageEncoder", name.as_str()),
314            Self::DomainStruct { name, .. } => ("DomainStruct", name.as_str()),
315        };
316        f.debug_struct("ItemContext")
317            .field("kind", &kind)
318            .field("name", &name)
319            .finish()
320    }
321}
322
323/// Token streams returned by hooks — appended after the generated item.
324pub type HookFn = dyn Fn(&ItemContext<'_>) -> Vec<proc_macro2::TokenStream> + Send + Sync;
325
326/// Wrapper so hooks can live in [`GenerationConfig`]. Uses [`Arc`] so
327/// `GenerationConfig` can derive [`Clone`].
328#[derive(Clone, Default)]
329pub(crate) struct Hooks(Vec<std::sync::Arc<HookFn>>);
330
331impl std::fmt::Debug for Hooks {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        f.debug_tuple("Hooks").field(&self.0.len()).finish()
334    }
335}
336impl Hooks {
337    pub(crate) fn push(&mut self, hook: std::sync::Arc<HookFn>) {
338        self.0.push(hook);
339    }
340    pub(crate) fn iter(&self) -> std::slice::Iter<'_, std::sync::Arc<HookFn>> {
341        self.0.iter()
342    }
343    pub(crate) fn is_empty(&self) -> bool {
344        self.0.is_empty()
345    }
346}
347
348// ── GenerationConfig ──────────────────────────────────────────────────────
349
350/// Options that shape generated Rust codecs.
351///
352/// Start with [`GenerationConfig::new`], chain builder methods, then pass to
353/// [`crate::Generator::new`].
354///
355/// ```rust
356/// use ergo_sbe::{DomainVarData, GenerationConfig, ConversionSelector};
357///
358/// let config = GenerationConfig::new("market_data")
359///     .with_domain_objects(DomainVarData::Strings)
360///     .with_domain_type(
361///         ConversionSelector::named_type("Decimal"),
362///         "rust_decimal::Decimal",
363///     );
364/// ```
365#[derive(Clone)]
366pub struct GenerationConfig {
367    /// Rust module name for the generated output file (`{module_name}.rs`).
368    pub(crate) module_name: String,
369    /// Sibling module that already owns shared types (multi-schema mode).
370    pub(crate) shared_module: Option<String>,
371    /// Emit owned `*Domain` structs + `TryFrom<&Decoder>` / `encode`.
372    pub(crate) domain_objects: bool,
373    /// Var-data shape on DTOs when `domain_objects` is set.
374    pub(crate) domain_var_data: DomainVarData,
375    /// Selectors for generic `*_as` / `*_from` conversion methods.
376    pub(crate) conversions: Vec<ConversionSelector>,
377    /// Domain-type mappings: `(selector, rust_type_path)`.
378    /// Implicitly enables conversion for the same selector.
379    pub(crate) domain_types: Vec<(ConversionSelector, String)>,
380    /// Selectors passed to [`Self::with_manual_domain_type`]:
381    /// get the same concrete `try_*` signatures as any other `domain_types`
382    /// entry, but the built-in `TryFromSbe`/`TryToSbe` impl (for `bool` /
383    /// `rust_decimal::Decimal` / `chrono::DateTime<Utc>`) is not generated —
384    /// the caller supplies it.
385    pub(crate) manual_impl_selectors: Vec<ConversionSelector>,
386    /// When set, emit `pub use <path> as sbe_rt;` instead of inlining runtime.
387    pub(crate) external_sbe_rt_path: Option<String>,
388    /// Emit `From<EncodeError/DecodeError>` for this error type path.
389    pub(crate) error_from_path: Option<String>,
390    /// Map enum/boolean NullVal → `Option<T>`. Matched selectors produce
391    /// `Option<EventCode>` accessors instead of bare enum types.
392    /// Wire is byte-identical: `None` writes the `NullVal` discriminant.
393    pub(crate) null_as_option: Vec<ConversionSelector>,
394    /// When true, every enum in the schema gets `Option<Enum>` accessors
395    /// without needing individual `with_null_as_option` calls.
396    pub(crate) all_enums_as_option: bool,
397    /// Emit `bool` ↔ BooleanType converters automatically for every enum
398    /// detected as boolean (name `BooleanType` or `semanticType="Boolean"`).
399    /// Equivalent to calling `with_domain_type(named_type(name), "bool")` for
400    /// each — saves boilerplate on schemas with many boolean flags.
401    pub(crate) auto_bool_domain: bool,
402    /// Emit `_unchecked` companions for benchmarking.
403    /// Appended when a name is a Rust keyword (default `"_"`).
404    pub(crate) keyword_append_token: String,
405    /// Emit `#[deprecated]` on schema-deprecated items (opt-in).
406    pub(crate) deprecated_attrs: bool,
407    /// Emit `Debug`/`Display` impls (default on; pass `false` to shrink output).
408    pub(crate) enable_display_debug: bool,
409    /// Emit meta-attribute constants (default on; pass `false` to shrink output).
410    pub(crate) enable_meta_attributes: bool,
411    /// Emit `AnyMessage`/`FrameCursor`/`MessageVisitor` dispatch (default on).
412    pub(crate) enable_dispatch: bool,
413    /// Hooks fired after each generated item (enum, set, composite, message).
414    /// Returned tokens are appended after the item's definition.
415    pub(crate) hooks: Hooks,
416}
417
418impl std::fmt::Debug for GenerationConfig {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        f.debug_struct("GenerationConfig")
421            .field("module_name", &self.module_name)
422            .field("shared_module", &self.shared_module)
423            .field("domain_objects", &self.domain_objects)
424            .field("domain_var_data", &self.domain_var_data)
425            .field("conversions", &self.conversions)
426            .field("domain_types", &self.domain_types)
427            .field("manual_impl_selectors", &self.manual_impl_selectors)
428            .field("external_sbe_rt_path", &self.external_sbe_rt_path)
429            .field("error_from_path", &self.error_from_path)
430            .field("null_as_option", &self.null_as_option)
431            .field("all_enums_as_option", &self.all_enums_as_option)
432            .field("auto_bool_domain", &self.auto_bool_domain)
433            .field("keyword_append_token", &self.keyword_append_token)
434            .field("deprecated_attrs", &self.deprecated_attrs)
435            .field("enable_display_debug", &self.enable_display_debug)
436            .field("enable_meta_attributes", &self.enable_meta_attributes)
437            .field("enable_dispatch", &self.enable_dispatch)
438            .field("hooks", &self.hooks)
439            .finish()
440    }
441}
442
443impl GenerationConfig {
444    /// Create a config for output module `{module_name}.rs` with
445    /// [`GenerationProfile::Full`] defaults.
446    ///
447    /// ```rust
448    /// use ergo_sbe::GenerationConfig;
449    /// let c = GenerationConfig::new("msgs");
450    /// ```
451    #[must_use]
452    pub fn new(module_name: impl Into<String>) -> Self {
453        Self {
454            module_name: module_name.into(),
455            shared_module: None,
456            domain_objects: false,
457            domain_var_data: DomainVarData::Bytes,
458            conversions: Vec::new(),
459            domain_types: Vec::new(),
460            manual_impl_selectors: Vec::new(),
461            external_sbe_rt_path: None,
462            error_from_path: None,
463            keyword_append_token: "_".into(),
464            deprecated_attrs: false,
465            null_as_option: Vec::new(),
466            all_enums_as_option: false,
467            auto_bool_domain: false,
468            enable_display_debug: true,
469            enable_meta_attributes: true,
470            enable_dispatch: true,
471            hooks: Hooks::default(),
472        }
473    }
474
475    /// Create a config with [`GenerationProfile::Lean`] defaults (no
476    /// Display/Debug, no meta attributes, no dispatch, no domain objects).
477    ///
478    /// Equivalent to `GenerationConfig::new(name).profile(GenerationProfile::Lean)`
479    /// but more direct. Explicit `with_*` settings (conversions, domain types,
480    /// auto-bool) can be added after — they are not cleared.
481    ///
482    /// ```rust
483    /// use ergo_sbe::GenerationConfig;
484    /// let c = GenerationConfig::lean("minimal");
485    /// ```
486    #[must_use]
487    pub fn lean(module_name: impl Into<String>) -> Self {
488        Self::new(module_name).profile(GenerationProfile::Lean)
489    }
490
491    /// The module name for generated output.
492    #[must_use]
493    pub(crate) fn module_name(&self) -> &str {
494        &self.module_name
495    }
496
497    /// Override the module name set in [`new`](Self::new). Use when cloning a base
498    /// config across several schemas — set the placeholder in [`new`](Self::new), then
499    /// call `.clone().with_module_name("orderbook")` on each.
500    ///
501    /// ```rust
502    /// use ergo_sbe::GenerationConfig;
503    /// let base = GenerationConfig::new("_");
504    /// let a = base.clone().with_module_name("md");
505    /// let b = base.clone().with_module_name("ob");
506    /// // `a` generates `md.rs`, `b` generates `ob.rs`, `base` unchanged.
507    /// ```
508    #[must_use]
509    pub fn with_module_name(mut self, name: impl Into<String>) -> Self {
510        let name = name.into();
511        debug_assert!(
512            is_valid_module_ident(&name),
513            "module name '{name}' contains path separators, '.', '..', or is empty"
514        );
515        self.module_name = name;
516        self
517    }
518
519    #[must_use]
520    pub(crate) fn domain_objects_enabled(&self) -> bool {
521        self.domain_objects
522    }
523
524    pub(crate) fn has_conversions(&self) -> bool {
525        // `with_bool_domain_type` is syntax sugar for `with_domain_type` on
526        // each boolean enum — it must also emit TryFromSbe/TryToSbe traits.
527        !self.conversions.is_empty() || !self.domain_types.is_empty() || self.auto_bool_domain
528    }
529
530    /// The external sbe_rt path, if set.
531    #[must_use]
532    pub(crate) fn external_sbe_rt_path(&self) -> Option<&str> {
533        self.external_sbe_rt_path.as_deref()
534    }
535
536    /// Re-use one `sbe_rt` runtime across separately generated schema modules.
537    ///
538    /// `path` must work in `pub use <path> as sbe_rt;`.
539    ///
540    /// ```
541    /// # use ergo_sbe::GenerationConfig;
542    /// // first module embeds sbe_rt; later modules do:
543    /// // pub use crate::common::sbe_rt as sbe_rt;
544    /// GenerationConfig::new("md")
545    ///     .with_external_sbe_rt("crate::common::sbe_rt");
546    /// ```
547    #[must_use]
548    pub fn with_external_sbe_rt(mut self, path: impl Into<String>) -> Self {
549        self.external_sbe_rt_path = Some(path.into());
550        self
551    }
552
553    /// Enable **generic** conversion methods for matching fields.
554    ///
555    /// # Generated API
556    ///
557    /// In build.rs: `.with_conversion(ConversionSelector::named_type("Decimal"))`.
558    /// Application code: `enc.price_from(&my_price)?;` / `dec.price_as::<MyPrice>()?`.
559    ///
560    /// # Example
561    ///
562    /// ```rust
563    /// use ergo_sbe::{GenerationConfig, ConversionSelector};
564    ///
565    /// let config = GenerationConfig::new("msgs")
566    ///     .with_conversion(ConversionSelector::named_type("Decimal"));
567    /// ```
568    ///
569    /// → [`sbe/tests/comprehensive_test.rs`](https://github.com/mimran1980/ergon/blob/main/sbe/tests/comprehensive_test.rs)
570    ///
571    /// Prefer [`Self::with_domain_type`] when one concrete Rust type is enough.
572    /// Duplicate selectors are ignored; selectors matching nothing error at
573    /// [`crate::Generator::generate`] time.
574    #[must_use]
575    pub fn with_conversion(mut self, selector: ConversionSelector) -> Self {
576        if !self.conversions.contains(&selector) {
577            self.conversions.push(selector);
578        }
579        self
580    }
581
582    /// Map matching enum fields from `NullVal` → `Option<T>`.
583    ///
584    /// Wire encoding is byte-identical: `None` writes the `NullVal`
585    /// discriminant, `Some(v)` writes `v`. Zero runtime cost.
586    ///
587    /// ```rust
588    /// use ergo_sbe::{ConversionSelector, GenerationConfig};
589    ///
590    /// // EventCode fields → Option<EventCode>
591    /// let config = GenerationConfig::new("msgs")
592    ///     .with_null_as_option(ConversionSelector::named_type("EventCode"));
593    /// ```
594    #[must_use]
595    pub fn with_null_as_option(mut self, selector: ConversionSelector) -> Self {
596        if !self.null_as_option.contains(&selector) {
597            self.null_as_option.push(selector);
598        }
599        self
600    }
601
602    /// Map **every** enum field in the schema to `Option<Enum>`.
603    ///
604    /// Shorthand for calling [`with_null_as_option`](Self::with_null_as_option)
605    /// on every named type. Opt-out per-enum is not yet supported —
606    /// the blanket flag always wins.
607    ///
608    /// ```rust
609    /// use ergo_sbe::GenerationConfig;
610    /// let config = GenerationConfig::new("msgs")
611    ///     .with_all_enums_as_option();
612    /// ```
613    #[must_use]
614    pub fn with_all_enums_as_option(mut self) -> Self {
615        self.all_enums_as_option = true;
616        self
617    }
618
619    /// Map matching fields to a **concrete** Rust type path.
620    ///
621    /// Implies [`Self::with_conversion`] for the same selector and emits
622    /// well-known `TryFromSbe`/`TryToSbe` impls for `bool`,
623    /// `rust_decimal::Decimal`, and `chrono::DateTime<Utc>` when those paths
624    /// are used. For any other `rust_type`, no impl is auto-generated.
625    ///
626    /// To skip the built-in impl and supply your own, use
627    /// [`Self::with_manual_domain_type`].
628    ///
629    /// # Generated API
630    ///
631    /// In build.rs: `.with_domain_type(ConversionSelector::named_type("Decimal"), "rust_decimal::Decimal")`
632    /// Application: `enc.try_price(rust_decimal::Decimal::new(12345, 2))?` / `let p = dec.try_price()?`.
633    ///
634    /// # Example
635    ///
636    /// ```rust
637    /// use ergo_sbe::{GenerationConfig, ConversionSelector};
638    ///
639    /// let config = GenerationConfig::new("msgs")
640    ///     .with_domain_type(
641    ///         ConversionSelector::named_type("Decimal"),
642    ///         "rust_decimal::Decimal",
643    ///     );
644    /// ```
645    ///
646    /// Do **not** also call [`Self::with_conversion`] for the same selector.
647    #[must_use]
648    pub fn with_domain_type(
649        self,
650        selector: ConversionSelector,
651        rust_type: impl Into<String>,
652    ) -> Self {
653        self.apply_domain_type(selector, rust_type, DomainImpl::Generated)
654    }
655
656    /// Like [`Self::with_domain_type`], but you write `impl TryFromSbe<Wire>`
657    /// / `impl TryToSbe<Wire>` yourself — e.g. a custom rounding rule, or
658    /// null/validation behaviour the built-in impl does not match.
659    ///
660    /// ergo-sbe still generates the concrete `try_price(...)?` / `try_price()?`
661    /// signatures that call those impls. A missing impl fails closed with a
662    /// named compile error, and for the three built-ins the generated
663    /// accessor's rustdoc carries the exact impl `Generated` would have
664    /// written, ready to copy and adjust.
665    ///
666    /// ```rust
667    /// use ergo_sbe::{GenerationConfig, ConversionSelector};
668    ///
669    /// let config = GenerationConfig::new("msgs")
670    ///     .with_manual_domain_type(
671    ///         ConversionSelector::named_type("Decimal"),
672    ///         "rust_decimal::Decimal",
673    ///     );
674    /// // Application code must provide:
675    /// //   impl TryFromSbe<Decimal> for rust_decimal::Decimal { ... }
676    /// //   impl TryToSbe<Decimal> for rust_decimal::Decimal { ... }
677    /// ```
678    #[must_use]
679    pub fn with_manual_domain_type(
680        self,
681        selector: ConversionSelector,
682        rust_type: impl Into<String>,
683    ) -> Self {
684        self.apply_domain_type(selector, rust_type, DomainImpl::Manual)
685    }
686
687    fn apply_domain_type(
688        mut self,
689        selector: ConversionSelector,
690        rust_type: impl Into<String>,
691        impl_kind: DomainImpl,
692    ) -> Self {
693        let sel = selector;
694        let ty = rust_type.into();
695        if !self.conversions.contains(&sel) {
696            self.conversions.push(sel.clone());
697        }
698        match impl_kind {
699            DomainImpl::Generated => self.manual_impl_selectors.retain(|s| s != &sel),
700            DomainImpl::Manual => {
701                if !self.manual_impl_selectors.contains(&sel) {
702                    self.manual_impl_selectors.push(sel.clone());
703                }
704            }
705        }
706        // Last-write-wins: calling with_domain_type(sel, "B") after
707        // with_domain_type(sel, "A") replaces the mapping.
708        if let Some(existing) = self.domain_types.iter_mut().find(|(s, _)| s == &sel) {
709            existing.1 = ty;
710        } else {
711            self.domain_types.push((sel, ty));
712        }
713        self
714    }
715
716    /// Emit `From<sbe_rt::EncodeError>` / `From<sbe_rt::DecodeError>` for your error type.
717    ///
718    /// In build.rs: `.with_error_from_impls("crate::AppError")`.
719    /// Application code: `enc.group(...)?;` — `EncodeError` auto-converts via `From`.
720    ///
721    /// **Note:** The generated `From` impl uses `format!("sbe encode: {err}")` —
722    /// stringifying the typed error through its `Display` form, then calling
723    /// `YourType::from(String)`. This means (1) your error type must implement
724    /// `From<String>`, and (2) field-level error details (e.g.
725    /// `EncodeError::BufferTooShort { field, needed, available }`) are lost in
726    /// the conversion. Implement `From<generated::sbe_rt::EncodeError>` and
727    /// `From<generated::sbe_rt::DecodeError>` on your error type so those
728    /// fields survive. Removal is scheduled for 1.0.
729    #[must_use]
730    #[deprecated(
731        since = "0.1.20",
732        note = "implement From<generated::sbe_rt::EncodeError> and From<generated::sbe_rt::DecodeError> on your error type so wire fields (needed/available) are preserved; this helper formats through String and will be removed in 1.0"
733    )]
734    pub fn with_error_from_impls(mut self, path: impl Into<String>) -> Self {
735        self.error_from_path = Some(path.into());
736        self
737    }
738
739    /// Generate owned domain structs next to flyweight codecs.
740    ///
741    /// # `var_data` — important choice ([`DomainVarData`])
742    ///
743    /// | Mode | DTO field | Invalid UTF-8 |
744    /// |------|-----------|---------------|
745    /// | [`DomainVarData::Bytes`] | `Vec<u8>` | n/a |
746    /// | [`DomainVarData::Strings`] | `String` | `InvalidUtf8` error (strict) |
747    ///
748    /// ```rust
749    /// use ergo_sbe::{DomainVarData, GenerationConfig};
750    /// let text = GenerationConfig::new("msgs")
751    ///     .with_domain_objects(DomainVarData::Strings);
752    /// let bytes = GenerationConfig::new("msgs")
753    ///     .with_domain_objects(DomainVarData::Bytes);
754    /// let _ = (text, bytes);
755    /// ```
756    ///
757    /// # Generated API
758    ///
759    /// `DomainVarData::Strings` → `manufacturer: String`.
760    /// `DomainVarData::Bytes` → `manufacturer: Vec<u8>`.
761    ///
762    /// → [`sbe/tests/domain_objects_test.rs`](https://github.com/mimran1980/ergon/blob/main/sbe/tests/domain_objects_test.rs)
763    #[must_use]
764    pub fn with_domain_objects(mut self, var_data: DomainVarData) -> Self {
765        self.domain_objects = true;
766        self.domain_var_data = var_data;
767        self
768    }
769
770    /// Shared module name for multi-schema generation ([`crate::Generator::generate_multi`]).
771    ///
772    /// First schema owns shared enums/sets/composites; later modules
773    /// `pub use super::<name>::*`.
774    #[must_use]
775    pub fn with_shared_module(mut self, name: impl Into<String>) -> Self {
776        self.shared_module = Some(name.into());
777        self
778    }
779
780    /// Token appended when a schema name is a Rust keyword (default `”_”`).
781    ///
782    /// Schema field `name="type"` becomes method `type_()`; with token `"x"`,
783    /// it becomes `typex()`.
784    ///
785    /// ```rust
786    /// use ergo_sbe::GenerationConfig;
787    /// let c = GenerationConfig::new("m").with_keyword_append_token("_");
788    /// let _ = c;
789    /// ```
790    #[must_use]
791    pub fn with_keyword_append_token(mut self, token: impl Into<String>) -> Self {
792        self.keyword_append_token = token.into();
793        self
794    }
795
796    /// Auto-register `bool` converters for every boolean enum in the
797    /// schema. Syntax sugar for calling
798    /// `with_domain_type(named_type("BooleanType"), "bool")` for each —
799    /// detects by name, `semanticType="Boolean"`, or True/False value pairs
800    /// with discriminants `0` and `1`.
801    ///
802    /// Only the canonical `{0, 1}` discriminant representation is detected
803    /// automatically. Schemas with non-standard boolean encodings (e.g.
804    /// `Yes=5, No=3`) should use explicit [`ConversionSelector::named_type`]
805    /// with [`GenerationConfig::with_conversion`] instead.
806    #[must_use]
807    pub fn with_bool_domain_type(mut self, enable: bool) -> Self {
808        self.auto_bool_domain = enable;
809        self
810    }
811
812    /// Emit `#[deprecated]` on schema-deprecated fields/types/messages.
813    #[must_use]
814    pub fn with_deprecated_attrs(mut self, enable: bool) -> Self {
815        self.deprecated_attrs = enable;
816        self
817    }
818
819    /// Control generated `Debug` and `Display` impls (**enabled by default**).
820    /// Pass `false` to omit them and shrink generated output.
821    #[must_use]
822    pub fn with_display_debug(mut self, enable: bool) -> Self {
823        self.enable_display_debug = enable;
824        self
825    }
826
827    /// Control meta-attribute constants (**enabled by default**). Pass `false`
828    /// to omit — removes `*_meta_attribute`, `*_ENCODING_OFFSET`,
829    /// `*_ENCODING_LENGTH`, `*_ID`, `*_SINCE_VERSION`, null/min/max field
830    /// constants, and the per-message `*_field_meta` module.
831    #[must_use]
832    pub fn with_meta_attributes(mut self, enable: bool) -> Self {
833        self.enable_meta_attributes = enable;
834        self
835    }
836
837    /// Control `AnyMessage` / `FrameCursor` / `MessageVisitor` dispatch code
838    /// (**enabled by default**). Pass `false` to omit — saves ~300 lines;
839    /// only meaningful when you do not need multi-template frame dispatch.
840    #[must_use]
841    pub fn with_dispatch(mut self, enable: bool) -> Self {
842        self.enable_dispatch = enable;
843        self
844    }
845
846    /// Apply a product profile that sets the size knobs together.
847    ///
848    /// | Profile | Display/Debug | Meta attrs | Dispatch | Domain objects |
849    /// |---------|---------------|------------|----------|----------------|
850    /// | [`GenerationProfile::Full`] | on | on | on | unchanged |
851    /// | [`GenerationProfile::Lean`] | off | off | off | off |
852    ///
853    /// Explicit `with_*` settings (conversions, domain types, auto-bool) win
854    /// regardless of order — `profile()` only sets the knobs it owns and
855    /// never clears explicit configuration. Prefer
856    /// [`lean`](Self::lean) for a clean Lean baseline.
857    ///
858    /// Chain further `with_*` calls after `profile` to override individual
859    /// knobs. Example:
860    ///
861    /// ```rust
862    /// use ergo_sbe::{GenerationConfig, GenerationProfile};
863    /// let _ = GenerationConfig::new("feed").profile(GenerationProfile::Lean);
864    /// ```
865    #[must_use]
866    pub fn profile(mut self, profile: GenerationProfile) -> Self {
867        match profile {
868            GenerationProfile::Full => {
869                self.enable_display_debug = true;
870                self.enable_meta_attributes = true;
871                self.enable_dispatch = true;
872            }
873            GenerationProfile::Lean => {
874                self.enable_display_debug = false;
875                self.enable_meta_attributes = false;
876                self.enable_dispatch = false;
877                self.domain_objects = false;
878                // ponytail: explicit conversion/domain-type settings survive
879                // profile() — they represent deliberate schema choices with
880                // higher precedence than a bulk surface preset.
881            }
882        }
883        self
884    }
885
886    /// Register a code-generation hook. The closure receives an
887    /// [`ItemContext`] for each generated item (enum, set, composite,
888    /// message decoder/encoder, domain struct) and returns token streams
889    /// appended after the item's definition.
890    ///
891    /// Hooks fire in registration order. Use [`quote::quote!`] in your
892    /// closure body to build the returned tokens.
893    ///
894    /// # Example — serde `Serialize` for enums
895    ///
896    /// ```rust
897    /// use ergo_sbe::{GenerationConfig, ItemContext};
898    /// use quote::quote;
899    ///
900    /// let config = GenerationConfig::new("msgs")
901    ///     .with_hook(|ctx: &ItemContext| -> Vec<proc_macro2::TokenStream> {
902    ///         match ctx {
903    ///             ItemContext::Enum { name, variants, .. } => {
904    ///                 // Manual Serialize impl appends after the enum definition
905    ///                 vec![quote! { /* impl Serialize for ... */ }]
906    ///             }
907    ///             _ => vec![],
908    ///         }
909    ///     });
910    /// ```
911    #[must_use]
912    pub fn with_hook<F>(mut self, hook: F) -> Self
913    where
914        F: Fn(&ItemContext) -> Vec<proc_macro2::TokenStream> + Send + Sync + 'static,
915    {
916        self.hooks.push(std::sync::Arc::new(hook));
917        self
918    }
919
920    /// True when at least one hook is registered.
921    pub(crate) fn has_hooks(&self) -> bool {
922        !self.hooks.is_empty()
923    }
924
925    /// Iterate all registered hooks.
926    pub(crate) fn run_hooks(&self, ctx: &ItemContext, out: &mut String) {
927        for hook in self.hooks.iter() {
928            for ts in hook(ctx) {
929                // Use TokenStream Display impl for formatting.
930                // For simple impl blocks this produces valid Rust.
931                use std::fmt::Write;
932                let _ = writeln!(out, "{}", ts);
933            }
934        }
935    }
936}
937
938impl Default for GenerationConfig {
939    fn default() -> Self {
940        Self::new("messages")
941    }
942}
943
944/// Reject module names that are not valid for `mod name;` in generated Rust.
945///
946/// `syn::parse_str::<syn::Ident>` alone accepts keywords (they parse as idents
947/// in isolation). We additionally reject strict keywords and reserved words
948/// that require a raw identifier (`r#gen`) under current/edition-reserved
949/// Rust editions — including 2024's `gen`.
950pub(crate) fn is_valid_module_ident(name: &str) -> bool {
951    if name.is_empty()
952        || name.contains('/')
953        || name.contains('\\')
954        || name.contains('.')
955        || name == ".."
956    {
957        return false;
958    }
959    if is_rust_keyword_or_reserved(name) {
960        return false;
961    }
962    syn::parse_str::<syn::Ident>(name).is_ok()
963}
964
965/// Strict keywords + reserved identifiers that cannot appear as a plain
966/// `mod name;` without `r#` escaping.
967fn is_rust_keyword_or_reserved(name: &str) -> bool {
968    // Keep in sync with the Rust reference keyword tables (strict + reserved
969    // + edition-reserved). Weak keywords (`union`, `macro_rules`, …) remain
970    // allowed as module names.
971    matches!(
972        name,
973        // Strict
974        "as" | "break" | "const" | "continue" | "crate" | "else" | "enum" | "extern"
975            | "false" | "fn" | "for" | "if" | "impl" | "in" | "let" | "loop" | "match"
976            | "mod" | "move" | "mut" | "pub" | "ref" | "return" | "self" | "Self"
977            | "static" | "struct" | "super" | "trait" | "true" | "type" | "unsafe"
978            | "use" | "where" | "while"
979            // 2018+
980            | "async" | "await" | "dyn"
981            // Reserved
982            | "abstract" | "become" | "box" | "do" | "final" | "macro" | "override"
983            | "priv" | "typeof" | "unsized" | "virtual" | "yield"
984            // Reserved / edition-reserved
985            | "try" | "gen"
986    )
987}
988
989#[cfg(test)]
990mod tests {
991    use super::{ConversionSelector, DomainVarData, GenerationConfig, GenerationProfile};
992
993    #[test]
994    fn default_config_is_clean() -> Result<(), Box<dyn std::error::Error>> {
995        let config = GenerationConfig::default();
996        assert_eq!(config.module_name(), "messages");
997        assert!(!config.domain_objects_enabled());
998        assert!(!config.has_conversions());
999        Ok(())
1000    }
1001
1002    #[test]
1003    fn with_conversion_adds_selector() -> Result<(), Box<dyn std::error::Error>> {
1004        let config = GenerationConfig::new("test")
1005            .with_conversion(ConversionSelector::named_type("Decimal"));
1006        assert!(config.has_conversions());
1007        assert_eq!(config.conversions.len(), 1);
1008        Ok(())
1009    }
1010
1011    #[test]
1012    fn profile_lean_preserves_explicit_conversions_and_domain_types()
1013    -> Result<(), Box<dyn std::error::Error>> {
1014        let full = GenerationConfig::new("m").profile(GenerationProfile::Full);
1015        assert!(full.enable_display_debug);
1016        assert!(full.enable_meta_attributes);
1017        assert!(full.enable_dispatch);
1018
1019        let lean = GenerationConfig::new("m")
1020            .with_domain_objects(DomainVarData::Bytes)
1021            .with_conversion(ConversionSelector::named_type("Decimal"))
1022            .with_domain_type(
1023                ConversionSelector::named_type("Decimal"),
1024                "rust_decimal::Decimal",
1025            )
1026            .profile(GenerationProfile::Lean);
1027        assert!(!lean.enable_display_debug);
1028        assert!(!lean.enable_meta_attributes);
1029        assert!(!lean.enable_dispatch);
1030        assert!(!lean.domain_objects);
1031        // Explicit conversions and domain types survive Lean — they're
1032        // deliberate schema choices with higher precedence than a bulk preset.
1033        assert!(
1034            lean.has_conversions(),
1035            "explicit conversions must survive Lean"
1036        );
1037        assert!(
1038            !lean.domain_types.is_empty(),
1039            "explicit domain types must survive Lean"
1040        );
1041
1042        // Later overrides still win.
1043        let override_dispatch = GenerationConfig::new("m")
1044            .profile(GenerationProfile::Lean)
1045            .with_dispatch(true);
1046        assert!(override_dispatch.enable_dispatch);
1047        Ok(())
1048    }
1049
1050    #[test]
1051    fn with_conversion_dedup() -> Result<(), Box<dyn std::error::Error>> {
1052        let config = GenerationConfig::new("test")
1053            .with_conversion(ConversionSelector::named_type("Decimal"))
1054            .with_conversion(ConversionSelector::named_type("Decimal"));
1055        assert_eq!(config.conversions.len(), 1);
1056        Ok(())
1057    }
1058
1059    #[test]
1060    fn with_domain_type_adds_conversion_and_type() -> Result<(), Box<dyn std::error::Error>> {
1061        let config = GenerationConfig::new("test").with_domain_type(
1062            ConversionSelector::named_type("Decimal"),
1063            "rust_decimal::Decimal",
1064        );
1065        assert!(config.has_conversions());
1066        assert_eq!(config.conversions.len(), 1);
1067        assert_eq!(config.domain_types.len(), 1);
1068        assert!(config.manual_impl_selectors.is_empty());
1069        Ok(())
1070    }
1071
1072    #[test]
1073    fn with_domain_type_two_argument_calls_agree() -> Result<(), Box<dyn std::error::Error>> {
1074        let sel = ConversionSelector::named_type("Decimal");
1075        let a =
1076            GenerationConfig::new("msgs").with_domain_type(sel.clone(), "rust_decimal::Decimal");
1077        let b = GenerationConfig::new("msgs").with_domain_type(sel, "rust_decimal::Decimal");
1078        assert_eq!(a.domain_types, b.domain_types);
1079        assert_eq!(a.manual_impl_selectors, b.manual_impl_selectors);
1080        Ok(())
1081    }
1082
1083    #[test]
1084    fn with_manual_domain_type_is_additive() -> Result<(), Box<dyn std::error::Error>> {
1085        let sel = ConversionSelector::named_type("Decimal");
1086        let config = GenerationConfig::new("msgs")
1087            .with_manual_domain_type(sel.clone(), "rust_decimal::Decimal");
1088        assert_eq!(config.domain_types.len(), 1);
1089        assert_eq!(config.manual_impl_selectors, vec![sel]);
1090        Ok(())
1091    }
1092
1093    #[test]
1094    fn with_domain_type_dedup() -> Result<(), Box<dyn std::error::Error>> {
1095        let config = GenerationConfig::new("test")
1096            .with_domain_type(
1097                ConversionSelector::named_type("Decimal"),
1098                "rust_decimal::Decimal",
1099            )
1100            .with_domain_type(
1101                ConversionSelector::named_type("Decimal"),
1102                "rust_decimal::Decimal",
1103            );
1104        assert_eq!(config.domain_types.len(), 1);
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn with_external_sbe_rt_sets_path() -> Result<(), Box<dyn std::error::Error>> {
1110        let config = GenerationConfig::new("m").with_external_sbe_rt("crate::rt::sbe_rt");
1111        assert_eq!(config.external_sbe_rt_path(), Some("crate::rt::sbe_rt"));
1112        Ok(())
1113    }
1114
1115    #[test]
1116    fn module_ident_rejects_keywords_and_reserved() -> Result<(), Box<dyn std::error::Error>> {
1117        use super::is_valid_module_ident;
1118        assert!(is_valid_module_ident("messages"));
1119        assert!(is_valid_module_ident("common_types"));
1120        assert!(!is_valid_module_ident("gen")); // Rust 2024 reserved
1121        assert!(!is_valid_module_ident("mod"));
1122        assert!(!is_valid_module_ident("async"));
1123        assert!(!is_valid_module_ident("try"));
1124        assert!(!is_valid_module_ident(""));
1125        assert!(!is_valid_module_ident("a.b"));
1126        Ok(())
1127    }
1128
1129    #[test]
1130    fn new_config_has_correct_defaults() -> Result<(), Box<dyn std::error::Error>> {
1131        let config = GenerationConfig::new("mymod");
1132        assert_eq!(config.module_name(), "mymod");
1133        assert!(!config.domain_objects_enabled());
1134        assert!(config.conversions.is_empty());
1135        assert!(config.domain_types.is_empty());
1136        assert_eq!(config.domain_var_data, DomainVarData::Bytes);
1137        assert!(config.enable_display_debug);
1138        assert!(config.enable_meta_attributes);
1139        assert!(config.enable_dispatch);
1140        Ok(())
1141    }
1142
1143    #[test]
1144    fn with_domain_objects_var_data_modes() -> Result<(), Box<dyn std::error::Error>> {
1145        let text = GenerationConfig::new("m").with_domain_objects(DomainVarData::Strings);
1146        assert!(text.domain_objects_enabled());
1147        assert_eq!(text.domain_var_data, DomainVarData::Strings);
1148        let bytes = GenerationConfig::new("m").with_domain_objects(DomainVarData::Bytes);
1149        assert!(bytes.domain_objects_enabled());
1150        assert_eq!(bytes.domain_var_data, DomainVarData::Bytes);
1151        Ok(())
1152    }
1153
1154    #[test]
1155    fn opt_in_codegen_flags_and_field_selector_are_recorded()
1156    -> Result<(), Box<dyn std::error::Error>> {
1157        let selector = ConversionSelector::field_path("Order.price");
1158        assert_eq!(
1159            selector,
1160            ConversionSelector::FieldPath("Order.price".to_string())
1161        );
1162
1163        #[allow(deprecated)]
1164        let config = GenerationConfig::new("m")
1165            .with_error_from_impls("crate::AppError")
1166            .with_shared_module("shared")
1167            .with_keyword_append_token("x")
1168            .with_bool_domain_type(true)
1169            .with_deprecated_attrs(true);
1170
1171        assert_eq!(config.error_from_path.as_deref(), Some("crate::AppError"));
1172        assert_eq!(config.shared_module.as_deref(), Some("shared"));
1173        assert_eq!(config.keyword_append_token, "x");
1174        assert!(config.auto_bool_domain);
1175        assert!(config.deprecated_attrs);
1176        Ok(())
1177    }
1178}