ergo-sbe 0.1.19

Simple Binary Encoding (SBE) — opinionated, idiomatic Rust code generation for low-latency trading protocols.
Documentation
//! `TryFromSbe` / `TryToSbe` trait emission and built-in domain-type impls.

use super::runtime::to_pascal_case;
use crate::structured_ir::SchemaElements;

/// Emit `TryFromSbe` / `TryToSbe` traits into the generated sbe_rt module.
pub(crate) fn emit_conversion_traits(src: &mut String) {
    src.push_str(
        "/// Convert from a wire type to an application type.\n\
         pub trait TryFromSbe<Wire>: Sized {\n\
             type Error: core::fmt::Debug + core::fmt::Display;\n\
             fn try_from_sbe(wire: Wire) -> Result<Self, Self::Error>;\n\
         }\n\n\
         /// Convert from an application type to a wire type.\n\
         pub trait TryToSbe<Wire> {\n\
             type Error: core::fmt::Debug + core::fmt::Display;\n\
             fn try_to_sbe(&self) -> Result<Wire, Self::Error>;\n\
         }\n\n",
    );
}

/// Emit `TryFromSbe` / `TryToSbe` impls for well-known **domain-type** mappings
/// (bool ↔ BooleanType, rust_decimal ↔ Decimal, chrono ↔ u64/UTCTimestamp).
///
/// These built-in impls are gated on `domain_types`, not bare `conversions`:
/// `with_conversion` alone keeps the seam dependency-free so callers can plug
/// any adapter (see samples/sbe-feature-tour `FixedPrice` / app-side
/// rust_decimal). `with_domain_type` opts into a concrete app type *and* these
/// well-known impls.
pub(crate) fn generate_conversion_impl_blocks(
    elements: &SchemaElements,
    _conversions: &[crate::ConversionSelector],
    domain_types: &[(crate::ConversionSelector, String)],
) -> String {
    let mut out = String::new();
    let span = proc_macro2::Span::call_site();

    // Built-ins require an explicit domain-type mapping (not conversion-only).
    // Emit TryFromSbe / TryToSbe for EVERY boolean enum mapped to "bool",
    // not just the one literally named BooleanType.
    for (sel, ty) in domain_types {
        if ty != "bool" {
            continue;
        }
        let bt_name = match sel {
            crate::ConversionSelector::NamedType(n) => to_pascal_case(n),
            _ => continue,
        };
        let bt_ident = syn::Ident::new(&bt_name, span);
        let ts = quote::quote! {
            impl TryFromSbe<#bt_ident> for bool {
                type Error = &'static str;
                #[inline]
                fn try_from_sbe(wire: #bt_ident) -> Result<Self, Self::Error> {
                    wire.as_bool().ok_or("null or unknown boolean discriminant")
                }
            }
            impl TryToSbe<#bt_ident> for bool {
                type Error = &'static str;
                #[inline]
                fn try_to_sbe(&self) -> Result<#bt_ident, Self::Error> {
                    Ok(#bt_ident::from(*self))
                }
            }
        };
        out.push_str(&ts.to_string());
    }

    let has_chrono_conv = domain_types.iter().any(|(sel, _)| {
        matches!(sel, crate::ConversionSelector::SemanticType(st) if st == "UTCTimestamp")
    });

    // Emit rust_decimal TryFromSbe/TryToSbe for EVERY composite mapped to
    // "rust_decimal::Decimal" — not just one literally named "Decimal". A
    // schema with Decimal64 / Decimal128 (or any other decimal composite)
    // gets one impl per composite, keyed to that composite's ident.
    for (sel, ty) in domain_types {
        if ty != "rust_decimal::Decimal" {
            continue;
        }
        let comp_name = match sel {
            crate::ConversionSelector::NamedType(n) => n.as_str(),
            _ => continue,
        };
        let Some(dec_composite) = elements.composites.iter().find(|c| c[0].name == comp_name)
        else {
            continue;
        };
        let dec_ident = syn::Ident::new(&to_pascal_case(comp_name), span);
        // Check if the schema's Decimal composite has a constant exponent.
        let exponent_is_constant = dec_composite
            .iter()
            .find(|t| t.name == "exponent")
            .map(|t| t.encoding.presence == crate::ir::Presence::Constant)
            .unwrap_or(false);
        let dec_new_call: proc_macro2::TokenStream = if exponent_is_constant {
            quote::quote! { #dec_ident::new(mantissa) }
        } else {
            quote::quote! { #dec_ident::new(mantissa, -(self.scale() as i8)) }
        };
        // A mantissa with presence="optional" (and a schema nullValue) has a
        // genuine null image — `mantissa()` decodes it as `Option<i64>`. The
        // wire null sentinel is not a valid Decimal, so it fails closed as a
        // typed error rather than silently decoding as a huge/wrong number.
        let mantissa_is_optional = dec_composite
            .iter()
            .find(|t| t.name == "mantissa")
            .map(|t| t.encoding.presence == crate::ir::Presence::Optional)
            .unwrap_or(false);
        let mantissa_expr: proc_macro2::TokenStream = if mantissa_is_optional {
            quote::quote! { wire.mantissa().ok_or("null Decimal mantissa")? as i128 }
        } else {
            quote::quote! { wire.mantissa() as i128 }
        };
        let ts = quote::quote! {
            impl TryFromSbe<#dec_ident> for rust_decimal::Decimal {
                type Error = &'static str;
                #[inline]
                fn try_from_sbe(wire: #dec_ident) -> Result<Self, Self::Error> {
                    let mantissa = #mantissa_expr;
                    let exponent = wire.exponent() as i32;
                    // SBE Decimal: negative exponent = fractional places (e.g.
                    // -2 → scale 2). Positive exponent = magnitude (mantissa ×
                    // 10^exp). rust_decimal scale must be a positive u32 ≤ 28.
                    let (mantissa, scale) = if exponent < 0 {
                        let scale = exponent.unsigned_abs();
                        (mantissa, scale)
                    } else {
                        let pow = 10i128.checked_pow(exponent as u32)
                            .ok_or("Decimal exponent overflow")?;
                        let scaled = mantissa.checked_mul(pow)
                            .ok_or("Decimal mantissa overflow")?;
                        (scaled, 0)
                    };
                    rust_decimal::Decimal::from_i128_with_scale(mantissa, scale)
                        .try_into()
                        .map_err(|_| "Decimal overflow")
                }
            }
            impl TryToSbe<#dec_ident> for rust_decimal::Decimal {
                type Error = &'static str;
                #[inline]
                fn try_to_sbe(&self) -> Result<#dec_ident, Self::Error> {
                    let mantissa: i64 = self.mantissa()
                        .try_into()
                        .map_err(|_| "Decimal mantissa overflow i64")?;
                    Ok(#dec_new_call)
                }
            }
        };
        out.push_str(&ts.to_string());
    }

    if has_chrono_conv {
        let ts = quote::quote! {
            impl TryFromSbe<u64> for chrono::DateTime<chrono::Utc> {
                type Error = &'static str;
                #[inline]
                fn try_from_sbe(wire: u64) -> Result<Self, Self::Error> {
                    let secs = (wire / 1_000_000_000) as i64;
                    let nsec = (wire % 1_000_000_000) as u32;
                    chrono::DateTime::from_timestamp(secs, nsec)
                        .ok_or("timestamp out of range for DateTime<Utc>")
                }
            }
            impl TryToSbe<u64> for chrono::DateTime<chrono::Utc> {
                type Error = &'static str;
                #[inline]
                fn try_to_sbe(&self) -> Result<u64, Self::Error> {
                    let total_nanos = self.timestamp_nanos_opt()
                        .ok_or("timestamp_nanos overflow")?;
                    u64::try_from(total_nanos)
                        .map_err(|_| "timestamp out of u64 range")
                }
            }
        };
        out.push_str(&ts.to_string());
    }

    out
}