Skip to main content

ergo_sbe/codegen/
mod.rs

1//! Rust code generation from a resolved [`crate::Schema`].
2//!
3//! Primary type: [`Generator`]. Configure with [`crate::GenerationConfig`],
4//! call [`Generator::generate`] or [`Generator::generate_multi`], write
5//! [`GeneratedModule::source`] to `OUT_DIR`, then `include!` it.
6//!
7//! # Pipeline
8//!
9//! 1. Partition IR tokens into enums, sets, composites, messages.
10//! 2. Emit type definitions.
11//! 3. Per message: decoder flyweight, encoder + type-state tails, optional domain DTO.
12//! 4. Emit `AnyMessage` / `FrameCursor` when multiple templates exist.
13//! 5. Format with `prettyplease`.
14//!
15//! # Example
16//!
17//! ```rust
18//! use ergo_sbe::{parse, Generator, GenerationConfig, Schema};
19//!
20//! let ir = parse(r#"<?xml version="1.0"?>
21//! <messageSchema package="ex" id="1" version="0" byteOrder="littleEndian">
22//!   <types>
23//!     <composite name="messageHeader">
24//!       <type name="blockLength" primitiveType="uint16"/>
25//!       <type name="templateId" primitiveType="uint16"/>
26//!       <type name="schemaId" primitiveType="uint16"/>
27//!       <type name="version" primitiveType="uint16"/>
28//!     </composite>
29//!   </types>
30//!   <message name="Ping" id="1">
31//!     <field name="seq" id="1" type="uint32" offset="0"/>
32//!   </message>
33//! </messageSchema>"#).unwrap();
34//! let schema = Schema::from_ir(ir);
35//! let set = Generator::new(GenerationConfig::new("ping"))
36//!     .generate(&schema)
37//!     .unwrap();
38//! let src = &set.modules().next().unwrap().source;
39//! assert!(src.contains("PingDecoder"));
40//! assert!(src.contains("PingEncoder"));
41//! ```
42//!
43//! See the [crate root](crate) for how to use generated codecs (encode/decode,
44//! conversion styles, domain objects, metadata).
45
46use std::collections::HashSet;
47use std::fmt::Write;
48
49use crate::ir::{ByteOrder, Ir, Presence, PrimitiveType, Signal, Token};
50use crate::structured_ir::*;
51use crate::{GenerationConfig, Schema};
52
53pub(crate) mod conversion_helpers;
54pub(crate) use conversion_helpers::*;
55pub(crate) mod conversion_traits;
56pub(crate) use conversion_traits::*;
57pub(crate) mod converter_impls;
58pub(crate) use converter_impls::generate_converter_impls;
59pub(crate) mod decoder_display;
60pub(crate) use decoder_display::generate_decoder_display;
61pub(crate) mod domain_cluster;
62pub(crate) use domain_cluster::*;
63pub(crate) mod encoded_length;
64pub(crate) mod field_type;
65pub(crate) use field_type::field_type_ident;
66pub(crate) mod message_header_template;
67pub(crate) use message_header_template::*;
68pub(crate) mod nullification;
69pub(crate) use nullification::*;
70pub(crate) mod runtime;
71use quote::format_ident;
72pub(crate) use runtime::*;
73pub(crate) mod group_encoder;
74pub(crate) use group_encoder::generate_group_encoder;
75pub(crate) mod group_decoder;
76pub(crate) use group_decoder::generate_group_decoder;
77pub(crate) mod tail_stages;
78pub(crate) use tail_stages::*;
79pub(crate) mod message_decoder;
80pub(crate) use message_decoder::generate_message_decoder;
81pub(crate) mod message_encoder;
82pub(crate) use message_encoder::generate_message_encoder;
83use sha2::{Digest, Sha256};
84
85/// One generated Rust source file.
86///
87/// Write `source` to `OUT_DIR.join(path)` from `build.rs`, then:
88/// `mod msgs { include!(concat!(env!("OUT_DIR"), "/msgs.rs")); }`
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct GeneratedModule {
91    /// Relative path, e.g. `"messages.rs"` or `"common_types.rs"`.
92    pub path: String,
93    /// Full formatted Rust source for that module.
94    pub source: String,
95}
96
97/// Set of modules from [`Generator::generate`] or [`Generator::generate_multi`].
98///
99/// ```rust
100/// # use std::path::Path;
101/// # fn example(generator: &mut ergo_sbe::Generator, schema: &ergo_sbe::Schema, out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
102/// let set = generator.generate(schema)?;
103/// for m in set.modules() {
104///     std::fs::write(out_dir.join(&m.path), &m.source)?;
105/// }
106/// for w in set.warnings() {
107///     println!("cargo:warning={w}");
108/// }
109/// # Ok(())
110/// # }
111/// ```
112#[derive(Clone, Debug, Default, Eq, PartialEq)]
113pub struct GeneratedModuleSet {
114    modules: Vec<GeneratedModule>,
115    /// Generation warnings (e.g. shared types with version-gated members).
116    warnings: Vec<String>,
117}
118
119/// Errors returned by [`Generator::generate`] when the configuration
120/// is invalid for the given schema.
121#[derive(Clone, Debug, Eq, PartialEq)]
122#[non_exhaustive]
123pub enum GenerateError {
124    /// A schema value cannot be represented by its declared message-header
125    /// field without using a reserved/null value.
126    HeaderValueOutOfRange {
127        /// Header field name.
128        field: String,
129        /// Schema value that would be written.
130        value: u64,
131        /// Maximum value declared by the field encoding.
132        maximum: u64,
133        /// Schema or message that supplied the value.
134        context: String,
135    },
136    /// A conversion selector matched no fields, or a domain type path is invalid.
137    InvalidConversion {
138        /// Description of the selector.
139        selector: String,
140        /// Why validation failed.
141        reason: String,
142    },
143    /// Two selectors mapped to the same generated method name.
144    ConversionCollision {
145        /// The colliding method name.
146        method: String,
147        /// The first selector that produced the collision.
148        selector_a: String,
149        /// The second selector that produced the collision.
150        selector_b: String,
151    },
152    /// Generated module source failed its own syntax check. This is always an
153    /// ergo-sbe codegen bug — report it.
154    InvalidGeneratedSource {
155        /// Which module produced invalid Rust.
156        module: String,
157        /// The syn parse error.
158        error: String,
159    },
160    /// A [`GenerationConfig`] field was rejected by codegen validation.
161    InvalidConfiguration {
162        /// Which config option was rejected.
163        option: String,
164        /// The rejected value.
165        value: String,
166        /// Why it was rejected.
167        reason: String,
168    },
169    /// Multi-schema generation found the same type name with incompatible
170    /// wire layouts across schemas.
171    IncompatibleSharedType {
172        /// Shared type name (enum, set, or composite).
173        name: String,
174        /// Module that first defined the type.
175        owner_module: String,
176        /// Module that reuses the name with a different layout.
177        consumer_module: String,
178        /// First differing property / fingerprint mismatch summary.
179        difference: String,
180    },
181}
182
183impl core::fmt::Display for GenerateError {
184    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
185        match self {
186            Self::HeaderValueOutOfRange {
187                field,
188                value,
189                maximum,
190                context,
191            } => {
192                write!(
193                    f,
194                    "message header field '{field}' value {value} for {context} exceeds declared maximum {maximum}"
195                )
196            }
197            Self::InvalidConversion { selector, reason } => {
198                write!(f, "invalid conversion '{selector}': {reason}")
199            }
200            Self::ConversionCollision {
201                method,
202                selector_a,
203                selector_b,
204            } => {
205                write!(
206                    f,
207                    "conversion method collision: '{method}' from '{selector_a}' and '{selector_b}'"
208                )
209            }
210            Self::InvalidGeneratedSource { module, error } => {
211                write!(
212                    f,
213                    "generated module '{module}' failed Rust syntax validation: {error}"
214                )
215            }
216            Self::InvalidConfiguration {
217                option,
218                value,
219                reason,
220            } => {
221                write!(
222                    f,
223                    "invalid configuration option '{option}': value '{value}' — {reason}"
224                )
225            }
226            Self::IncompatibleSharedType {
227                name,
228                owner_module,
229                consumer_module,
230                difference,
231            } => {
232                write!(
233                    f,
234                    "shared type '{name}' is wire-incompatible between modules \
235                     '{owner_module}' (owner) and '{consumer_module}': {difference}"
236                )
237            }
238        }
239    }
240}
241
242impl core::error::Error for GenerateError {}
243
244impl GeneratedModuleSet {
245    pub(crate) fn push(&mut self, module: GeneratedModule) {
246        self.modules.push(module);
247    }
248
249    /// Iterate modules in a stable order (write each to `OUT_DIR`).
250    #[must_use]
251    pub fn modules(&self) -> impl ExactSizeIterator<Item = &GeneratedModule> {
252        self.modules.iter()
253    }
254
255    /// Non-fatal warnings (e.g. shared types with `sinceVersion > 0`).
256    /// Surface via `cargo:warning=` from `build.rs`.
257    #[must_use]
258    pub fn warnings(&self) -> &[String] {
259        &self.warnings
260    }
261
262    /// Consume the set and take ownership of the generated modules and
263    /// warnings without cloning those buffers.
264    ///
265    /// Module order matches [`Self::modules`] (generation order; stable for
266    /// a given schema set). Warnings are returned rather than discarded so a
267    /// `build.rs` can still emit `cargo::warning=` after taking the source.
268    ///
269    /// ```rust
270    /// # fn example(set: ergo_sbe::GeneratedModuleSet) {
271    /// let (modules, warnings) = set.into_parts();
272    /// for m in modules {
273    ///     let _ = (m.path, m.source);
274    /// }
275    /// for w in warnings {
276    ///     println!("cargo::warning={w}");
277    /// }
278    /// # }
279    /// ```
280    #[must_use]
281    pub fn into_parts(self) -> (Vec<GeneratedModule>, Vec<String>) {
282        (self.modules, self.warnings)
283    }
284}
285
286/// SBE-to-Rust generator.
287/// Bundled schema identity + generation config, resolved once per schema.
288/// Replaces the 6–15 parameter lists threaded through every generator function.
289#[allow(missing_docs)]
290pub(crate) struct GenerationContext {
291    pub elements: SchemaElements,
292    pub byte_order: ByteOrder,
293    pub schema_id: u16,
294    pub schema_version: u16,
295    pub header_type: String,
296    pub header_size: usize,
297    pub schema_name: String,
298    pub multi_message: bool,
299    pub conversions: Vec<crate::ConversionSelector>,
300    pub domain_types: Vec<(crate::ConversionSelector, String)>,
301    pub domain_objects: bool,
302    pub domain_var_data: crate::config::DomainVarData,
303    pub enable_display_debug: bool,
304    pub enable_meta_attributes: bool,
305    pub enable_dispatch: bool,
306}
307
308/// SBE → Rust codec generator.
309///
310/// Holds a [`GenerationConfig`]. Call [`Self::generate`] for one schema or
311/// [`Self::generate_multi`] when sharing types across schemas.
312///
313/// ```rust
314/// use ergo_sbe::{parse, Generator, GenerationConfig, Schema};
315/// # let xml = r#"<?xml version="1.0"?><messageSchema package="t" id="1" version="0"
316/// # byteOrder="littleEndian"><types><composite name="messageHeader">
317/// # <type name="blockLength" primitiveType="uint16"/>
318/// # <type name="templateId" primitiveType="uint16"/>
319/// # <type name="schemaId" primitiveType="uint16"/>
320/// # <type name="version" primitiveType="uint16"/>
321/// # </composite></types><message name="M" id="1">
322/// # <field name="x" id="1" type="uint8" offset="0"/></message></messageSchema>"#;
323/// let schema = Schema::from_ir(parse(xml).unwrap());
324/// let modules = Generator::new(GenerationConfig::new("m"))
325///     .generate(&schema)
326///     .unwrap();
327/// assert_eq!(modules.modules().len(), 1);
328/// ```
329#[derive(Debug)]
330pub struct Generator {
331    config: GenerationConfig,
332}
333
334impl Generator {
335    /// Create a generator with the given [`GenerationConfig`].
336    #[must_use]
337    pub const fn new(config: GenerationConfig) -> Self {
338        Self { config }
339    }
340
341    fn validate_header_values(&self, schema: &Schema) -> Result<(), GenerateError> {
342        let elements = partition_tokens(&schema.ir.tokens);
343        let Some(header) = elements
344            .composites
345            .iter()
346            .find(|tokens| tokens[0].name == schema.ir.header_type)
347        else {
348            // Synthetic `Schema::new` values used by metadata-only callers may
349            // contain no messages or header tokens. XML-parsed schemas have
350            // already had their header structure validated.
351            return Ok(());
352        };
353
354        let check = |field_name: &str, value: u64, context: String| -> Result<(), GenerateError> {
355            let Some(field) = header
356                .iter()
357                .find(|token| token.signal == Signal::BeginField && token.name == field_name)
358            else {
359                return Ok(());
360            };
361            if field.encoding.presence == Presence::Constant {
362                return Ok(());
363            }
364            let maximum = field.encoding.max_value.unwrap_or(u64::MAX);
365            if value > maximum {
366                return Err(GenerateError::HeaderValueOutOfRange {
367                    field: field_name.to_string(),
368                    value,
369                    maximum,
370                    context,
371                });
372            }
373            Ok(())
374        };
375
376        let schema_context = format!("schema '{}'", schema.package);
377        check("schemaId", u64::from(schema.id), schema_context.clone())?;
378        check("version", u64::from(schema.version), schema_context)?;
379
380        for message_tokens in &elements.messages {
381            let message = parse_message_structure(message_tokens, &elements);
382            let context = format!("message '{}'", message.name);
383            check("templateId", u64::from(message.id), context.clone())?;
384            let block_length = u64::try_from(message.block_length).unwrap_or(u64::MAX);
385            check("blockLength", block_length, context)?;
386        }
387
388        Ok(())
389    }
390
391    fn validate_conversions(&self, schema: &Schema) -> Result<(), GenerateError> {
392        if !self.config.has_conversions() {
393            return Ok(());
394        }
395        let elements = partition_tokens(&schema.ir.tokens);
396        for sel in &self.config.conversions {
397            let matched = match sel {
398                crate::ConversionSelector::NamedType(name) => {
399                    elements.composites.iter().any(|c| c[0].name == *name)
400                        || elements.enums.iter().any(|e| e[0].name == *name)
401                        || elements.sets.iter().any(|s| s[0].name == *name)
402                }
403                crate::ConversionSelector::SemanticType(_) => {
404                    // Semantic types are validated during codegen when we can
405                    // inspect field metadata — always passes pre-validation.
406                    true
407                }
408                crate::ConversionSelector::FieldPath(_) => {
409                    // Field paths are validated during codegen.
410                    true
411                }
412            };
413            if !matched {
414                return Err(GenerateError::InvalidConversion {
415                    selector: format!("{sel:?}"),
416                    reason: "no matching type found in schema".into(),
417                });
418            }
419        }
420        for (sel, rust_type) in &self.config.domain_types {
421            if rust_type.is_empty() {
422                return Err(GenerateError::InvalidConversion {
423                    selector: format!("{sel:?}"),
424                    reason: "domain type path must not be empty".into(),
425                });
426            }
427            // Validate the path is parseable Rust — catch typos at build time,
428            // not as a panic deep in codegen.
429            syn::parse_str::<syn::Type>(rust_type).map_err(|e| {
430                GenerateError::InvalidConversion {
431                    selector: format!("{sel:?}"),
432                    reason: format!("domain type path is not a valid Rust type: {e}"),
433                }
434            })?;
435        }
436        Ok(())
437    }
438
439    /// Validate conversions against the token union of all schemas in a
440    /// multi-schema generation. A [`crate::ConversionSelector::NamedType`]
441    /// that only exists in one schema's type declarations is valid as long
442    /// as at least one schema in the union contains the named type.
443    fn validate_conversions_union(
444        &self,
445        union: &[(&Schema, crate::structured_ir::SchemaElements)],
446    ) -> Result<(), GenerateError> {
447        if !self.config.has_conversions() {
448            return Ok(());
449        }
450        for sel in &self.config.conversions {
451            if let crate::ConversionSelector::NamedType(name) = sel {
452                let matched = union.iter().any(|(_, elements)| {
453                    elements.composites.iter().any(|c| c[0].name == *name)
454                        || elements.enums.iter().any(|e| e[0].name == *name)
455                        || elements.sets.iter().any(|s| s[0].name == *name)
456                });
457                if !matched {
458                    return Err(GenerateError::InvalidConversion {
459                        selector: format!("{sel:?}"),
460                        reason: "no matching type found in any schema".into(),
461                    });
462                }
463            }
464            // SemanticType and FieldPath are validated during codegen per-field.
465        }
466        for (sel, rust_type) in &self.config.domain_types {
467            syn::parse_str::<syn::Type>(rust_type).map_err(|e| {
468                GenerateError::InvalidConversion {
469                    selector: format!("{sel:?}"),
470                    reason: format!("domain type path is not a valid Rust type: {e}"),
471                }
472            })?;
473        }
474        Ok(())
475    }
476
477    /// Validate user-supplied paths that will be parsed by syn later. Catches
478    /// typos at config-validation time rather than as panics in codegen.
479    fn validate_paths(&self) -> Result<(), GenerateError> {
480        // Module name must be a single Rust identifier (no path separators).
481        let mn = self.config.module_name();
482        if !crate::config::is_valid_module_ident(mn) {
483            return Err(GenerateError::InvalidConfiguration {
484                option: "module_name".into(),
485                value: mn.into(),
486                reason:
487                    "module name must be a single Rust identifier — no '/', '\\\\', '.', or '..'"
488                        .into(),
489            });
490        }
491        if let Some(ref err_path) = self.config.error_from_path {
492            syn::parse_str::<syn::Type>(err_path).map_err(|e| {
493                GenerateError::InvalidConversion {
494                    selector: "error_from_path".into(),
495                    reason: format!("error-from path is not a valid Rust type: {e}"),
496                }
497            })?;
498        }
499        if let Some(ref rt_path) = self.config.external_sbe_rt_path {
500            syn::parse_str::<syn::Path>(rt_path).map_err(|e| {
501                GenerateError::InvalidConfiguration {
502                    option: "external_sbe_rt".into(),
503                    value: rt_path.clone(),
504                    reason: format!("not a valid Rust path: {e}"),
505                }
506            })?;
507        }
508        Ok(())
509    }
510
511    #[allow(missing_docs)]
512    fn effective_domain_types(
513        &self,
514        schemas: &[(&Schema, &str)],
515    ) -> Vec<(crate::ConversionSelector, String)> {
516        let mut types = self.config.domain_types.clone();
517        if self.config.auto_bool_domain {
518            for (schema, _) in schemas {
519                let elements = partition_tokens(&schema.ir.tokens);
520                for e in &elements.enums {
521                    let name = &e[0].name;
522                    if crate::structured_ir::is_bool_value_enum(&elements, name) {
523                        let sel = crate::ConversionSelector::named_type(name);
524                        if !types.iter().any(|(s, _)| s == &sel) {
525                            types.push((sel, "bool".into()));
526                        }
527                    }
528                }
529            }
530        }
531        types
532    }
533
534    /// Generate one Rust module for `schema` (file name from config module name).
535    ///
536    /// # Errors
537    ///
538    /// [`GenerateError`] if conversion selectors match nothing or collide.
539    pub fn generate(&self, schema: &Schema) -> Result<GeneratedModuleSet, GenerateError> {
540        let effective = self.effective_domain_types(&[(schema, "")]);
541        with_keyword_append(&self.config.keyword_append_token, || {
542            with_deprecated_attrs(self.config.deprecated_attrs, || {
543                self.validate_header_values(schema)?;
544                self.validate_conversions(schema)?;
545                self.validate_paths()?;
546                let mut modules = GeneratedModuleSet::default();
547                let src = self.gen_schema(schema, &HashSet::new(), false, true, &effective)?;
548                modules.push(GeneratedModule {
549                    path: format!("{}.rs", self.config.module_name),
550                    source: src,
551                });
552                Ok(modules)
553            })
554        })
555    }
556
557    /// Generate modules for several schemas, optionally deduplicating shared types.
558    ///
559    /// When [`GenerationConfig::with_shared_module`] is set:
560    /// - first entry owns shared enums/sets/composites (+ usually `sbe_rt`);
561    /// - later entries emit `pub use super::<shared>::*;` and skip shared types.
562    ///
563    /// Each entry is `(schema, module_name)` → `{module_name}.rs`.
564    ///
565    /// # Errors
566    ///
567    /// Same conversion validation as [`Self::generate`].
568    pub fn generate_multi(
569        &self,
570        schemas: &[(&Schema, &str)],
571    ) -> Result<GeneratedModuleSet, GenerateError> {
572        let effective = self.effective_domain_types(schemas);
573        with_keyword_append(&self.config.keyword_append_token, || {
574            with_deprecated_attrs(self.config.deprecated_attrs, || {
575                self.validate_paths()?;
576                self.generate_multi_inner(schemas, &effective)
577            })
578        })
579    }
580
581    fn generate_multi_inner(
582        &self,
583        schemas: &[(&Schema, &str)],
584        domain_types: &[(crate::ConversionSelector, String)],
585    ) -> Result<GeneratedModuleSet, GenerateError> {
586        let mut modules = GeneratedModuleSet::default();
587        let mut shared_types: HashSet<String> = HashSet::new();
588        let empty_set: HashSet<String> = HashSet::new();
589
590        // Validate per-schema module names before emitting any file.
591        {
592            let mut seen = HashSet::new();
593            for (i, (_, module_name)) in schemas.iter().enumerate() {
594                if !crate::config::is_valid_module_ident(module_name) {
595                    return Err(GenerateError::InvalidConfiguration {
596                        option: format!("schemas[{i}].module_name"),
597                        value: module_name.to_string(),
598                        reason:
599                            "module name must be a single Rust identifier — no '/', '\\\\', '.', or '..'"
600                                .into(),
601                    });
602                }
603                if !seen.insert(module_name.to_string()) {
604                    return Err(GenerateError::InvalidConfiguration {
605                        option: format!("schemas[{i}].module_name"),
606                        value: module_name.to_string(),
607                        reason:
608                            "duplicate module name — each schema must have a unique module name"
609                                .into(),
610                    });
611                }
612            }
613        }
614
615        // Validate shared types have identical wire fingerprints when names
616        // collide. A type name is not wire identity — same-name types with
617        // different layouts or byte order silently produce corrupted codecs.
618        if schemas.len() > 1 && self.config.shared_module.is_some() {
619            let owner_module = schemas[0].1.to_string();
620            let owner_byte_order = schemas[0].0.ir.byte_order;
621            let first_elements = partition_tokens(&schemas[0].0.ir.tokens);
622            for (schema, consumer_module) in schemas.iter().skip(1) {
623                let elements = partition_tokens(&schema.ir.tokens);
624                let consumer_byte_order = schema.ir.byte_order;
625                let check = |kind: &str, name: String, a: String, b: String| {
626                    if a != b {
627                        Err(GenerateError::IncompatibleSharedType {
628                            name: name.clone(),
629                            owner_module: owner_module.clone(),
630                            consumer_module: consumer_module.to_string(),
631                            difference: format!(
632                                "{kind} fingerprint mismatch (owner={a}, consumer={b})"
633                            ),
634                        })
635                    } else {
636                        Ok(())
637                    }
638                };
639                // Compare enums
640                for et in &elements.enums {
641                    let name = to_pascal_case(&et[0].name);
642                    if let Some(ref_et) = first_elements
643                        .enums
644                        .iter()
645                        .find(|e| to_pascal_case(&e[0].name) == name)
646                    {
647                        check(
648                            "enum",
649                            name,
650                            canonical_token_fingerprint(ref_et, owner_byte_order),
651                            canonical_token_fingerprint(et, consumer_byte_order),
652                        )?;
653                    }
654                }
655                // Compare sets
656                for st in &elements.sets {
657                    let name = to_pascal_case(&st[0].name);
658                    if let Some(ref_st) = first_elements
659                        .sets
660                        .iter()
661                        .find(|s| to_pascal_case(&s[0].name) == name)
662                    {
663                        check(
664                            "set",
665                            name,
666                            canonical_token_fingerprint(ref_st, owner_byte_order),
667                            canonical_token_fingerprint(st, consumer_byte_order),
668                        )?;
669                    }
670                }
671                // Compare composites
672                for ct in &elements.composites {
673                    let name = to_pascal_case(&ct[0].name);
674                    if let Some(ref_ct) = first_elements
675                        .composites
676                        .iter()
677                        .find(|c| to_pascal_case(&c[0].name) == name)
678                    {
679                        check(
680                            "composite",
681                            name,
682                            canonical_token_fingerprint(ref_ct, owner_byte_order),
683                            canonical_token_fingerprint(ct, consumer_byte_order),
684                        )?;
685                    }
686                }
687            }
688        }
689
690        // Shared module name must identify the first-schema (owner) module so
691        // consumers' `pub use super::<shared>::*` resolves to the owner crate
692        // path rather than a free-floating alias.
693        if let Some(ref shared) = self.config.shared_module {
694            let owner = schemas[0].1;
695            if shared != owner {
696                return Err(GenerateError::InvalidConfiguration {
697                    option: "shared_module".into(),
698                    value: shared.clone(),
699                    reason: format!(
700                        "must equal the first schema module name (owner '{owner}'); \
701                         consumers import `super::{shared}::*` from that module"
702                    ),
703                });
704            }
705        }
706
707        // Validate conversions against the union of all schemas' types, not
708        // each schema individually. A NamedType selector may only exist in
709        // one schema's type declarations (valid), and the union covers all.
710        {
711            let mut union_elements: Vec<(&Schema, crate::structured_ir::SchemaElements)> =
712                Vec::with_capacity(schemas.len());
713            for (schema, _) in schemas.iter() {
714                union_elements.push((schema, partition_tokens(&schema.ir.tokens)));
715            }
716            self.validate_conversions_union(&union_elements)?;
717        }
718
719        for (i, (schema, module_name)) in schemas.iter().enumerate() {
720            self.validate_header_values(schema)?;
721            if i == 0 {
722                let elements = partition_tokens(&schema.ir.tokens);
723                for et in &elements.enums {
724                    let name = to_pascal_case(&et[0].name);
725                    shared_types.insert(name.clone());
726                    if let Some(warn) = warn_version_gated(&name, et, schema) {
727                        modules.warnings.push(warn);
728                    }
729                }
730                for st in &elements.sets {
731                    shared_types.insert(to_pascal_case(&st[0].name));
732                }
733                for ct in &elements.composites {
734                    let name = to_pascal_case(&ct[0].name);
735                    shared_types.insert(name.clone());
736                    if let Some(warn) = warn_version_gated(&name, ct, schema) {
737                        modules.warnings.push(warn);
738                    }
739                }
740            }
741            let is_importing = i > 0 && self.config.shared_module.is_some();
742            // Emit sbe_rt in the first module always, and in every module
743            // when there is no shared module (standalone mode).
744            let emit_sbe_rt = i == 0 || self.config.shared_module.is_none();
745            // Type dedup only applies when a shared module is configured.
746            // Without it, each schema is standalone and defines all its types.
747            // The first schema (shared-module owner) always defines ALL its types.
748            let skip_set: &HashSet<String> = if self.config.shared_module.is_some() && i > 0 {
749                &shared_types
750            } else {
751                &empty_set
752            };
753            let src = self.gen_schema(schema, skip_set, is_importing, emit_sbe_rt, domain_types)?;
754            modules.push(GeneratedModule {
755                path: format!("{}.rs", module_name),
756                source: src,
757            });
758        }
759        Ok(modules)
760    }
761
762    /// Build an [`ItemContext::Enum`] from IR tokens.
763    fn build_enum_ctx<'s>(
764        tokens: &[crate::ir::Token],
765        schema: &'s crate::Schema,
766    ) -> crate::ItemContext<'s> {
767        let name = to_pascal_case(&tokens[0].name);
768        let encoding_type = tokens[0]
769            .encoding
770            .primitive_type
771            .unwrap_or(PrimitiveType::UInt8);
772        let et_str = rust_type(encoding_type).to_string();
773        let variants: Vec<_> = tokens
774            .iter()
775            .filter(|t| t.signal == crate::ir::Signal::Encoding)
776            .filter_map(|t| {
777                let val = t.encoding.constant_value.as_ref()?;
778                let value: i128 = if encoding_type == PrimitiveType::Char {
779                    i128::from(val.as_bytes().first().copied().unwrap_or(0))
780                } else {
781                    // i128 covers uint64 discriminants above i64::MAX
782                    // (rare but schema-legal) without wrapping negative.
783                    val.parse::<i128>().ok()?
784                };
785                Some(crate::EnumVariantInfo {
786                    name: to_pascal_case(&t.name),
787                    snake_name: to_snake_case(&t.name),
788                    label: t.name.clone(),
789                    value,
790                    description: t.encoding.description.clone(),
791                })
792            })
793            .collect();
794        crate::ItemContext::Enum {
795            schema,
796            name,
797            encoding_type: et_str,
798            variants,
799        }
800    }
801
802    /// Build a message decoder/encoder context from a [`MessageStructure`].
803    fn build_message_ctx<'s>(
804        msg: &MessageStructure,
805        kind: crate::ItemKind,
806        schema: &'s crate::Schema,
807    ) -> crate::ItemContext<'s> {
808        let name = to_pascal_case(&msg.name);
809        let name_with = |suffix: &str| format!("{name}{suffix}");
810        let fields = message_field_infos(&msg.fields, &[], None);
811        let name = match kind {
812            crate::ItemKind::MessageDecoder => name_with("Decoder"),
813            crate::ItemKind::MessageEncoder => name_with("Encoder"),
814            _ => name,
815        };
816        match kind {
817            crate::ItemKind::MessageDecoder => crate::ItemContext::MessageDecoder {
818                schema,
819                name,
820                template_id: msg.id,
821                block_length: msg.block_length,
822                fields,
823            },
824            crate::ItemKind::MessageEncoder => crate::ItemContext::MessageEncoder {
825                schema,
826                name,
827                template_id: msg.id,
828                block_length: msg.block_length,
829                fields,
830            },
831            _ => unreachable!("build_message_ctx only for MessageDecoder/MessageEncoder"),
832        }
833    }
834
835    /// Build an [`ItemContext::Composite`] from IR tokens.
836    ///
837    /// Uses the canonical [`parse_composite_members`] so every member is
838    /// reported exactly once with its real Rust type: primitives keep their
839    /// element type (`[T; N]` for arrays), nested composites/enums/sets report
840    /// their type name. Container/ref tokens are never miscounted as fields.
841    fn build_composite_ctx<'s>(
842        tokens: &[crate::ir::Token],
843        schema: &'s crate::Schema,
844    ) -> crate::ItemContext<'s> {
845        use crate::structured_ir::MemberType;
846        let name = to_pascal_case(&tokens[0].name);
847        // Metadata lives on different tokens depending on the member kind:
848        // - primitive: the `BeginField` wrapper carries it (inner token is the
849        //   unnamed `<type>` encoding);
850        // - nested composite/enum/set: the `BeginField` carries only offsets;
851        //   `semanticType`/`nullValue`/`description`/`deprecated` live on the
852        //   inner `BeginComposite`/`BeginEnum`/`BeginSet` token.
853        let member_field_token = |member_name: &str| {
854            tokens
855                .iter()
856                .find(|t| t.signal == crate::ir::Signal::BeginField && t.name == member_name)
857        };
858        let inner_type_token = |field_name: &str| {
859            // Find the BeginField for this member, then peek at the adjacent
860            // non-field token that carries the actual type's encoding metadata.
861            let mut it = tokens.iter().skip_while(|t| {
862                !(t.signal == crate::ir::Signal::BeginField && t.name == field_name)
863            });
864            let _ = it.next(); // skip the BeginField itself
865            it.find(|t| {
866                matches!(
867                    t.signal,
868                    crate::ir::Signal::Encoding
869                        | crate::ir::Signal::BeginComposite
870                        | crate::ir::Signal::BeginEnum
871                        | crate::ir::Signal::BeginSet
872                )
873            })
874        };
875        let fields: Vec<_> = crate::structured_ir::parse_composite_members(tokens)
876            .into_iter()
877            .map(|m| {
878                let field_tok = member_field_token(&m.name);
879                let inner_tok = inner_type_token(&m.name);
880                // For primitives the field wrapper carries metadata; for
881                // containers the inner token does.
882                let enc = match &m.member_type {
883                    MemberType::Primitive { .. } => field_tok.map(|t| &t.encoding),
884                    MemberType::Composite { .. }
885                    | MemberType::Enum { .. }
886                    | MemberType::Set { .. } => inner_tok.map(|t| &t.encoding),
887                };
888                let (rust_type, presence) = match &m.member_type {
889                    MemberType::Primitive {
890                        prim,
891                        length,
892                        presence,
893                        ..
894                    } => {
895                        let base = crate::structured_ir::rust_type(*prim);
896                        let rt = match length {
897                            Some(len) => format!("[{base}; {len}]"),
898                            None => base.to_string(),
899                        };
900                        let ps = match presence {
901                            crate::ir::Presence::Optional => "optional",
902                            crate::ir::Presence::Constant => "constant",
903                            crate::ir::Presence::Required => "required",
904                        };
905                        (rt, ps)
906                    }
907                    MemberType::Composite { name, .. } => (to_pascal_case(name), "required"),
908                    MemberType::Enum { name, .. } => (to_pascal_case(name), "required"),
909                    MemberType::Set { name, .. } => (to_pascal_case(name), "required"),
910                };
911                crate::FieldInfo {
912                    name: to_snake_case(&m.name),
913                    rust_type,
914                    offset: Some(m.offset),
915                    since_version: m.since_version,
916                    semantic_type: enc.and_then(|e| e.semantic_type.clone()),
917                    presence,
918                    null_value: enc.and_then(|e| e.null_value),
919                    deprecated: enc.is_some_and(|e| e.deprecated),
920                    description: enc.and_then(|e| e.description.clone()),
921                }
922            })
923            .collect();
924        crate::ItemContext::Composite {
925            schema,
926            name,
927            fields,
928        }
929    }
930
931    /// Build an [`ItemContext::Set`] from IR tokens.
932    fn build_set_ctx<'s>(
933        tokens: &[crate::ir::Token],
934        schema: &'s crate::Schema,
935    ) -> crate::ItemContext<'s> {
936        let name = to_pascal_case(&tokens[0].name);
937        let encoding_type = tokens[0]
938            .encoding
939            .primitive_type
940            .unwrap_or(PrimitiveType::UInt8);
941        let et_str = rust_type(encoding_type).to_string();
942        let choices: Vec<_> = tokens
943            .iter()
944            .filter(|t| t.signal == crate::ir::Signal::Encoding)
945            .map(|t| crate::SetChoiceInfo {
946                name: to_pascal_case(&t.name),
947                snake_name: to_snake_case(&t.name),
948                label: t.name.clone(),
949                bit_position: t
950                    .encoding
951                    .constant_value
952                    .as_ref()
953                    .and_then(|v| v.parse::<u8>().ok())
954                    .unwrap_or(0),
955                description: t.encoding.description.clone(),
956            })
957            .collect();
958        crate::ItemContext::Set {
959            schema,
960            name,
961            encoding_type: et_str,
962            choices,
963        }
964    }
965
966    /// Run registered hooks and append returned tokens to `src`.
967    fn run_hooks(&self, ctx: &crate::ItemContext, src: &mut String) {
968        if !self.config.has_hooks() {
969            return;
970        }
971        self.config.run_hooks(ctx, src);
972    }
973    /// type names already generated by earlier schemas; those types are skipped
974    /// in this call (the caller arranges `pub use super::*;`).
975    fn gen_schema(
976        &self,
977        schema: &Schema,
978        shared: &HashSet<String>,
979        is_importing: bool,
980        emit_sbe_rt: bool,
981        domain_types: &[(crate::ConversionSelector, String)],
982    ) -> Result<String, GenerateError> {
983        let ir = &schema.ir;
984
985        let mut src = String::new();
986        // NOTE: In Rust edition 2024, inner attributes (`#![allow(...)])`) are
987        // not permitted inside `include!()` files.  All suppression lints are
988        // therefore emitted as outer `#[allow(..)]` on `pub mod sbe_rt`.
989        // Outer doc comment (`///`) — syn/prettyplease preserves it; `//` would
990        // be silently dropped.
991        writeln!(
992            src,
993            "/// Generated from SBE schema package `{}` id {} version {}.",
994            schema.package, schema.id, schema.version
995        )
996        .unwrap();
997        // Lint allow list is intentionally narrow — do not re-add
998        // unused_unsafe / unused_imports / dead_code (hide generator bugs).
999        // Remaining allows (schema reality):
1000        // - absurd_extreme_comparisons / identity_op / erasing_op / unnecessary_cast:
1001        //   schema min/max/const offsets can be tautological after folding.
1002        // - double_must_use: staged builders return must_use types from must_use methods.
1003        // - eq_op: schema-driven `x == x` style checks in generated matches.
1004        // - manual_range_contains: generated version gates prefer explicit compares
1005        //   that stay readable next to sinceVersion literals.
1006        // - non_camel_case_types / non_snake_case: SBE identifiers as emitted.
1007        src.push_str(
1008            "#[allow(clippy::absurd_extreme_comparisons, clippy::double_must_use, \
1009                       clippy::erasing_op, clippy::identity_op, clippy::unnecessary_cast)]\n",
1010        );
1011        src.push_str("#[allow(non_camel_case_types)]\n");
1012        src.push_str("#[allow(non_snake_case)]\n");
1013        src.push_str("#[allow(clippy::eq_op)]\n");
1014        src.push_str("#[allow(clippy::manual_range_contains)]\n\n");
1015
1016        // If importing from a shared module, bring all its items into scope.
1017        // This covers shared types + the sbe_rt runtime module.
1018        if is_importing {
1019            if let Some(ref shared_mod) = self.config.shared_module {
1020                write!(src, "pub use super::{}::*;\n\n", shared_mod).unwrap();
1021            }
1022        }
1023
1024        // `SbeMessage`'s sealing marker lives with the runtime that declares the
1025        // trait, so a module reusing someone else's `sbe_rt` must name that
1026        // owner's sealing module rather than declaring a second one.
1027        let sealed_path = if let Some(ref ext) = self.config.external_sbe_rt_path {
1028            let owner = ext.strip_suffix("::sbe_rt").unwrap_or(ext);
1029            format!("{owner}::{}", crate::codegen::runtime::SEALED_MODULE)
1030        } else if is_importing {
1031            let shared = self
1032                .config
1033                .shared_module
1034                .as_deref()
1035                .expect("is_importing implies a shared module");
1036            format!(
1037                "super::{shared}::{}",
1038                crate::codegen::runtime::SEALED_MODULE
1039            )
1040        } else {
1041            crate::codegen::runtime::SEALED_MODULE.to_string()
1042        };
1043        crate::codegen::runtime::set_sealed_path(&sealed_path);
1044
1045        if let Some(ref ext) = self.config.external_sbe_rt_path {
1046            let _ = writeln!(src, "pub use {ext} as sbe_rt;\n");
1047            if self.config.has_conversions() {
1048                emit_conversion_traits(&mut src);
1049            }
1050        } else if emit_sbe_rt {
1051            src.push_str(&generate_sbe_rt_src());
1052            // A shared runtime is implemented against by sibling modules, so its
1053            // sealing module widens to `pub(super)`. A self-contained module
1054            // keeps it private, which is what makes `SbeMessage` unimplementable
1055            // outside the generated module.
1056            src.push_str(&crate::codegen::runtime::generate_sealed_module_src(
1057                self.config.shared_module.is_some(),
1058            ));
1059            if self.config.has_conversions() {
1060                emit_conversion_traits(&mut src);
1061            }
1062        }
1063
1064        let elements = partition_tokens(&ir.tokens);
1065
1066        for enum_tokens in &elements.enums {
1067            let type_name = to_pascal_case(&enum_tokens[0].name);
1068            if shared.contains(&type_name) {
1069                continue;
1070            }
1071            generate_enum(&mut src, enum_tokens);
1072            if self.config.has_hooks() {
1073                let ctx = Self::build_enum_ctx(enum_tokens, schema);
1074                self.run_hooks(&ctx, &mut src);
1075            }
1076        }
1077
1078        for set_tokens in &elements.sets {
1079            let type_name = to_pascal_case(&set_tokens[0].name);
1080            if shared.contains(&type_name) {
1081                continue;
1082            }
1083            generate_set(&mut src, set_tokens);
1084            if self.config.has_hooks() {
1085                let ctx = Self::build_set_ctx(set_tokens, schema);
1086                self.run_hooks(&ctx, &mut src);
1087            }
1088        }
1089
1090        for composite_tokens in &elements.composites {
1091            let type_name = to_pascal_case(&composite_tokens[0].name);
1092            if shared.contains(&type_name) {
1093                continue;
1094            }
1095            let comp_byte_order = ir.byte_order;
1096            generate_composite(&mut src, composite_tokens, comp_byte_order);
1097            if self.config.has_hooks() {
1098                let ctx = Self::build_composite_ctx(composite_tokens, schema);
1099                self.run_hooks(&ctx, &mut src);
1100            }
1101        }
1102
1103        let header_pascal = to_pascal_case(&ir.header_type);
1104        if header_pascal != "MessageHeader" && !shared.contains(&header_pascal) {
1105            write!(src, "pub type MessageHeader = {};\n\n", header_pascal).unwrap();
1106        }
1107
1108        let messages: Vec<MessageStructure> = elements
1109            .messages
1110            .iter()
1111            .map(|toks| parse_message_structure(toks, &elements))
1112            .collect();
1113
1114        // Selectors for conversion/domain accessors: explicit list + domain_types
1115        // (covers with_domain_type and auto_bool without a separate with_conversion).
1116        let mut conv_sels = self.config.conversions.clone();
1117        for (sel, _) in domain_types {
1118            if !conv_sels.iter().any(|s| s == sel) {
1119                conv_sels.push(sel.clone());
1120            }
1121        }
1122
1123        let mut schema_markers = occupied_type_names(&elements);
1124        let mut message_markers: Vec<(String, String)> = Vec::new();
1125        for msg in &messages {
1126            let multi = messages.len() > 1;
1127            let (decoder_ts, marker) = generate_message_decoder(
1128                msg,
1129                &elements,
1130                &mut schema_markers,
1131                ir.byte_order,
1132                ir.id,
1133                ir.version,
1134                &ir.header_type,
1135                &ir.package,
1136                multi,
1137                self.config.enable_display_debug,
1138                self.config.enable_meta_attributes,
1139                self.config.enable_dispatch,
1140                self.config.domain_objects,
1141                self.config.domain_var_data,
1142                &conv_sels,
1143                domain_types,
1144                &self.config.hooks,
1145                schema,
1146                &self.config.null_as_option,
1147                self.config.all_enums_as_option,
1148            );
1149            src.push_str(&decoder_ts.to_string());
1150            src.push('\n');
1151            message_markers.push((to_pascal_case(&msg.name), marker));
1152            // Hooks for the message decoder
1153            if self.config.has_hooks() {
1154                let ctx = Self::build_message_ctx(msg, crate::ItemKind::MessageDecoder, schema);
1155                self.run_hooks(&ctx, &mut src);
1156            }
1157            let encoder_ts = generate_message_encoder(
1158                msg,
1159                &elements,
1160                ir.byte_order,
1161                ir.id,
1162                ir.version,
1163                &ir.header_type,
1164                multi,
1165                &conv_sels,
1166                domain_types,
1167                self.config.enable_meta_attributes,
1168                self.config.enable_display_debug,
1169            );
1170            src.push_str(&encoder_ts.to_string());
1171            // Hooks for the message encoder
1172            if self.config.has_hooks() {
1173                let ctx = Self::build_message_ctx(msg, crate::ItemKind::MessageEncoder, schema);
1174                self.run_hooks(&ctx, &mut src);
1175            }
1176
1177            // Converter seam: domain-type / with_conversion / auto_bool.
1178            if !conv_sels.is_empty() {
1179                let manual_impl_snippets = generate_manual_impl_snippets(
1180                    &elements,
1181                    domain_types,
1182                    &self.config.manual_impl_selectors,
1183                );
1184                let converter_ts = generate_converter_impls(
1185                    msg,
1186                    &conv_sels,
1187                    domain_types,
1188                    &manual_impl_snippets,
1189                    multi,
1190                );
1191                src.push_str(&converter_ts);
1192            }
1193            src.push('\n');
1194            if self.config.enable_meta_attributes {
1195                generate_message_field_meta(&mut src, msg);
1196            }
1197        }
1198
1199        // 6b. Emit TryFromSbe/TryToSbe impls for configured domain-type conversions.
1200        // Only the module that owns `sbe_rt` emits these. The built-in impls
1201        // target well-known types (`bool`, `rust_decimal`, `chrono`), and a
1202        // shared-module consumer re-emitting `impl TryFromSbe<BooleanType> for
1203        // bool` against the imported `BooleanType` collides with the owner's
1204        // identical impl ("conflicting implementation"). Every non-shared
1205        // module owns its own `sbe_rt`, so this still fires for each of them.
1206        if self.config.has_conversions() && emit_sbe_rt {
1207            let impl_blocks = generate_conversion_impl_blocks(
1208                &elements,
1209                &self.config.conversions,
1210                domain_types,
1211                &self.config.manual_impl_selectors,
1212            );
1213            src.push_str(&impl_blocks);
1214        }
1215
1216        // 6c. Emit EncodedLengthAccumulator if any message needs staged builder
1217        {
1218            let has_staged = messages.iter().any(|m| {
1219                matches!(
1220                    encoded_length::strategy(m),
1221                    encoded_length::LengthStrategy::Staged
1222                )
1223            });
1224            if has_staged {
1225                let support_ts = encoded_length::generate_support();
1226                src.push_str(&support_ts.to_string());
1227            }
1228        }
1229
1230        // 7. Generate schema-level constants — SEMANTIC_VERSION, SCHEMA_HASH, SCHEMA_SHA256, SCHEMA_SHA256_HEX
1231        if let Some(ref sem_ver) = schema.ir.semantic_version {
1232            write!(
1233                src,
1234                "pub const SEMANTIC_VERSION: &str = \"{}\";\n\n",
1235                sem_ver
1236            )
1237            .unwrap();
1238        }
1239        let schema_hash = compute_schema_hash(&schema.package, schema.id, schema.version);
1240        write!(src, "pub const SCHEMA_HASH: u64 = {};\n\n", schema_hash).unwrap();
1241        let sha256_hash = compute_schema_sha256(&schema.ir);
1242        src.push_str("pub const SCHEMA_SHA256: [u8; 32] = [");
1243        for (i, &b) in sha256_hash.iter().enumerate() {
1244            if i > 0 {
1245                src.push_str(", ");
1246            }
1247            write!(src, "0x{:02x}", b).unwrap();
1248        }
1249        src.push_str("];\n\n");
1250        let hex: String = sha256_hash.iter().map(|b| format!("{:02x}", b)).collect();
1251        write!(src, "pub const SCHEMA_SHA256_HEX: &str = \"{}\";\n\n", hex).unwrap();
1252        // 7.6. Generate prelude module — single import surface for users
1253        generate_prelude(
1254            &mut src,
1255            &elements,
1256            &messages,
1257            ir.id,
1258            ir.version,
1259            self.config.enable_dispatch,
1260        );
1261        // 7.6b. Opt-in From<EncodeError/DecodeError> for user error type
1262        if let Some(ref err_path) = self.config.error_from_path {
1263            let err_ty: syn::Type = syn::parse_str(err_path).expect("invalid error_from_path");
1264            let span = proc_macro2::Span::call_site();
1265            let impls = quote::quote! {
1266                /// Generated: encode errors convert directly to the crate error type.
1267                impl From<sbe_rt::EncodeError> for #err_ty {
1268                    fn from(e: sbe_rt::EncodeError) -> Self {
1269                        Self::from(format!("sbe encode: {e}"))
1270                    }
1271                }
1272                /// Generated: decode errors convert directly to the crate error type.
1273                impl From<sbe_rt::DecodeError> for #err_ty {
1274                    fn from(e: sbe_rt::DecodeError) -> Self {
1275                        Self::from(format!("sbe decode: {e}"))
1276                    }
1277                }
1278            };
1279            src.push_str(&impls.to_string());
1280            src.push('\n');
1281        }
1282        // 7.7. Byte helpers. Checked helpers are public; unchecked raw I/O is
1283        // private + unsafe — never a safe public memory-safety
1284        // precondition for callers.
1285        let read_bytes_ts: proc_macro2::TokenStream = quote::quote! {
1286            /// Read `N` bytes from `buf` at `offset` into a fixed-size array.
1287            ///
1288            /// Bounds-checked slice indexing. LLVM elides the check when the
1289            /// slice length is known (stack buffer with visible size).
1290            #[inline]
1291            pub fn read_bytes<const N: usize>(buf: &[u8], offset: usize) -> [u8; N] {
1292                buf[offset..offset + N].try_into().expect("read_bytes: buffer too short")
1293            }
1294
1295            #[inline]
1296            pub fn write_bytes<const N: usize>(buf: &mut [u8], offset: usize, bytes: &[u8; N]) {
1297                buf[offset..offset + N].copy_from_slice(bytes);
1298            }
1299
1300            /// Unchecked companion to [`read_bytes`] — zero bounds checks.
1301            ///
1302            /// # Safety
1303            /// Caller guarantees `offset + N` does not overflow and
1304            /// `offset + N <= buf.len()`.
1305            // `always`: pairs with scalar getter `#[inline(always)]` for no-LTO
1306            // decode_scalar parity (plain `#[inline]` lost the maintained gate).
1307            #[inline(always)]
1308            #[allow(dead_code)] // used from generated accessors in this module
1309            unsafe fn read_bytes_unchecked<const N: usize>(buf: &[u8], offset: usize) -> [u8; N] {
1310                // SAFETY: caller guarantees offset + N <= buf.len().
1311                unsafe {
1312                    core::ptr::read_unaligned(buf.as_ptr().add(offset) as *const [u8; N])
1313                }
1314            }
1315
1316
1317            /// Unchecked companion to [`write_bytes`] — zero bounds checks.
1318            ///
1319            /// # Safety
1320            /// Caller guarantees `offset + N` does not overflow and
1321            /// `offset + N <= buf.len()`.
1322            #[inline]
1323            #[allow(dead_code)]
1324            unsafe fn write_bytes_unchecked<const N: usize>(
1325                buf: &mut [u8],
1326                offset: usize,
1327                bytes: &[u8; N],
1328            ) {
1329                // SAFETY: caller guarantees offset + N <= buf.len().
1330                unsafe {
1331                    core::ptr::write_unaligned(buf.as_mut_ptr().add(offset) as *mut [u8; N], *bytes)
1332                }
1333            }
1334        };
1335        src.push_str(&read_bytes_ts.to_string());
1336        src.push('\n');
1337        generate_schema_id_from_header(&mut src, &elements, &ir.header_type, ir.byte_order);
1338
1339        if self.config.enable_dispatch {
1340            let any_msg_ts = generate_any_message(
1341                &messages,
1342                &elements,
1343                ir.id,
1344                &ir.header_type,
1345                &ir.package,
1346                &message_markers,
1347            );
1348            src.push_str(&any_msg_ts.to_string());
1349            src.push('\n');
1350        }
1351
1352        let mut file = match syn::parse_str::<syn::File>(&src) {
1353            Ok(f) => f,
1354            Err(e) => {
1355                return Err(GenerateError::InvalidGeneratedSource {
1356                    module: self.config.module_name.clone(),
1357                    error: e.to_string(),
1358                });
1359            }
1360        };
1361        annotate_missing_public_docs(&mut file);
1362        Ok(prettyplease::unparse(&file))
1363    }
1364}
1365
1366fn item_is_public(vis: &syn::Visibility) -> bool {
1367    matches!(vis, syn::Visibility::Public(_))
1368}
1369
1370fn attrs_have_doc(attrs: &[syn::Attribute]) -> bool {
1371    attrs.iter().any(|a| a.path().is_ident("doc"))
1372}
1373
1374fn fallback_public_doc(kind: &str, name: &str) -> syn::Attribute {
1375    let text = format!("Generated {kind} `{name}`.");
1376    syn::parse_quote!(#[doc = #text])
1377}
1378
1379fn doc_is_placeholder(attr: &syn::Attribute) -> bool {
1380    if !attr.path().is_ident("doc") {
1381        return false;
1382    }
1383    let syn::Meta::NameValue(nv) = &attr.meta else {
1384        return false;
1385    };
1386    let syn::Expr::Lit(syn::ExprLit {
1387        lit: syn::Lit::Str(s),
1388        ..
1389    }) = &nv.value
1390    else {
1391        return false;
1392    };
1393    s.value().trim() == "Generated public API."
1394}
1395
1396fn ensure_public_doc(attrs: &mut Vec<syn::Attribute>, kind: &str, name: &str) {
1397    if attrs.iter().any(doc_is_placeholder)
1398        && attrs
1399            .iter()
1400            .filter(|a| a.path().is_ident("doc"))
1401            .all(doc_is_placeholder)
1402    {
1403        attrs.retain(|a| !doc_is_placeholder(a));
1404    }
1405    if !attrs_have_doc(attrs) {
1406        attrs.insert(0, fallback_public_doc(kind, name));
1407    }
1408}
1409
1410/// Fill operational-fallback rustdoc on every public item so generated
1411/// codecs compile under `#![deny(missing_docs)]` even when the schema
1412/// omits descriptions.
1413fn annotate_missing_public_docs(file: &mut syn::File) {
1414    for item in &mut file.items {
1415        annotate_item(item);
1416    }
1417}
1418
1419fn annotate_item(item: &mut syn::Item) {
1420    match item {
1421        syn::Item::Struct(s) if item_is_public(&s.vis) => {
1422            let name = s.ident.to_string();
1423            ensure_public_doc(&mut s.attrs, "struct", &name);
1424            for field in &mut s.fields {
1425                if !item_is_public(&field.vis) {
1426                    continue;
1427                }
1428                match &field.ident {
1429                    Some(ident) => ensure_public_doc(&mut field.attrs, "field", &ident.to_string()),
1430                    None => {
1431                        // Keep `pub struct Engine(pub [u8; N])` on one line;
1432                        // the struct rustdoc covers the wire image.
1433                    }
1434                }
1435            }
1436        }
1437        syn::Item::Enum(e) if item_is_public(&e.vis) => {
1438            let name = e.ident.to_string();
1439            ensure_public_doc(&mut e.attrs, "enum", &name);
1440            for variant in &mut e.variants {
1441                ensure_public_doc(&mut variant.attrs, "variant", &variant.ident.to_string());
1442                for field in &mut variant.fields {
1443                    if let Some(ident) = &field.ident {
1444                        ensure_public_doc(&mut field.attrs, "field", &ident.to_string());
1445                    }
1446                }
1447            }
1448        }
1449        syn::Item::Fn(f) if item_is_public(&f.vis) => {
1450            ensure_public_doc(&mut f.attrs, "function", &f.sig.ident.to_string());
1451        }
1452        syn::Item::Const(c) if item_is_public(&c.vis) => {
1453            ensure_public_doc(&mut c.attrs, "constant", &c.ident.to_string());
1454        }
1455        syn::Item::Type(t) if item_is_public(&t.vis) => {
1456            ensure_public_doc(&mut t.attrs, "type", &t.ident.to_string());
1457        }
1458        syn::Item::Trait(t) if item_is_public(&t.vis) => {
1459            let name = t.ident.to_string();
1460            ensure_public_doc(&mut t.attrs, "trait", &name);
1461            for trait_item in &mut t.items {
1462                match trait_item {
1463                    syn::TraitItem::Fn(f) => {
1464                        ensure_public_doc(&mut f.attrs, "method", &f.sig.ident.to_string());
1465                    }
1466                    syn::TraitItem::Const(c) => {
1467                        ensure_public_doc(&mut c.attrs, "constant", &c.ident.to_string());
1468                    }
1469                    syn::TraitItem::Type(ty) => {
1470                        ensure_public_doc(&mut ty.attrs, "type", &ty.ident.to_string());
1471                    }
1472                    _ => {}
1473                }
1474            }
1475        }
1476        syn::Item::Impl(i) => {
1477            for impl_item in &mut i.items {
1478                match impl_item {
1479                    syn::ImplItem::Fn(f) if item_is_public(&f.vis) => {
1480                        ensure_public_doc(&mut f.attrs, "method", &f.sig.ident.to_string());
1481                    }
1482                    syn::ImplItem::Const(c) if item_is_public(&c.vis) => {
1483                        ensure_public_doc(&mut c.attrs, "constant", &c.ident.to_string());
1484                    }
1485                    syn::ImplItem::Type(t) if item_is_public(&t.vis) => {
1486                        ensure_public_doc(&mut t.attrs, "type", &t.ident.to_string());
1487                    }
1488                    _ => {}
1489                }
1490            }
1491        }
1492        syn::Item::Mod(m) if item_is_public(&m.vis) => {
1493            ensure_public_doc(&mut m.attrs, "module", &m.ident.to_string());
1494            if let Some((_, items)) = &mut m.content {
1495                for nested in items {
1496                    annotate_item(nested);
1497                }
1498            }
1499        }
1500        syn::Item::Use(u) if item_is_public(&u.vis) => {
1501            ensure_public_doc(&mut u.attrs, "import", "use");
1502        }
1503        syn::Item::Static(s) if item_is_public(&s.vis) => {
1504            ensure_public_doc(&mut s.attrs, "static", &s.ident.to_string());
1505        }
1506        _ => {}
1507    }
1508}
1509
1510/// Generate a `pub mod prelude` that re-exports the common API surface so users
1511/// can write `use my_schema::prelude::*;`.
1512#[cfg(test)]
1513mod tests {
1514    use super::Generator;
1515    use crate::{GenerationConfig, Schema};
1516
1517    #[test]
1518    fn generator_emits_deterministic_module_name() -> Result<(), Box<dyn std::error::Error>> {
1519        let mut generator = Generator::new(GenerationConfig::new("market_data"));
1520        let schema = Schema::new("fix.sbe", 1, 0);
1521
1522        let modules = generator.generate(&schema)?;
1523        let collected = modules.modules().collect::<Vec<_>>();
1524
1525        assert_eq!(collected.len(), 1);
1526        assert_eq!(collected[0].path, "market_data.rs");
1527        assert!(collected[0].source.contains("fix.sbe"));
1528
1529        Ok(())
1530    }
1531
1532    #[test]
1533    fn generate_multi_creates_separate_modules() -> Result<(), Box<dyn std::error::Error>> {
1534        let mut config = GenerationConfig::new("common");
1535        config.shared_module = Some("common_types".to_string());
1536
1537        let mut generator = Generator::new(config);
1538
1539        let schema_a = Schema::new("common.sbe", 1, 0);
1540        let schema_b = Schema::new("market_data.sbe", 2, 0);
1541
1542        let modules =
1543            generator.generate_multi(&[(&schema_a, "common_types"), (&schema_b, "market_data")])?;
1544        let collected: Vec<_> = modules.modules().collect();
1545
1546        assert_eq!(collected.len(), 2);
1547        assert_eq!(collected[0].path, "common_types.rs");
1548        assert_eq!(collected[1].path, "market_data.rs");
1549
1550        assert!(collected[0].source.contains("pub mod sbe_rt"));
1551
1552        // Second module does NOT have its own sbe_rt (sbe_rt comes via pub use)
1553        assert!(!collected[1].source.contains("pub mod sbe_rt"));
1554
1555        assert!(
1556            collected[1]
1557                .source
1558                .contains("pub use super::common_types::*;")
1559        );
1560
1561        assert!(collected[0].source.contains("common.sbe"));
1562        assert!(collected[1].source.contains("market_data.sbe"));
1563
1564        Ok(())
1565    }
1566
1567    #[test]
1568    fn into_parts_preserves_module_order_and_warnings() -> Result<(), Box<dyn std::error::Error>> {
1569        let mut set = super::GeneratedModuleSet::default();
1570        set.push(super::GeneratedModule {
1571            path: "common_types.rs".into(),
1572            source: "mod common;".into(),
1573        });
1574        set.push(super::GeneratedModule {
1575            path: "market_data.rs".into(),
1576            source: "mod market;".into(),
1577        });
1578        set.warnings
1579            .push("shared type Price has sinceVersion > 0".into());
1580        let expected_paths: Vec<String> = set.modules().map(|m| m.path.clone()).collect();
1581        let expected_warnings = set.warnings().to_vec();
1582        let (modules, warnings) = set.into_parts();
1583        assert_eq!(
1584            modules.iter().map(|m| m.path.as_str()).collect::<Vec<_>>(),
1585            expected_paths
1586                .iter()
1587                .map(String::as_str)
1588                .collect::<Vec<_>>()
1589        );
1590        assert_eq!(modules[0].source, "mod common;");
1591        assert_eq!(modules[1].source, "mod market;");
1592        assert_eq!(warnings, expected_warnings);
1593        Ok(())
1594    }
1595
1596    #[test]
1597    fn generate_multi_without_shared_module_emits_sbe_rt_everywhere()
1598    -> Result<(), Box<dyn std::error::Error>> {
1599        let config = GenerationConfig::new("common");
1600        let mut generator = Generator::new(config);
1601
1602        let schema_a = Schema::new("common.sbe", 1, 0);
1603        let schema_b = Schema::new("market_data.sbe", 2, 0);
1604
1605        let modules = generator.generate_multi(&[(&schema_a, "a_mod"), (&schema_b, "b_mod")])?;
1606        let collected: Vec<_> = modules.modules().collect();
1607
1608        assert_eq!(collected.len(), 2);
1609
1610        // Both modules get sbe_rt when no shared_module is configured
1611        assert!(collected[0].source.contains("pub mod sbe_rt"));
1612        assert!(collected[1].source.contains("pub mod sbe_rt"));
1613
1614        // No top-level pub use re-exports (prelude's pub use is inside its module)
1615        assert!(!collected[1].source.contains("\npub use super::"));
1616        Ok(())
1617    }
1618
1619    use super::{
1620        SchemaElements, parse_composite_members, parse_field_structure, parse_group_structure,
1621        parse_message_structure, parse_vardata_structure, to_snake_case,
1622    };
1623    use crate::ir::{Encoding, Signal, Token};
1624
1625    fn make_token(signal: Signal) -> Token {
1626        Token {
1627            id: None,
1628            name: String::new(),
1629            signal,
1630            encoding: Encoding::default(),
1631            span: None,
1632        }
1633    }
1634
1635    fn empty_elements() -> SchemaElements {
1636        SchemaElements {
1637            composites: vec![],
1638            enums: vec![],
1639            sets: vec![],
1640            messages: vec![],
1641        }
1642    }
1643
1644    #[test]
1645    fn message_structure_skips_unexpected_signal() -> Result<(), Box<dyn std::error::Error>> {
1646        // parse_message_structure body loop: BeginEnum inside a message body
1647        // falls to `_ => i += 1` (lines ~797-799).
1648        let elem = empty_elements();
1649        let _ = parse_message_structure(
1650            &[
1651                make_token(Signal::BeginMessage),
1652                make_token(Signal::BeginEnum), // unexpected
1653                make_token(Signal::EndMessage),
1654            ],
1655            &elem,
1656        );
1657
1658        Ok(())
1659    }
1660
1661    #[test]
1662    fn group_structure_skips_unexpected_signal() -> Result<(), Box<dyn std::error::Error>> {
1663        // parse_group_structure body loop: BeginMessage inside a group body
1664        // falls to `_ => i += 1` (lines ~937-939).
1665        let elem = empty_elements();
1666        let _ = parse_group_structure(
1667            &[
1668                make_token(Signal::BeginGroup),
1669                make_token(Signal::BeginMessage), // unexpected
1670                make_token(Signal::EndGroup),
1671            ],
1672            &elem,
1673        );
1674
1675        Ok(())
1676    }
1677
1678    #[test]
1679    fn vardata_structure_skips_non_length_fields() -> Result<(), Box<dyn std::error::Error>> {
1680        // parse_vardata_structure loops tokens looking for the "length"
1681        // BeginField; any other BeginField falls to `i += 1` (lines ~974-977).
1682        let _ = parse_vardata_structure(&[
1683            make_token(Signal::BeginComposite),
1684            make_token(Signal::BeginField),
1685            make_token(Signal::EndField),
1686            make_token(Signal::EndComposite),
1687        ]);
1688
1689        Ok(())
1690    }
1691
1692    #[test]
1693    fn composite_members_skips_non_field_signals() -> Result<(), Box<dyn std::error::Error>> {
1694        // parse_composite_members loops from index 1 to len-1; any signal
1695        // that isn't BeginField falls to `else { i += 1 }` (lines ~1097-1099).
1696        let _ = parse_composite_members(&[
1697            make_token(Signal::BeginComposite),
1698            make_token(Signal::BeginMessage), // not BeginField → skip
1699            make_token(Signal::EndComposite),
1700        ]);
1701        Ok(())
1702    }
1703
1704    #[test]
1705    fn field_structure_falls_back_to_uint8_primitive() -> Result<(), Box<dyn std::error::Error>> {
1706        // parse_field_structure: when tokens.len() > 2 and the inner signal
1707        // isn't BeginComposite/Enum/Set, defaults to Primitive(UInt8) (865-871).
1708        let elem = empty_elements();
1709        let _ = parse_field_structure(
1710            &[
1711                make_token(Signal::BeginField),
1712                make_token(Signal::BeginMessage), // unexpected inner → Primitive default
1713                make_token(Signal::EndField),
1714            ],
1715            &elem,
1716        );
1717
1718        Ok(())
1719    }
1720
1721    #[test]
1722    fn group_array_codegen_uses_the_complete_field_extent_and_element_range()
1723    -> Result<(), Box<dyn std::error::Error>> {
1724        let xml = r#"<?xml version="1.0"?>
1725        <messageSchema package="array.guard" id="305" version="1" byteOrder="littleEndian">
1726          <types>
1727            <composite name="messageHeader">
1728              <type name="blockLength" primitiveType="uint16"/>
1729              <type name="templateId" primitiveType="uint16"/>
1730              <type name="schemaId" primitiveType="uint16"/>
1731              <type name="version" primitiveType="uint16"/>
1732            </composite>
1733            <composite name="groupSizeEncoding">
1734              <type name="blockLength" primitiveType="uint16"/>
1735              <type name="numInGroup" primitiveType="uint16"/>
1736            </composite>
1737            <type name="Values" primitiveType="uint32" length="2"/>
1738            <enum name="State" encodingType="uint8">
1739              <validValue name="Ready">1</validValue>
1740            </enum>
1741            <set name="Flags" encodingType="uint8">
1742              <choice name="Active">0</choice>
1743            </set>
1744            <enum name="BooleanType" encodingType="uint8">
1745              <validValue name="F">0</validValue>
1746              <validValue name="T">1</validValue>
1747            </enum>
1748          </types>
1749          <message name="ArrayBoundaryMessage" id="1">
1750            <group name="entries" id="1">
1751              <field name="base" id="2" type="uint8"/>
1752              <field name="values" id="3" type="Values"/>
1753              <field name="state" id="4" type="State" sinceVersion="1"/>
1754              <field name="flags" id="5" type="Flags" sinceVersion="1"/>
1755              <field name="enabled" id="6" type="BooleanType" sinceVersion="1"/>
1756            </group>
1757          </message>
1758        </messageSchema>"#;
1759        let schema = crate::Schema::from_ir(crate::parse(xml)?);
1760        let mut generator = crate::Generator::new(crate::GenerationConfig::new("array_guard"));
1761        let modules = generator.generate(&schema)?;
1762        let source = &modules
1763            .modules()
1764            .next()
1765            .ok_or("missing generated module")?
1766            .source;
1767
1768        assert!(
1769            source.contains("9 > self.acting_block_length"),
1770            "u32[2] at offset 1 must require all nine entry bytes"
1771        );
1772        assert!(
1773            source.contains("let all: [u8; 8]"),
1774            "u32[2] must bulk-read exactly eight bytes"
1775        );
1776        assert!(
1777            source.contains("all[0usize]") && source.contains("all[7usize]"),
1778            "the unrolled array decode must use the complete byte range"
1779        );
1780        assert!(
1781            source.contains("|| 10 > self.acting_block_length"),
1782            "the versioned enum at offset nine must require its complete tenth byte"
1783        );
1784        assert!(
1785            source.contains("|| 11 > self.acting_block_length"),
1786            "the versioned set at offset ten must require its complete eleventh byte"
1787        );
1788        assert!(
1789            source.contains("pub fn try_enabled_bool(&self) -> Result<Option<bool>,")
1790                && source.contains("InvalidBoolean"),
1791            "a versioned BooleanType group field must carry the typed bool accessor"
1792        );
1793        Ok(())
1794    }
1795
1796    #[test]
1797    fn snake_case_handles_empty_or_special_input() -> Result<(), Box<dyn std::error::Error>> {
1798        assert_eq!(to_snake_case(""), "");
1799        // Double-underscore input exercises the dedup `continue` (line 520).
1800        assert_eq!(to_snake_case("Foo__Bar"), "foo_bar");
1801
1802        Ok(())
1803    }
1804
1805    #[test]
1806    fn partition_skips_unexpected_at_top_level() -> Result<(), Box<dyn std::error::Error>> {
1807        // Top-level loop only matches BeginComposite/Enum/Set/Message;
1808        // BeginField falls to `_ => i += 1` (lines ~682-684).
1809        let _ = super::partition_tokens(&[make_token(Signal::BeginField)]);
1810
1811        Ok(())
1812    }
1813
1814    #[test]
1815    fn partition_skips_unexpected_in_message_body() -> Result<(), Box<dyn std::error::Error>> {
1816        // Message body loop only matches BeginField/Group/VarData;
1817        // BeginEnum inside a message body falls to `_ => i += 1` (lines ~797).
1818        let _ = super::partition_tokens(&[
1819            make_token(Signal::BeginMessage),
1820            make_token(Signal::BeginEnum), // unexpected inside message body
1821            make_token(Signal::EndMessage),
1822        ]);
1823
1824        Ok(())
1825    }
1826
1827    #[test]
1828    fn partition_skips_unexpected_in_group_body() -> Result<(), Box<dyn std::error::Error>> {
1829        // Group body loop only matches BeginField/Group/VarData;
1830        // BeginMessage inside a group falls to `_ => i += 1` (lines ~937).
1831        let _ = super::partition_tokens(&[
1832            make_token(Signal::BeginGroup),
1833            make_token(Signal::BeginMessage), // unexpected inside group body
1834            make_token(Signal::EndGroup),
1835        ]);
1836
1837        Ok(())
1838    }
1839
1840    #[test]
1841    fn partition_skips_unexpected_after_top_level_items() -> Result<(), Box<dyn std::error::Error>>
1842    {
1843        // After BeginMessage/EndMessage pair, unrelated signals skip at top level.
1844        let _ = super::partition_tokens(&[
1845            make_token(Signal::BeginMessage),
1846            make_token(Signal::EndMessage),
1847            make_token(Signal::BeginEnum), // at top level
1848        ]);
1849
1850        Ok(())
1851    }
1852
1853    #[test]
1854    fn semantic_type_matches_primitive_field() -> Result<(), Box<dyn std::error::Error>> {
1855        use crate::ir::{Presence, PrimitiveType};
1856        use crate::structured_ir::{FieldType, MessageField};
1857        let field = MessageField {
1858            name: "exchangeTimestamp".into(),
1859            id: Some(1),
1860            offset: 0,
1861            presence: Presence::Required,
1862            since_version: 0,
1863            null_value: None,
1864            min_value: None,
1865            max_value: None,
1866            description: None,
1867            deprecated: false,
1868            semantic_type: Some("UTCTimestamp".into()),
1869            constant_value: None,
1870            epoch: None,
1871            time_unit: None,
1872            character_encoding: None,
1873            field_type: FieldType::Primitive(PrimitiveType::UInt64, None),
1874        };
1875        let conversions = vec![crate::ConversionSelector::semantic_type("UTCTimestamp")];
1876        assert!(
1877            super::field_has_conversion_free(&field, &conversions),
1878            "SemanticType should match primitive u64 with semanticType=UTCTimestamp"
1879        );
1880
1881        let domain_types = vec![(
1882            crate::ConversionSelector::semantic_type("UTCTimestamp"),
1883            "chrono::DateTime<chrono::Utc>".into(),
1884        )];
1885        let dt = super::find_domain_type(&field, &domain_types);
1886        assert_eq!(
1887            dt,
1888            Some("chrono::DateTime<chrono::Utc>"),
1889            "should find domain type for UTCTimestamp"
1890        );
1891        Ok(())
1892    }
1893
1894    #[test]
1895    fn chrono_converter_generates_accessor() -> Result<(), Box<dyn std::error::Error>> {
1896        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1897        <sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe"
1898            package="test.chrono" id="1" version="0" byteOrder="littleEndian">
1899          <types>
1900            <composite name="messageHeader">
1901              <type name="blockLength" primitiveType="uint16"/>
1902              <type name="templateId"   primitiveType="uint16"/>
1903              <type name="schemaId"     primitiveType="uint16"/>
1904              <type name="version"      primitiveType="uint16"/>
1905            </composite>
1906          </types>
1907          <sbe:message name="TsMsg" id="1">
1908            <field name="ts" id="1" type="uint64" semanticType="UTCTimestamp"/>
1909          </sbe:message>
1910        </sbe:messageSchema>"#;
1911        let ir = crate::parse(xml)?;
1912        let schema = crate::Schema::from_ir(ir);
1913        let config = crate::GenerationConfig::new("test_chrono").with_domain_type(
1914            crate::ConversionSelector::semantic_type("UTCTimestamp"),
1915            "chrono::DateTime<chrono::Utc>",
1916        );
1917        let mut generator = crate::Generator::new(config);
1918        let modules = generator.generate(&schema)?;
1919        let src = modules.modules().next().unwrap().source.clone();
1920        assert!(
1921            src.contains("fn try_ts(") && src.contains("chrono::DateTime"),
1922            "should generate fallible concrete DateTime accessor for UTCTimestamp field"
1923        );
1924        assert!(
1925            src.contains("fn ts_wire"),
1926            "should rename raw u64 getter to _wire"
1927        );
1928        assert!(
1929            src.contains("impl TryFromSbe<u64> for chrono::DateTime<chrono::Utc>"),
1930            "should generate TryFromSbe impl"
1931        );
1932        Ok(())
1933    }
1934
1935    #[test]
1936    fn narrow_message_header_rejects_values_above_declared_field_maximum() {
1937        fn generate(xml: &str) -> Result<super::GeneratedModuleSet, super::GenerateError> {
1938            let ir = crate::parse(xml).expect("schema should parse before codegen validation");
1939            let schema = crate::Schema::from_ir(ir);
1940            crate::Generator::new(crate::GenerationConfig::new("narrow")).generate(&schema)
1941        }
1942
1943        fn schema(schema_id: u16, version: u16, template_id: u16, block_length: u16) -> String {
1944            format!(
1945                r#"<messageSchema package="test" id="{schema_id}" version="{version}" byteOrder="littleEndian">
1946                  <types>
1947                    <composite name="messageHeader">
1948                      <type name="schemaId" primitiveType="uint8"/>
1949                      <type name="version" primitiveType="uint8"/>
1950                      <type name="templateId" primitiveType="uint8"/>
1951                      <type name="blockLength" primitiveType="uint8"/>
1952                    </composite>
1953                  </types>
1954                  <message name="M" id="{template_id}" blockLength="{block_length}"/>
1955                </messageSchema>"#
1956            )
1957        }
1958
1959        for (xml, field) in [
1960            (schema(255, 1, 1, 0), "schemaId"),
1961            (schema(1, 255, 1, 0), "version"),
1962            (schema(1, 1, 255, 0), "templateId"),
1963            (schema(1, 1, 1, 255), "blockLength"),
1964        ] {
1965            let error = generate(&xml).expect_err("reserved null/max value must be rejected");
1966            assert!(
1967                error.to_string().contains(field),
1968                "expected {field} error, got: {error}"
1969            );
1970        }
1971    }
1972
1973    /// Placement utils live on metadata only — a field named `remaining` keeps
1974    /// its natural accessor name and does not force `_field`.
1975    #[test]
1976    fn field_named_remaining_keeps_name_placement_on_metadata()
1977    -> Result<(), Box<dyn std::error::Error>> {
1978        let xml = r#"<messageSchema package="test" id="1" version="1" byteOrder="littleEndian">
1979          <types>
1980            <composite name="messageHeader">
1981              <type name="blockLength" primitiveType="uint16"/>
1982              <type name="templateId" primitiveType="uint16"/>
1983              <type name="schemaId" primitiveType="uint16"/>
1984              <type name="version" primitiveType="uint16"/>
1985            </composite>
1986          </types>
1987          <message name="Msg" id="1" blockLength="8">
1988            <field name="remaining" id="1" type="int64"/>
1989          </message>
1990        </messageSchema>"#;
1991
1992        let ir = crate::parse(xml).expect("schema should parse");
1993        let schema = crate::Schema::from_ir(ir);
1994        let modules =
1995            crate::Generator::new(crate::GenerationConfig::new("test")).generate(&schema)?;
1996        let src = modules.modules().next().expect("one module").source.clone();
1997
1998        assert!(
1999            src.contains("fn remaining(&self) -> i64")
2000                || src.contains("fn remaining(&self) -> i64,"),
2001            "field accessor must keep name remaining() as i64. src snippet check failed"
2002        );
2003        assert!(
2004            !src.contains("fn remaining_field"),
2005            "placement-name fields must not be renamed to remaining_field"
2006        );
2007        assert!(
2008            src.contains("fn get_metadata("),
2009            "placement utils must be on get_metadata()"
2010        );
2011        // Metadata still exposes remaining() as a byte slice utility.
2012        assert!(
2013            src.contains("DecoderMetadata"),
2014            "DecoderMetadata type must be emitted"
2015        );
2016
2017        Ok(())
2018    }
2019
2020    /// A hook that adds serde `Serialize` for every SBE enum (variants as
2021    /// strings) and every SBE set (variants as `Vec<String>`), plus
2022    /// `Deserialize` for the enum.
2023    #[test]
2024    fn hook_adds_serde_impls_for_enum_and_set() -> Result<(), Box<dyn std::error::Error>> {
2025        let xml = r#"<messageSchema package="test" id="1" version="0" byteOrder="littleEndian">
2026          <types>
2027            <composite name="messageHeader">
2028              <type name="blockLength" primitiveType="uint16"/>
2029              <type name="templateId" primitiveType="uint16"/>
2030              <type name="schemaId" primitiveType="uint16"/>
2031              <type name="version" primitiveType="uint16"/>
2032            </composite>
2033            <enum name="EventCode" encodingType="uint32">
2034              <validValue name="Ok" description="Success">200</validValue>
2035              <validValue name="Error" description="Failure">400</validValue>
2036              <validValue name="Timeout">408</validValue>
2037            </enum>
2038            <set name="OptionalFields" encodingType="uint8">
2039              <choice name="hasPrice">0</choice>
2040              <choice name="hasQty">1</choice>
2041              <choice name="hasVenue">2</choice>
2042            </set>
2043          </types>
2044          <message name="Msg" id="1" blockLength="0"/>
2045        </messageSchema>"#;
2046
2047        use crate::{EnumVariantInfo, ItemContext, ItemKind, SetChoiceInfo};
2048        use quote::format_ident;
2049
2050        let config = crate::GenerationConfig::new("test")
2051            .with_hook(|ctx: &ItemContext| -> Vec<proc_macro2::TokenStream> {
2052                match ctx {
2053                    ItemContext::Enum { name, variants, .. } => {
2054                        let ident = format_ident!("{name}");
2055                        let var_names: Vec<_> = variants.iter().map(|v| format_ident!("{}", v.name)).collect();
2056                        let var_labels: Vec<_> = variants.iter().map(|v| v.name.clone()).collect();
2057                        vec![quote::quote! {
2058                            impl serde::Serialize for #ident {
2059                                fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2060                                    let label = match self {
2061                                        #(Self::#var_names => #var_labels,)*
2062                                    };
2063                                    s.serialize_str(label)
2064                                }
2065                            }
2066
2067                            impl<'de> serde::Deserialize<'de> for #ident {
2068                                fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2069                                    let s = <&str>::deserialize(d)?;
2070                                    match s {
2071                                        #(#var_labels => Ok(Self::#var_names),)*
2072                                        _ => Err(serde::de::Error::unknown_variant(s, &[#(#var_labels),*])),
2073                                    }
2074                                }
2075                            }
2076                        }]
2077                    }
2078                    ItemContext::Set { name, encoding_type, choices, .. } => {
2079                        let ident = format_ident!("{name}");
2080                        // Getters are `is_{snake_name}()`; the wire mask is
2081                        // `1 << bit_position`. Use u64 as the accumulator so
2082                        // bit positions 0-63 work regardless of the schema's
2083                        // encodingType (u8/u16/u32/u64).
2084                        let c_getters: Vec<_> = choices
2085                            .iter()
2086                            .map(|c| format_ident!("is_{}", c.snake_name))
2087                            .collect();
2088                        let c_labels: Vec<_> = choices.iter().map(|c| c.label.clone()).collect();
2089                        let c_bits: Vec<_> = choices.iter().map(|c| c.bit_position).collect();
2090                        let acc_ty: syn::Type = syn::parse_str(encoding_type).unwrap();
2091                        vec![quote::quote! {
2092                            impl serde::Serialize for #ident {
2093                                fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2094                                    let mut names = Vec::new();
2095                                    #(if self.#c_getters() { names.push(#c_labels); })*
2096                                    names.serialize(s)
2097                                }
2098                            }
2099
2100                            impl<'de> serde::Deserialize<'de> for #ident {
2101                                fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2102                                    let names: Vec<String> = Vec::deserialize(d)?;
2103                                    let mut value: u64 = 0;
2104                                    for name in &names {
2105                                        match name.as_str() {
2106                                            #(#c_labels => value |= 1u64 << #c_bits,)*
2107                                            other => return Err(serde::de::Error::unknown_variant(
2108                                                other, &[#(#c_labels),*])),
2109                                        }
2110                                    }
2111                                    Ok(Self(value as #acc_ty))
2112                                }
2113                            }
2114                        }]
2115                    }
2116                    _ => vec![],
2117                }
2118            });
2119
2120        let ir = crate::parse(xml).expect("schema should parse");
2121        let schema = crate::Schema::from_ir(ir);
2122        let modules = crate::Generator::new(config).generate(&schema)?;
2123        let src = modules.modules().next().expect("one module").source.clone();
2124
2125        // Enum: Serialize impl must exist with variant labels.
2126        assert!(
2127            src.contains("impl serde::Serialize for EventCode"),
2128            "missing Serialize for enum"
2129        );
2130        assert!(src.contains("\"Ok\""), "missing Ok label");
2131        assert!(src.contains("\"Error\""), "missing Error label");
2132        assert!(
2133            src.contains("impl<'de> serde::Deserialize<'de> for EventCode"),
2134            "missing Deserialize for enum"
2135        );
2136        assert!(
2137            src.contains("unknown_variant"),
2138            "missing error handling in Deserialize"
2139        );
2140
2141        // Set: Serialize impl must exist.
2142        assert!(
2143            src.contains("impl serde::Serialize for OptionalFields"),
2144            "missing Serialize for set"
2145        );
2146        assert!(src.contains("\"hasPrice\""), "missing hasPrice label");
2147        assert!(
2148            src.contains("impl<'de> serde::Deserialize<'de> for OptionalFields"),
2149            "missing Deserialize for set"
2150        );
2151
2152        Ok(())
2153    }
2154
2155    /// `with_bool_domain_type()` must work for multi-schema generation, not
2156    /// just single-schema. Each schema's boolean enums are auto-registered, and
2157    /// the generated output includes the domain-typed getter.
2158    #[test]
2159    fn with_bool_domain_type_works_with_generate_multi() -> Result<(), Box<dyn std::error::Error>> {
2160        let xml_a = r#"<?xml version="1.0"?>
2161        <messageSchema package="a" id="1" version="0" byteOrder="littleEndian">
2162          <types>
2163            <composite name="messageHeader">
2164              <type name="blockLength" primitiveType="uint16"/>
2165              <type name="templateId" primitiveType="uint16"/>
2166              <type name="schemaId" primitiveType="uint16"/>
2167              <type name="version" primitiveType="uint16"/>
2168            </composite>
2169            <enum name="BooleanType" encodingType="uint8">
2170              <validValue name="F">0</validValue>
2171              <validValue name="T">1</validValue>
2172            </enum>
2173          </types>
2174          <message name="MsgA" id="1" blockLength="1">
2175            <field name="flag" id="1" type="BooleanType" offset="0"/>
2176          </message>
2177        </messageSchema>"#;
2178        let xml_b = r#"<?xml version="1.0"?>
2179        <messageSchema package="b" id="2" version="0" byteOrder="littleEndian">
2180          <types>
2181            <composite name="messageHeader">
2182              <type name="blockLength" primitiveType="uint16"/>
2183              <type name="templateId" primitiveType="uint16"/>
2184              <type name="schemaId" primitiveType="uint16"/>
2185              <type name="version" primitiveType="uint16"/>
2186            </composite>
2187            <enum name="BooleanType" encodingType="uint8">
2188              <validValue name="F">0</validValue>
2189              <validValue name="T">1</validValue>
2190            </enum>
2191          </types>
2192          <message name="MsgB" id="1" blockLength="1">
2193            <field name="enabled" id="1" type="BooleanType" offset="0"/>
2194          </message>
2195        </messageSchema>"#;
2196
2197        let schema_a = Schema::from_ir(crate::parse(xml_a)?);
2198        let schema_b = Schema::from_ir(crate::parse(xml_b)?);
2199        let mut generator = Generator::new(
2200            crate::GenerationConfig::new("common_types")
2201                .with_shared_module("common_types")
2202                .with_bool_domain_type(true),
2203        );
2204        let modules =
2205            generator.generate_multi(&[(&schema_a, "common_types"), (&schema_b, "consumer")])?;
2206        let collected: Vec<_> = modules.modules().collect();
2207        assert_eq!(collected.len(), 2);
2208
2209        // The consumer module should have a bool-typed getter on the field
2210        // whose type is BooleanType (auto-registered as bool domain type).
2211        // The domain getter is `{field}_bool`, not the bare name — the bare
2212        // name stays as the wire-type accessor.
2213        let consumer_src = &collected[1].source;
2214        assert!(
2215            consumer_src.contains("fn try_enabled_bool"),
2216            "with_bool_domain_type must produce bool getter in multi-schema; got:\n{consumer_src}",
2217        );
2218        Ok(())
2219    }
2220
2221    fn ping_schema_xml() -> &'static str {
2222        r#"<?xml version="1.0"?>
2223        <messageSchema package="ex" id="1" version="0" byteOrder="littleEndian">
2224          <types>
2225            <composite name="messageHeader">
2226              <type name="blockLength" primitiveType="uint16"/>
2227              <type name="templateId" primitiveType="uint16"/>
2228              <type name="schemaId" primitiveType="uint16"/>
2229              <type name="version" primitiveType="uint16"/>
2230            </composite>
2231          </types>
2232          <message name="Ping" id="1" blockLength="4">
2233            <field name="seq" id="1" type="uint32" offset="0"/>
2234          </message>
2235        </messageSchema>"#
2236    }
2237
2238    fn generate_ping(config: GenerationConfig) -> Result<String, Box<dyn std::error::Error>> {
2239        let schema = Schema::from_ir(crate::parse(ping_schema_xml())?);
2240        let src = Generator::new(config)
2241            .generate(&schema)?
2242            .modules()
2243            .next()
2244            .ok_or("no module")?
2245            .source
2246            .clone();
2247        Ok(src)
2248    }
2249
2250    /// Size knobs must omit the corresponding tokens when set to `false`.
2251    #[test]
2252    fn size_knobs_omit_display_meta_and_dispatch() -> Result<(), Box<dyn std::error::Error>> {
2253        let full = generate_ping(GenerationConfig::new("ping"))?;
2254        assert!(
2255            full.contains("core::fmt::Display for PingDecoder"),
2256            "default must emit Display for PingDecoder; got marker search fail in {} chars",
2257            full.len()
2258        );
2259        assert!(
2260            full.contains("core::fmt::Debug for PingDecoder"),
2261            "default must emit Debug for PingDecoder"
2262        );
2263        assert!(
2264            full.contains("SEQ_ENCODING_OFFSET"),
2265            "default must emit field ENCODING_OFFSET constants"
2266        );
2267        assert!(
2268            full.contains("seq_meta_attribute"),
2269            "default must emit field meta_attribute fn"
2270        );
2271        assert!(
2272            full.contains("ping_field_meta"),
2273            "default must emit per-message field_meta module"
2274        );
2275        assert!(
2276            full.contains("enum AnyMessage"),
2277            "default must emit AnyMessage dispatch"
2278        );
2279
2280        let lean = generate_ping(
2281            GenerationConfig::new("ping")
2282                .with_display_debug(false)
2283                .with_meta_attributes(false)
2284                .with_dispatch(false),
2285        )?;
2286        assert!(
2287            !lean.contains("core::fmt::Display for PingDecoder")
2288                && !lean.contains("core::fmt::Debug for PingDecoder"),
2289            "with_display_debug(false) must omit Display/Debug"
2290        );
2291        assert!(
2292            !lean.contains("SEQ_ENCODING_OFFSET") && !lean.contains("seq_meta_attribute"),
2293            "with_meta_attributes(false) must omit field meta constants"
2294        );
2295        assert!(
2296            !lean.contains("ping_field_meta"),
2297            "with_meta_attributes(false) must omit field_meta module"
2298        );
2299        assert!(
2300            !lean.contains("enum AnyMessage") && !lean.contains("struct FrameCursor"),
2301            "with_dispatch(false) must omit AnyMessage/FrameCursor"
2302        );
2303        // Codec surface still present.
2304        assert!(lean.contains("struct PingDecoder"));
2305        assert!(lean.contains("struct PingEncoder"));
2306        Ok(())
2307    }
2308}