Skip to main content

olai_codegen/parsing/
types.rs

1//! Type conversion utilities for code generation
2//!
3//! This module provides utilities for converting between protobuf types and Rust types
4//! during code generation.
5
6use convert_case::{Case, Casing};
7use proc_macro2::TokenStream;
8use quote::quote;
9
10use crate::codegen::Runtime;
11use crate::utils::{extract_simple_type_name, last_segment};
12
13/// Context for rendering types in different situations
14#[derive(Debug, Clone, Copy)]
15pub enum RenderContext {
16    /// A constructor (new method) in Rust
17    Constructor,
18    /// when extracting from a request inside implementations of FromRequest or FromRequestParts
19    /// (path params: renders enum as i32 for direct axum::extract::Path deserialization)
20    Extractor,
21    /// when building the QueryParams serde struct for query string deserialization
22    /// (renders enum as its actual Rust type so serde can deserialize from string variant names)
23    QueryExtractor,
24    /// Regular parameter type
25    Parameter,
26    /// Return type
27    ReturnType,
28    /// Field type in a struct
29    FieldType,
30    /// Builder method parameter
31    BuilderMethod,
32    /// Python parameter type (Rust FFI signatures for PyO3 bindings)
33    PythonParameter,
34    /// NAPI parameter type (Rust NAPI function signatures for Node.js bindings)
35    NapiParameter,
36}
37
38/// Convert a unified type to a Rust type string.
39///
40/// The `runtime` only selects the crate of the free-form JSON types ([`JsonType::rust_path`]).
41/// Otherwise the type is runtime-independent: under both prost and buffa, generated
42/// builder/constructor params and the query-deserialization struct use the bare enum type `E`
43/// (serde deserializes the variant-name string into it), and the runtime-specific wrapping
44/// (`as i32` / `EnumValue::Known`) is applied by [`field_assignment`].
45pub fn unified_to_rust(
46    unified_type: &UnifiedType,
47    context: RenderContext,
48    runtime: Runtime,
49) -> String {
50    let base_type_str = match &unified_type.base_type {
51        BaseType::String => {
52            // A repeated string stays a plain `Vec<String>`.
53            if matches!(context, RenderContext::Constructor)
54                && !unified_type.is_optional
55                && !unified_type.is_repeated
56            {
57                "impl Into<String>".to_string()
58            } else {
59                "String".to_string()
60            }
61        }
62        BaseType::Int32 => "i32".to_string(),
63        BaseType::Int64 => "i64".to_string(),
64        BaseType::Bool => "bool".to_string(),
65        BaseType::Float64 => "f64".to_string(),
66        BaseType::Float32 => "f32".to_string(),
67        BaseType::Bytes => "Vec<u8>".to_string(),
68        BaseType::Unit => "()".to_string(),
69        BaseType::Message(name) => match JsonType::of(name) {
70            Some(json) => json.rust_path(runtime).to_string(),
71            None => extract_simple_type_name(name),
72        },
73        BaseType::Enum(name) => {
74            if matches!(
75                context,
76                RenderContext::Extractor | RenderContext::NapiParameter
77            ) {
78                "i32".to_string()
79            } else {
80                convert_protobuf_enum_to_rust_type(name)
81            }
82        }
83        BaseType::OneOf(name) => extract_simple_type_name(name),
84        BaseType::Map(key_type, value_type) => {
85            if matches!(context, RenderContext::Constructor) && !unified_type.is_optional {
86                let value = match value_type.base_type {
87                    BaseType::String => "impl Into<String>".to_string(),
88                    _ => unified_to_rust(value_type, RenderContext::Parameter, runtime),
89                };
90                format!("impl IntoIterator<Item = (impl Into<String>, {value})>")
91            } else {
92                let key_str = unified_to_rust(key_type, context, runtime);
93                let value_str = unified_to_rust(value_type, context, runtime);
94                format!("HashMap<{key_str}, {value_str}>")
95            }
96        }
97    };
98
99    let mut result = base_type_str;
100
101    // In builder methods we require the inner type only.
102    if unified_type.is_repeated && !matches!(context, RenderContext::BuilderMethod) {
103        result = format!("Vec<{result}>");
104    }
105
106    if should_wrap_in_option(context, unified_type) {
107        result = format!("Option<{result}>");
108    }
109
110    result
111}
112
113/// Return `true` when the Rust representation of `ty` in `ctx` should be wrapped in `Option<T>`.
114///
115/// Two independent conditions trigger wrapping:
116///
117/// 1. **Optional field outside a builder method** — builder methods use `impl Into<Option<T>>`
118///    for their parameter type and leave the inner `Option<T>` implicit, so wrapping is skipped
119///    in that context.
120///
121/// 2. **FFI boundaries (Python / NAPI) with maps or repeated fields** — at FFI boundaries we
122///    distinguish "field absent" from "field is an empty collection" by wrapping maps and
123///    repeated fields in `Option<T>`, even when the field is not marked optional in proto.
124fn should_wrap_in_option(ctx: RenderContext, ty: &UnifiedType) -> bool {
125    let is_optional_non_builder = ty.is_optional && !matches!(ctx, RenderContext::BuilderMethod);
126    let is_ffi = matches!(
127        ctx,
128        RenderContext::PythonParameter | RenderContext::NapiParameter
129    );
130    // At FFI boundaries, collections (maps, repeated) are wrapped in `Option` so callers can
131    // distinguish "absent" from "empty". Message/oneof optionality is carried by `ty.is_optional`
132    // (set during analysis from the field's `REQUIRED` behavior), so a required message body
133    // renders as a bare `T` required param while an optional one renders as `Option<T>`.
134    let is_ffi_collection =
135        is_ffi && (matches!(ty.base_type, BaseType::Map(_, _)) || ty.is_repeated);
136    is_optional_non_builder || is_ffi_collection
137}
138
139/// Generate field assignment code.
140///
141/// Wraps a constructor/builder parameter so it can be stored into the request struct field.
142/// `runtime` selects the ABI of the protobuf library the generated code consumes:
143///
144/// - [`Runtime::Prost`]: open enums are `i32` (`#field as i32`); singular message/oneof
145///   fields are `Option<Box<T>>` (`Some(#field)`).
146/// - [`Runtime::Buffa`]: open enums are `EnumValue<E>` (`buffa::EnumValue::Known(#field)`);
147///   singular message/oneof fields are `MessageField<T>` (`buffa::MessageField::some(#field)`).
148pub fn field_assignment(
149    unified_type: &UnifiedType,
150    field_ident: &proc_macro2::Ident,
151    ctx: &RenderContext,
152    runtime: Runtime,
153) -> TokenStream {
154    if matches!(ctx, RenderContext::BuilderMethod) {
155        return flexible_optional_field_assignment(unified_type, field_ident, runtime);
156    }
157    match &unified_type.base_type {
158        // A single string is an `impl Into<String>`; a repeated one is already `Vec<String>`.
159        BaseType::String if !unified_type.is_optional && !unified_type.is_repeated => {
160            quote! { #field_ident.into() }
161        }
162        BaseType::Enum(_) => match runtime {
163            Runtime::Prost if unified_type.is_repeated => {
164                quote! { #field_ident.into_iter().map(|v| v as i32).collect() }
165            }
166            Runtime::Prost => quote! { #field_ident as i32 },
167            Runtime::Buffa if unified_type.is_repeated => {
168                quote! { #field_ident.into_iter().map(buffa::EnumValue::Known).collect() }
169            }
170            Runtime::Buffa => quote! { buffa::EnumValue::Known(#field_ident) },
171        },
172        // Only string map values go through `.into()`; others already have the field's type.
173        BaseType::Map(_, value)
174            if matches!(ctx, RenderContext::Constructor)
175                && !matches!(value.base_type, BaseType::String) =>
176        {
177            quote! { #field_ident.into_iter().map(|(k, v)| (k.into(), v)).collect() }
178        }
179        BaseType::Map(_, _) => quote! {
180            #field_ident.into_iter().map(|(k, v)| (k.into(), v.into())).collect()
181        },
182        // Singular message/oneof fields wrap the bare `T` constructor param into the request
183        // struct's optional-message representation. (Repeated messages are `Vec<T>`, not
184        // optional, so they fall through unchanged.) Oneof fields are `Option<Oneof>` under both
185        // runtimes, so they always take the `Some(..)` form regardless of `runtime`.
186        BaseType::OneOf(_) if !unified_type.is_repeated => {
187            quote! { Some(#field_ident) }
188        }
189        BaseType::Message(_) if !unified_type.is_repeated => match runtime {
190            // prost: `Option<Box<T>>`
191            Runtime::Prost => quote! { Some(#field_ident) },
192            // buffa: `MessageField<T>`
193            Runtime::Buffa => quote! { buffa::MessageField::some(#field_ident) },
194        },
195        _ => quote! { #field_ident },
196    }
197}
198
199/// Convert a unified type to a Rust NAPI parameter type string.
200///
201/// NAPI requires concrete types (no `impl Into<>`). Optional fields
202/// become `Option<T>`, maps become `Option<HashMap<K, V>>`.
203pub fn unified_to_napi(unified_type: &UnifiedType) -> String {
204    let base_type_str = match &unified_type.base_type {
205        BaseType::String => "String".to_string(),
206        BaseType::Int32 => "i32".to_string(),
207        BaseType::Int64 => "i64".to_string(),
208        BaseType::Bool => "bool".to_string(),
209        BaseType::Float64 => "f64".to_string(),
210        BaseType::Float32 => "f32".to_string(),
211        BaseType::Bytes => "Vec<u8>".to_string(),
212        BaseType::Unit => "()".to_string(),
213        BaseType::Message(name) => extract_simple_type_name(name),
214        BaseType::Enum(name) => convert_protobuf_enum_to_rust_type(name),
215        BaseType::OneOf(name) => extract_simple_type_name(name),
216        BaseType::Map(key_type, value_type) => {
217            let key_str = unified_to_napi(key_type);
218            let value_str = unified_to_napi(value_type);
219            format!("HashMap<{key_str}, {value_str}>")
220        }
221    };
222
223    let mut result = base_type_str;
224
225    if unified_type.is_repeated {
226        result = format!("Vec<{result}>");
227    }
228
229    if unified_type.is_optional
230        || matches!(unified_type.base_type, BaseType::Map(_, _))
231        || unified_type.is_repeated
232    {
233        result = format!("Option<{result}>");
234    }
235
236    result
237}
238
239/// Convert a unified type to a TypeScript type annotation string.
240///
241/// Enums are mapped to `number` since they cross the NAPI boundary as `i32`.
242pub fn unified_to_typescript(unified_type: &UnifiedType) -> String {
243    let base_type_str = match &unified_type.base_type {
244        BaseType::String => "string".to_string(),
245        BaseType::Int32 | BaseType::Int64 => "number".to_string(),
246        BaseType::Bool => "boolean".to_string(),
247        BaseType::Float64 | BaseType::Float32 => "number".to_string(),
248        BaseType::Bytes => "Uint8Array".to_string(),
249        BaseType::Unit => "void".to_string(),
250        BaseType::Message(name) => extract_simple_type_name(name),
251        BaseType::Enum(_) => "number".to_string(),
252        BaseType::OneOf(name) => extract_simple_type_name(name),
253        BaseType::Map(key_type, value_type) => {
254            let key_str = unified_to_typescript(key_type);
255            let value_str = unified_to_typescript(value_type);
256            format!("Record<{key_str}, {value_str}>")
257        }
258    };
259
260    let mut result = base_type_str;
261
262    if unified_type.is_repeated {
263        result = format!("{result}[]");
264    }
265
266    if unified_type.is_optional {
267        result = format!("{result} | undefined");
268    }
269
270    result
271}
272
273fn flexible_optional_field_assignment(
274    unified_type: &UnifiedType,
275    field_ident: &proc_macro2::Ident,
276    runtime: Runtime,
277) -> TokenStream {
278    if unified_type.is_optional {
279        match (&unified_type.base_type, runtime) {
280            (BaseType::Enum(_), Runtime::Prost) => quote! { #field_ident.into().map(|e| e as i32) },
281            (BaseType::Enum(_), Runtime::Buffa) => {
282                quote! { #field_ident.into().map(buffa::EnumValue::Known) }
283            }
284            // buffa stores singular message/oneof fields as `MessageField<T>`. The
285            // setter arg is `impl Into<Option<T>>`, so resolve to `Option<T>` first
286            // (annotating the intermediate disambiguates the chained `.into()`), then
287            // convert via `MessageField: From<Option<T>>`.
288            (BaseType::Message(_) | BaseType::OneOf(_), Runtime::Buffa) => quote! {
289                {
290                    let #field_ident: ::core::option::Option<_> = #field_ident.into();
291                    buffa::MessageField::from(#field_ident)
292                }
293            },
294            _ => quote! { #field_ident.into() },
295        }
296    } else {
297        match (&unified_type.base_type, runtime) {
298            (
299                BaseType::String
300                | BaseType::Int32
301                | BaseType::Int64
302                | BaseType::Bool
303                | BaseType::Float64
304                | BaseType::Float32,
305                _,
306            ) => quote! { #field_ident.into() },
307            (BaseType::Enum(_), Runtime::Prost) => quote! { #field_ident as i32 },
308            (BaseType::Enum(_), Runtime::Buffa) => quote! { buffa::EnumValue::Known(#field_ident) },
309            // Message, OneOf, and Map types are always handled by their own
310            // dedicated branches in `builder_with_impl` and never reach this path.
311            _ => quote! { #field_ident },
312        }
313    }
314}
315
316/// Convert protobuf enum type to Rust type
317///
318/// # FIXME: Fragile heuristic for nested vs package-level enums
319///
320/// This function guesses whether an enum is nested inside a message (e.g.
321/// `example.Catalog.Status`) or defined at package level (e.g.
322/// `example.catalog.v1.CatalogType`) by inspecting the casing of the last
323/// path segment before the enum name.  The special-case for `"V1"` and the
324/// all-lowercase check are workarounds for common proto package naming
325/// conventions, not a principled solution.
326///
327/// The correct fix (Phase 3): accept a `package_prefix: &str` parameter so
328/// callers can strip the known package prefix and always identify the boundary
329/// between package segments and message/enum names deterministically.
330pub(crate) fn convert_protobuf_enum_to_rust_type(enum_name: &str) -> String {
331    let enum_name = enum_name.trim_start_matches('.');
332    let Some((parent_part, enum_simple_name)) = enum_name.rsplit_once('.') else {
333        return convert_enum_name_to_rust(enum_name);
334    };
335    let enum_rust_name = convert_enum_name_to_rust(enum_simple_name);
336
337    // A nested enum's parent is a PascalCase message name; a package-level enum's is a package
338    // segment like `v1`.
339    let parent = last_segment(parent_part);
340    let is_nested = parent.starts_with(char::is_uppercase)
341        && parent != "V1"
342        && !parent.chars().all(|c| c.is_lowercase() || c.is_numeric());
343    if is_nested {
344        // The parent message becomes its snake_case module.
345        format!("{}::{enum_rust_name}", parent.to_case(Case::Snake))
346    } else {
347        enum_rust_name
348    }
349}
350
351/// Convert enum name from proto format to Rust format
352fn convert_enum_name_to_rust(enum_name: &str) -> String {
353    // Convert from proto naming to Rust naming if needed
354    if enum_name.contains('_') && enum_name.chars().all(|c| c.is_uppercase() || c == '_') {
355        enum_name.to_case(Case::Pascal)
356    } else {
357        enum_name.to_string()
358    }
359}
360
361/// A language-neutral description of a protobuf field's type.
362///
363/// `UnifiedType` is the bridge between parsing and generation: a proto field's type and
364/// cardinality are normalized into this single shape once, then each language backend renders it
365/// with its own function ([`unified_to_rust`], [`unified_to_napi`], [`unified_to_typescript`]). The [`base_type`](Self::base_type) carries
366/// the underlying category while [`is_optional`](Self::is_optional) and
367/// [`is_repeated`](Self::is_repeated) describe the cardinality wrapping applied around it.
368#[derive(Debug, Clone)]
369pub struct UnifiedType {
370    /// The underlying type category.
371    pub base_type: BaseType,
372    /// Whether this type is optional (`Option<T>` in Rust).
373    pub is_optional: bool,
374    /// Whether this type is repeated (`Vec<T>` in Rust).
375    pub is_repeated: bool,
376}
377
378/// A free-form JSON type of `google/protobuf/struct.proto`: the runtime's well-known type in
379/// Rust, a plain object in Python.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub enum JsonType {
382    /// `google.protobuf.Value`: any JSON value.
383    Value,
384    /// `google.protobuf.Struct`: a JSON object.
385    Struct,
386    /// `google.protobuf.ListValue`: a JSON array.
387    ListValue,
388}
389
390impl JsonType {
391    /// The JSON type of the proto message `name`, if it is one.
392    pub fn of(name: &str) -> Option<Self> {
393        match name.trim_start_matches('.') {
394            "google.protobuf.Value" => Some(Self::Value),
395            "google.protobuf.Struct" => Some(Self::Struct),
396            "google.protobuf.ListValue" => Some(Self::ListValue),
397            _ => None,
398        }
399    }
400
401    /// The JSON type of a field type: the type itself, or the value type of a map.
402    pub fn of_field(ty: &UnifiedType) -> Option<Self> {
403        match &ty.base_type {
404            BaseType::Message(name) => Self::of(name),
405            BaseType::Map(_, value) => Self::of_field(value),
406            _ => None,
407        }
408    }
409
410    /// The path of the type in the `runtime`'s well-known-types crate.
411    pub fn rust_path(self, runtime: Runtime) -> &'static str {
412        match (runtime, self) {
413            (Runtime::Buffa, Self::Value) => "::buffa_types::google::protobuf::Value",
414            (Runtime::Buffa, Self::Struct) => "::buffa_types::google::protobuf::Struct",
415            (Runtime::Buffa, Self::ListValue) => "::buffa_types::google::protobuf::ListValue",
416            (Runtime::Prost, Self::Value) => "::prost_types::Value",
417            (Runtime::Prost, Self::Struct) => "::prost_types::Struct",
418            (Runtime::Prost, Self::ListValue) => "::prost_types::ListValue",
419        }
420    }
421
422    /// The suffix of the type's conversion functions: `value`, `struct`, `list`.
423    pub fn fn_suffix(self) -> &'static str {
424        match self {
425            Self::Value => "value",
426            Self::Struct => "struct",
427            Self::ListValue => "list",
428        }
429    }
430}
431
432/// The underlying category of a [`UnifiedType`], independent of optional/repeated wrapping.
433///
434/// Scalars map directly to a primitive in each target language; the named variants
435/// ([`Message`](Self::Message), [`Enum`](Self::Enum), [`OneOf`](Self::OneOf)) carry the
436/// fully-qualified protobuf type name, from which renderers take the final segment.
437#[derive(Debug, Clone)]
438pub enum BaseType {
439    /// String type
440    String,
441    /// 32-bit integer
442    Int32,
443    /// 64-bit integer
444    Int64,
445    /// Boolean
446    Bool,
447    /// 64-bit float
448    Float64,
449    /// 32-bit float
450    Float32,
451    /// Byte array
452    Bytes,
453    /// Protobuf message type
454    Message(String),
455    /// Protobuf enum type
456    Enum(String),
457    /// Protobuf oneof type
458    OneOf(String),
459    /// Map type with key and value types
460    Map(Box<UnifiedType>, Box<UnifiedType>),
461    /// Unit/void type
462    Unit,
463}
464
465impl UnifiedType {
466    /// Extract a syn::Ident for the inner type name (last segment of a qualified name).
467    pub fn type_ident(&self) -> syn::Ident {
468        let name = match &self.base_type {
469            BaseType::Message(n) | BaseType::Enum(n) | BaseType::OneOf(n) => last_segment(n),
470            BaseType::String => "String",
471            BaseType::Int32 => "i32",
472            BaseType::Int64 => "i64",
473            BaseType::Bool => "bool",
474            BaseType::Float64 => "f64",
475            BaseType::Float32 => "f32",
476            BaseType::Bytes => "Bytes",
477            BaseType::Unit => "()",
478            BaseType::Map(_, _) => "HashMap",
479        };
480        quote::format_ident!("{}", name)
481    }
482
483    /// A singular `bytes` value: not repeated.
484    pub fn is_singular_bytes(&self) -> bool {
485        matches!(self.base_type, BaseType::Bytes) && !self.is_repeated
486    }
487
488    /// Create a simple string type
489    pub fn string() -> Self {
490        Self {
491            base_type: BaseType::String,
492            is_optional: false,
493            is_repeated: false,
494        }
495    }
496
497    /// Create an optional version of this type
498    pub fn optional(mut self) -> Self {
499        self.is_optional = true;
500        self
501    }
502
503    /// Create a repeated version of this type
504    pub fn repeated(mut self) -> Self {
505        self.is_repeated = true;
506        self
507    }
508
509    /// Create a map type
510    pub fn map(key: UnifiedType, value: UnifiedType) -> Self {
511        Self {
512            base_type: BaseType::Map(Box::new(key), Box::new(value)),
513            is_optional: false,
514            is_repeated: false,
515        }
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn json_types_render_as_the_runtime_well_known_type() {
525        let structs = UnifiedType::map(
526            UnifiedType::string(),
527            UnifiedType {
528                base_type: BaseType::Message(".google.protobuf.Struct".to_string()),
529                is_optional: false,
530                is_repeated: false,
531            },
532        );
533        assert_eq!(
534            unified_to_rust(&structs, RenderContext::Parameter, Runtime::Prost),
535            "HashMap<String, ::prost_types::Struct>"
536        );
537        assert_eq!(
538            unified_to_rust(&structs, RenderContext::Parameter, Runtime::Buffa),
539            "HashMap<String, ::buffa_types::google::protobuf::Struct>"
540        );
541    }
542}