Skip to main content

krabka_protocol_codegen/emit/
owned.rs

1//! Emit Rust source for the owned flavor of a `MessageSpec`.
2//!
3//! This module handles primitive fields, tagged fields, primitive arrays, and
4//! nested struct fields. Nested anonymous structs become sibling types in the
5//! same generated file. The module also supports `commonStructs`.
6
7use std::collections::HashMap;
8
9use proc_macro2::{Literal, TokenStream};
10use quote::{format_ident, quote};
11
12use crate::{
13    emit::common::format_int_literal,
14    ir::{FieldSpec, FlexibleVersions, MessageSpec, MessageType, VersionRange},
15    name_conv,
16    resolve::{self, Resolution},
17};
18
19#[derive(Debug, thiserror::Error)]
20pub enum EmitError {
21    #[error("unsupported (in 1a): {0}")]
22    Unsupported(String),
23    #[error("resolve error: {0}")]
24    Resolve(#[from] resolve::ResolveError),
25    #[error("an emitter produced the unlexable fragment `{fragment}`: {message}")]
26    Lex { fragment: String, message: String },
27    #[error("the generated {what} is not valid Rust: {source}")]
28    Syntax {
29        what: &'static str,
30        #[source]
31        source: syn::Error,
32    },
33    #[error("{0} must carry an apiKey in its schema")]
34    MissingApiKey(String),
35    #[error("tagged field `{0}` has no tag")]
36    MissingTag(String),
37    #[error("struct field `{0}` does not resolve to a type")]
38    Unresolved(String),
39}
40
41/// Build the `use` items for a common-struct file body.
42///
43/// This function replicates the import selection the former
44/// `emit_common_struct_file` used. It differs from `emit_imports` in one way:
45/// it has NO records-import block, because common structs never carry
46/// `records` fields.
47pub(crate) fn emit_common_imports(fields: &[FieldSpec], flex_min_val: i16) -> TokenStream {
48    let types = used_field_types_recursive(fields);
49    let has_flex = flex_min_val < i16::MAX;
50    let tagged = fields.iter().any(|f| f.tag.is_some());
51    let use_string = uses_string(&types);
52    let use_bytes = uses_bytes(&types);
53    let use_nullable_struct = uses_nullable_struct_recursive(fields);
54
55    let mut out = quote!(
56        use bytes::{Buf, BufMut};
57    );
58
59    {
60        let mut gets: Vec<&str> = Vec::new();
61        let mut puts: Vec<&str> = Vec::new();
62        for (t, g, p) in &[
63            ("int8", "get_i8", "put_i8"),
64            ("int16", "get_i16", "put_i16"),
65            ("uint16", "get_u16", "put_u16"),
66            ("int32", "get_i32", "put_i32"),
67            ("int64", "get_i64", "put_i64"),
68            ("bool", "get_bool", "put_bool"),
69            ("float64", "get_f64", "put_f64"),
70        ] {
71            if uses_fixed_type(&types, t) {
72                gets.push(g);
73                puts.push(p);
74            }
75        }
76        if use_nullable_struct && !gets.contains(&"get_i8") {
77            // get_i8 needed for nullable struct decode prefix even when no int8 fields.
78            gets.push("get_i8");
79        }
80        if !gets.is_empty() {
81            let mut combined: Vec<&str> = gets.into_iter().chain(puts).collect();
82            combined.sort_unstable();
83            combined.dedup();
84            let items: Vec<_> = combined.iter().map(|n| format_ident!("{n}")).collect();
85            out.extend(quote!(use crate::primitives::fixed::{ #(#items),* };));
86        }
87    }
88
89    if use_string {
90        out.extend(string_import(uses_nullable_string_recursive(fields)));
91    }
92
93    if use_bytes {
94        out.extend(bytes_import(
95            uses_non_nullable_bytes_recursive(fields),
96            uses_nullable_bytes_recursive(fields),
97        ));
98    }
99
100    out.extend(tagged_import(has_flex, tagged));
101
102    out.extend(quote!(
103        use crate::{Decode, Encode, ProtocolError, UnknownTaggedFields};
104    ));
105    out
106}
107
108/// The `string_bytes` import list for string fields. The nullable variant also
109/// pulls in the nullable-string helpers.
110fn string_import(use_nullable_string: bool) -> TokenStream {
111    let names: &[&str] = if use_nullable_string {
112        &[
113            "compact_nullable_string_len",
114            "compact_string_len",
115            "get_compact_nullable_string_owned",
116            "get_compact_string_owned",
117            "get_nullable_string_owned",
118            "get_string_owned",
119            "nullable_string_len",
120            "put_compact_nullable_string",
121            "put_compact_string",
122            "put_nullable_string",
123            "put_string",
124            "string_len",
125        ]
126    } else {
127        &[
128            "compact_string_len",
129            "get_compact_string_owned",
130            "get_string_owned",
131            "put_compact_string",
132            "put_string",
133            "string_len",
134        ]
135    };
136    let items: Vec<_> = names.iter().map(|n| format_ident!("{n}")).collect();
137    quote!(use crate::primitives::string_bytes::{ #(#items),* };)
138}
139
140/// The `string_bytes` import list for `bytes` fields.
141fn bytes_import(use_non_nullable_bytes: bool, use_nullable_bytes: bool) -> TokenStream {
142    let mut items: Vec<&str> = Vec::new();
143    if use_non_nullable_bytes {
144        items.extend([
145            "bytes_len",
146            "compact_bytes_len",
147            "get_bytes_owned",
148            "get_compact_bytes_owned",
149            "put_bytes",
150            "put_compact_bytes",
151        ]);
152    }
153    if use_nullable_bytes {
154        items.extend([
155            "compact_nullable_bytes_len",
156            "get_compact_nullable_bytes_owned",
157            "get_nullable_bytes_owned",
158            "nullable_bytes_len",
159            "put_compact_nullable_bytes",
160            "put_nullable_bytes",
161        ]);
162    }
163    items.sort_unstable();
164    let idents: Vec<_> = items.iter().map(|n| format_ident!("{n}")).collect();
165    quote!(use crate::primitives::string_bytes::{ #(#idents),* };)
166}
167
168/// The `tagged_fields` import line. This function pulls in `encode_to_bytes`
169/// only when there are known tagged fields to encode.
170fn tagged_import(flex: bool, tagged: bool) -> TokenStream {
171    if flex && tagged {
172        quote!(
173            use crate::tagged_fields::{
174                encode_to_bytes, read_tagged_fields, tagged_fields_len, WriteTaggedFields,
175            };
176        )
177    } else if flex {
178        quote!(
179            use crate::tagged_fields::{read_tagged_fields, tagged_fields_len, WriteTaggedFields};
180        )
181    } else {
182        quote!()
183    }
184}
185
186pub(crate) fn flex_min(spec: &MessageSpec) -> i16 {
187    match spec.flexible_versions {
188        FlexibleVersions::Range(r) => r.min,
189        FlexibleVersions::None => i16::MAX,
190    }
191}
192
193pub(crate) fn base_type(t: &str) -> &str {
194    t.strip_prefix("[]").unwrap_or(t)
195}
196
197/// Collect the set of primitive schema types that non-tagged fields actually
198/// use, so the emitter writes only the imports it needs.
199fn used_field_types_recursive(fields: &[FieldSpec]) -> Vec<String> {
200    let mut types: Vec<String> = Vec::new();
201    for f in fields {
202        let base = base_type(&f.field_type).to_owned();
203        if !types.contains(&base) {
204            types.push(base);
205        }
206        if !f.fields.is_empty() {
207            for t in used_field_types_recursive(&f.fields) {
208                if !types.contains(&t) {
209                    types.push(t);
210                }
211            }
212        }
213    }
214    types
215}
216
217fn has_tagged_fields_recursive(fields: &[FieldSpec]) -> bool {
218    fields
219        .iter()
220        .any(|f| f.tag.is_some() || has_tagged_fields_recursive(&f.fields))
221}
222
223/// Returns true if any field, at any depth, is `float64`.
224/// `f64` does not implement `Eq`, so structs with `float64` fields must not
225/// derive `Eq`.
226pub(crate) fn has_float64_recursive(fields: &[FieldSpec]) -> bool {
227    fields.iter().any(|f| {
228        let base = base_type(&f.field_type);
229        base == "float64" || has_float64_recursive(&f.fields)
230    })
231}
232
233fn uses_fixed_type(types: &[String], t: &str) -> bool {
234    types.iter().any(|s| s == t)
235}
236
237fn uses_string(types: &[String]) -> bool {
238    types.iter().any(|t| t == "string")
239}
240
241fn uses_bytes(types: &[String]) -> bool {
242    types.iter().any(|t| t.as_str() == "bytes")
243}
244
245fn uses_nullable_bytes_recursive(fields: &[FieldSpec]) -> bool {
246    fields.iter().any(|f| {
247        let base = base_type(&f.field_type);
248        let here = base == "bytes" && f.nullable_versions.is_some();
249        here || uses_nullable_bytes_recursive(&f.fields)
250    })
251}
252
253fn uses_non_nullable_bytes_recursive(fields: &[FieldSpec]) -> bool {
254    fields.iter().any(|f| {
255        let base = base_type(&f.field_type);
256        let here = base == "bytes" && needs_non_nullable_codec(f);
257        here || uses_non_nullable_bytes_recursive(&f.fields)
258    })
259}
260
261/// True if a field needs the non-nullable codec for at least some versions.
262/// That happens when the field is never nullable, or when its nullable range
263/// is narrower than its own version range, so a per-version split emits a
264/// non-nullable branch.
265fn needs_non_nullable_codec(f: &FieldSpec) -> bool {
266    f.nullable_versions.is_none() || nullable_split_cond(f).is_some()
267}
268
269fn uses_nullable_records_recursive(fields: &[FieldSpec]) -> bool {
270    fields.iter().any(|f| {
271        let base = base_type(&f.field_type);
272        let here = base == "records" && f.nullable_versions.is_some();
273        here || uses_nullable_records_recursive(&f.fields)
274    })
275}
276
277fn uses_non_nullable_records_recursive(fields: &[FieldSpec]) -> bool {
278    fields.iter().any(|f| {
279        let base = base_type(&f.field_type);
280        let here = base == "records" && needs_non_nullable_codec(f);
281        here || uses_non_nullable_records_recursive(&f.fields)
282    })
283}
284
285/// Returns true if any field, at any depth, has a string type that is also
286/// nullable.
287fn uses_nullable_string_recursive(fields: &[FieldSpec]) -> bool {
288    fields.iter().any(|f| {
289        let base = base_type(&f.field_type);
290        let here = base == "string" && (f.nullable_versions.is_some() || f.tag.is_some());
291        here || uses_nullable_string_recursive(&f.fields)
292    })
293}
294
295/// Returns true if any field has a non-array struct type with nullableVersions
296/// set. Such fields need `get_i8` for the nullable prefix byte in decode.
297fn uses_nullable_struct_recursive(fields: &[FieldSpec]) -> bool {
298    fields.iter().any(|f| {
299        let t = &f.field_type;
300        let base = base_type(t);
301        let here = is_struct_type(base) && !t.starts_with("[]") && f.nullable_versions.is_some();
302        here || uses_nullable_struct_recursive(&f.fields)
303    })
304}
305
306pub(crate) fn has_any_flex(spec: &MessageSpec) -> bool {
307    matches!(spec.flexible_versions, FlexibleVersions::Range(_))
308}
309
310fn has_any_tagged_in_spec(spec: &MessageSpec) -> bool {
311    has_tagged_fields_recursive(&spec.fields)
312}
313
314pub(crate) fn emit_imports(spec: &MessageSpec) -> TokenStream {
315    let types = used_field_types_recursive(&spec.fields);
316    let tagged = has_any_tagged_in_spec(spec);
317    let flex = has_any_flex(spec);
318    let use_string = uses_string(&types);
319    let use_bytes = uses_bytes(&types);
320    let use_nullable_string = uses_nullable_string_recursive(&spec.fields);
321    let use_nullable_bytes = uses_nullable_bytes_recursive(&spec.fields);
322    let use_non_nullable_bytes = uses_non_nullable_bytes_recursive(&spec.fields);
323
324    let mut out = quote!(
325        use bytes::{Buf, BufMut};
326    );
327
328    let use_nullable_struct = uses_nullable_struct_recursive(&spec.fields);
329
330    // Emit only the specific fixed-type imports actually used, to avoid unused-import warnings.
331    {
332        let mut gets: Vec<&str> = Vec::new();
333        let mut puts: Vec<&str> = Vec::new();
334        if uses_fixed_type(&types, "int8") {
335            gets.push("get_i8");
336            puts.push("put_i8");
337        } else if use_nullable_struct {
338            // get_i8 is needed for nullable struct decode (1-byte signed null prefix).
339            gets.push("get_i8");
340        }
341        if uses_fixed_type(&types, "int16") {
342            gets.push("get_i16");
343            puts.push("put_i16");
344        }
345        if uses_fixed_type(&types, "uint16") {
346            gets.push("get_u16");
347            puts.push("put_u16");
348        }
349        if uses_fixed_type(&types, "int32") {
350            gets.push("get_i32");
351            puts.push("put_i32");
352        }
353        if uses_fixed_type(&types, "int64") {
354            gets.push("get_i64");
355            puts.push("put_i64");
356        }
357        if uses_fixed_type(&types, "bool") {
358            gets.push("get_bool");
359            puts.push("put_bool");
360        }
361        if uses_fixed_type(&types, "float64") {
362            gets.push("get_f64");
363            puts.push("put_f64");
364        }
365        if !gets.is_empty() {
366            let combined: Vec<&str> = gets.into_iter().chain(puts).collect();
367            let sorted = {
368                let mut v = combined;
369                v.sort_unstable();
370                v
371            };
372            let items: Vec<_> = sorted.iter().map(|n| format_ident!("{n}")).collect();
373            out.extend(quote!(use crate::primitives::fixed::{ #(#items),* };));
374        }
375    }
376
377    if use_string {
378        // Emit only the string helpers that are actually needed for the fields
379        // present in this message to avoid unused-import warnings.
380        out.extend(string_import(use_nullable_string));
381    }
382
383    if use_bytes {
384        out.extend(bytes_import(use_non_nullable_bytes, use_nullable_bytes));
385    }
386
387    // Records fields: import the byte-primitive helpers used in the generated wrapper code.
388    {
389        let use_nullable_records = uses_nullable_records_recursive(&spec.fields);
390        let use_non_nullable_records = uses_non_nullable_records_recursive(&spec.fields);
391        if use_nullable_records || use_non_nullable_records {
392            // Note: compact_bytes_len_from_size is used via fully-qualified path in generated code.
393            let mut items: Vec<&str> = Vec::new();
394            if use_non_nullable_records {
395                items.extend([
396                    "get_bytes_owned",
397                    "get_compact_bytes_owned",
398                    "put_bytes",
399                    "put_compact_bytes",
400                ]);
401            }
402            if use_nullable_records {
403                items.extend([
404                    "get_compact_nullable_bytes_owned",
405                    "get_nullable_bytes_owned",
406                    "put_compact_bytes",
407                    "put_compact_nullable_bytes",
408                    "put_bytes",
409                    "put_nullable_bytes",
410                ]);
411            }
412            items.sort_unstable();
413            items.dedup();
414            let idents: Vec<_> = items.iter().map(|n| format_ident!("{n}")).collect();
415            out.extend(quote!(use crate::primitives::string_bytes::{ #(#idents),* };));
416        }
417    }
418
419    // Tagged-fields support: encode_to_bytes only when there are known tagged fields to encode.
420    out.extend(tagged_import(flex, tagged));
421
422    out.extend(quote!(
423        use crate::{Decode, Encode, ProtocolError, UnknownTaggedFields};
424    ));
425    out
426}
427
428pub(crate) fn emit_constants(spec: &MessageSpec) -> Result<TokenStream, EmitError> {
429    let min_version = Literal::i16_unsuffixed(spec.valid_versions.min);
430    let max_version = Literal::i16_unsuffixed(spec.valid_versions.max);
431    let flex_minimum = flex_min(spec);
432    let flex = Literal::i16_unsuffixed(flex_minimum);
433    let flex_check = match flex_minimum {
434        i16::MIN => quote!(true),
435        i16::MAX => quote!(version == FLEXIBLE_MIN),
436        _ => quote!(version >= FLEXIBLE_MIN),
437    };
438    // Request/Response schemas have an API key; Header/Data schemas do not.
439    let api_key_const = match spec.message_type {
440        MessageType::Request | MessageType::Response => {
441            let api_key = spec
442                .api_key
443                .map(Literal::i16_unsuffixed)
444                .ok_or_else(|| EmitError::MissingApiKey(spec.name.clone()))?;
445            quote!(pub const API_KEY: i16 = #api_key;)
446        }
447        // No API_KEY const for framing/data types.
448        MessageType::Header | MessageType::Data => quote!(),
449    };
450    // Only a request can mark its latest version unstable. See
451    // `MessageSpec::latest_stable_version`.
452    let latest_stable_const = match spec.message_type {
453        MessageType::Request => {
454            let latest_stable = Literal::i16_unsuffixed(spec.latest_stable_version());
455            quote!(pub const LATEST_STABLE_VERSION: i16 = #latest_stable;)
456        }
457        MessageType::Response | MessageType::Header | MessageType::Data => quote!(),
458    };
459    Ok(quote! {
460        #api_key_const
461        pub const MIN_VERSION: i16 = #min_version;
462        pub const MAX_VERSION: i16 = #max_version;
463        #latest_stable_const
464        pub const FLEXIBLE_MIN: i16 = #flex;
465
466        #[inline]
467        #[must_use]
468        pub fn is_flexible(version: i16) -> bool { #flex_check }
469    })
470}
471
472/// Returns a Rust expression for the default value of an owned field. It
473/// respects the schema-level `default` attribute, for example `"-1"` for
474/// `ControllerId`.
475///
476/// A nullable field with no explicit `"default"` in its schema gets the
477/// *empty* value of its type wrapped in `Some`, matching Kafka's Java message
478/// generator (`FieldSpec.fieldDefaultToJava`): an empty array, an empty
479/// string, or a default-constructed nested struct. Only a field whose schema
480/// explicitly says `"default": "null"` defaults to `None`.
481pub(crate) fn owned_default_expr(f: &FieldSpec, res_map: &HashMap<String, Resolution>) -> String {
482    let base = base_type(&f.field_type);
483    let is_array = f.field_type.starts_with("[]");
484    let nullable = is_nullable(f) || matches!(&f.default, Some(serde_json::Value::Null));
485    // Kafka schemas use "null" (string) to mean the default is null for nullable fields.
486    let default_is_null = matches!(&f.default, Some(serde_json::Value::Null))
487        || matches!(&f.default, Some(serde_json::Value::String(s)) if s == "null");
488
489    // Kafka's `FieldSpec.fieldDefaultToJava` hardcodes `null` as the default
490    // for `records` fields unconditionally, regardless of any explicit
491    // schema default. This is a permanent special case, not subject to the
492    // general nullable-default-value rule below. It only applies here when
493    // the field is nullable (`Option<T>` on the Rust side); a `records`
494    // field with no `nullableVersions` (e.g.
495    // `FetchSnapshotResponse.UnalignedRecords`) has no `Option` to put
496    // `None` into, and keeps the empty-value default, matching how Kafka's
497    // Java field (always a plain, nullable-by-language reference) still
498    // requires the caller to set a real value before encoding such a field.
499    if base == "records" && nullable {
500        return "None".into();
501    }
502
503    if nullable && default_is_null {
504        return "None".into();
505    }
506
507    let inner = if is_array {
508        "Vec::new()".into()
509    } else if f.default.is_none()
510        && let Some(resolution) = res_map.get(base)
511    {
512        format!("{}::default()", resolution.rust_path)
513    } else {
514        f.default
515            .as_ref()
516            .map_or_else(|| owned_zero(base), |v| scalar_owned_default(base, v))
517    };
518
519    if nullable {
520        format!("Some({inner})")
521    } else {
522        inner
523    }
524}
525
526fn scalar_owned_default(base_type: &str, val: &serde_json::Value) -> String {
527    // Kafka schema defaults are always stored as JSON strings (e.g., "-1", "null", "true").
528    // We parse the string value to extract the actual default.
529    match (base_type, val) {
530        // String-encoded "null" for a nullable field means None — handled by caller.
531        (_, serde_json::Value::String(s)) if s == "null" => "None".into(),
532        ("string", serde_json::Value::String(s)) if s.is_empty() => "String::new()".into(),
533        ("string", serde_json::Value::String(s)) => format!("{s:?}.to_owned()"),
534        ("bool", serde_json::Value::String(s)) if s == "true" => "true".into(),
535        ("bool", serde_json::Value::String(_)) => "false".into(),
536        ("bool", serde_json::Value::Bool(b)) => b.to_string(),
537        ("int8", serde_json::Value::String(s)) => format!("{}i8", s.trim()),
538        ("int16", serde_json::Value::String(s)) => format!("{}i16", s.trim()),
539        // Format as underscored literal to satisfy clippy::unreadable_literal.
540        ("int32", serde_json::Value::String(s)) => format_int_literal(s.trim(), "i32"),
541        ("int64", serde_json::Value::String(s)) => format_int_literal(s.trim(), "i64"),
542        ("int8", serde_json::Value::Number(n)) => format!("{n}i8"),
543        ("int16", serde_json::Value::Number(n)) => format!("{n}i16"),
544        ("int32", serde_json::Value::Number(n)) => format_int_literal(&n.to_string(), "i32"),
545        ("int64", serde_json::Value::Number(n)) => format_int_literal(&n.to_string(), "i64"),
546        _ => owned_zero(base_type),
547    }
548}
549
550fn owned_zero(base: &str) -> String {
551    match base {
552        "string" => "String::new()".into(),
553        "bytes" => "bytes::Bytes::new()".into(),
554        "bool" => "false".into(),
555        "int8" => "0i8".into(),
556        "int16" => "0i16".into(),
557        "int32" => "0i32".into(),
558        "int64" => "0i64".into(),
559        "uint16" => "0u16".into(),
560        "uint32" => "0u32".into(),
561        "float64" => "0.0f64".into(),
562        "uuid" => "crate::primitives::uuid::Uuid::default()".into(),
563        "records" => "crate::records::RecordsPayload::default()".into(),
564        _ => "Default::default()".into(),
565    }
566}
567
568/// Parse a string schema default as an integer for comparison with zero.
569fn parse_string_default_as_i64(s: &str) -> Option<i64> {
570    s.trim().parse::<i64>().ok()
571}
572
573/// Returns true if any field in `fields` has a non-trivial schema default,
574/// which is one that differs from the Rust type's natural Default.
575///
576/// A nullable field's derived `Default` is always `None`, via
577/// `#[derive(Default)]` on `Option<T>`. Since a nullable field with no
578/// explicit `"default": "null"` now needs `Some(<empty>)`, not `None`, it
579/// always needs a manual impl unless the schema says the default is null.
580pub(crate) fn needs_manual_default(fields: &[FieldSpec]) -> bool {
581    fields.iter().any(|f| {
582        let nullable = is_nullable(f) || matches!(&f.default, Some(serde_json::Value::Null));
583        let default_is_null = matches!(&f.default, Some(serde_json::Value::Null))
584            || matches!(&f.default, Some(serde_json::Value::String(s)) if s == "null");
585        if nullable {
586            return !default_is_null;
587        }
588        match &f.default {
589            // unreachable Null case: covered by `nullable` above. `Bool(false)` is
590            // the Default for bool.
591            None | Some(serde_json::Value::Null | serde_json::Value::Bool(false)) => false,
592            Some(serde_json::Value::String(s)) if s == "null" => false,
593            Some(serde_json::Value::String(s)) if s == "false" => false,
594            Some(serde_json::Value::String(s)) if s.is_empty() => false,
595            Some(serde_json::Value::Number(n)) if n.as_i64() == Some(0) => false,
596            Some(serde_json::Value::String(s)) if parse_string_default_as_i64(s) == Some(0) => {
597                false
598            }
599            Some(_) => true,
600        }
601    })
602}
603
604/// Returns the resolved Rust path for a struct-typed field, or `None` for primitives.
605pub(crate) fn struct_path_for(
606    f: &FieldSpec,
607    res_map: &HashMap<String, Resolution>,
608) -> Option<String> {
609    let base = base_type(&f.field_type);
610    if is_struct_type(base) {
611        res_map.get(base).map(|r| r.rust_path.clone())
612    } else {
613        None
614    }
615}
616
617pub(crate) fn is_struct_type(t: &str) -> bool {
618    t.chars().next().is_some_and(char::is_uppercase)
619}
620
621/// Build the populated-value expression for one owned field. `option` mirrors
622/// the field's Rust-type `Option<...>` wrapping, which `emit_struct`
623/// computes.
624pub(crate) fn owned_populated_value(
625    f: &FieldSpec,
626    res_map: &HashMap<String, Resolution>,
627    option: bool,
628) -> Result<String, EmitError> {
629    let base = base_type(&f.field_type);
630    let is_array = f.field_type.starts_with("[]");
631    // Records are left at default — building a valid batch here is unnecessary
632    // and the dedicated records tests cover that codec.
633    if base == "records" {
634        return Ok(owned_default_expr(f, res_map));
635    }
636    let elem = owned_populated_scalar(base, f, res_map)?;
637    let inner = if is_array {
638        format!("vec![{elem}]")
639    } else {
640        elem
641    };
642    Ok(if option {
643        format!("Some({inner})")
644    } else {
645        inner
646    })
647}
648
649fn owned_populated_scalar(
650    base: &str,
651    f: &FieldSpec,
652    res_map: &HashMap<String, Resolution>,
653) -> Result<String, EmitError> {
654    Ok(match base {
655        "bool" => "true".to_owned(),
656        "int8" => "1i8".to_owned(),
657        "int16" => "1i16".to_owned(),
658        "int32" => "1i32".to_owned(),
659        "int64" => "1i64".to_owned(),
660        "uint16" => "1u16".to_owned(),
661        "uint32" => "1u32".to_owned(),
662        "float64" => "1.0f64".to_owned(),
663        "string" => "\"x\".to_owned()".to_owned(),
664        "bytes" => "::bytes::Bytes::from_static(b\"x\")".to_owned(),
665        "uuid" => "crate::primitives::uuid::Uuid([1u8; 16])".to_owned(),
666        _ => {
667            let path =
668                struct_path_for(f, res_map).ok_or_else(|| EmitError::Unresolved(f.name.clone()))?;
669            format!("{path}::populated(version)")
670        }
671    })
672}
673
674// --- single-field encode/decode helpers -----------------------------------
675
676/// Returns `true` if this field has the per-field override
677/// `"flexibleVersions": "none"`. Such a field must always use the legacy
678/// (non-compact) codec, even in flex message versions.
679pub(crate) fn field_forces_non_flex(f: &FieldSpec) -> bool {
680    matches!(f.flexible_versions, Some(FlexibleVersions::None))
681}
682
683/// Wrap the result in `Some` when the emitter uses a non-nullable decode but
684/// the field type is `Option<T>`. For an array of structs this wraps the whole
685/// block.
686pub(crate) fn wrap_non_nullable_for_option(
687    _schema_type: &str,
688    non_nullable_call: &str,
689    _res_map: &HashMap<String, Resolution>,
690) -> String {
691    // The field is typed as Option<T> (because nullable_versions exists).
692    // A non-nullable decode produces T, so we must wrap in Some.
693    format!("Some({non_nullable_call})")
694}
695
696/// Returns a Rust boolean expression that is `true` when the tagged field
697/// equals its schema-specified default. The emitter uses it to suppress
698/// tagged-field serialization. JVM Kafka also omits tagged fields that equal
699/// their defaults.
700pub(crate) fn tagged_is_default_cond(f: &FieldSpec) -> String {
701    let field = name_conv::field_name(&f.name);
702    let base = base_type(&f.field_type);
703    let is_array = f.field_type.starts_with("[]");
704    let nullable = is_nullable(f) || matches!(&f.default, Some(serde_json::Value::Null));
705    let default_is_null = matches!(&f.default, Some(serde_json::Value::Null))
706        || matches!(&f.default, Some(serde_json::Value::String(s)) if s == "null");
707
708    // `records` fields always default to `None`, per Kafka's generator (see
709    // `owned_default_expr`), when they are nullable (`Option<T>`).
710    if base == "records" && nullable {
711        return format!("self.{field}.is_none()");
712    }
713    if nullable && default_is_null {
714        // Explicit `"default": "null"`: the field's default is `None`.
715        return format!("self.{field}.is_none()");
716    }
717    if nullable && f.default.is_none() {
718        // No explicit default: Kafka's implicit default for a nullable
719        // array/string/bytes/struct field is the *empty* value wrapped in
720        // `Some` (see `owned_default_expr`), not `None`. `None` (the wire
721        // null) is a distinct, non-default value here and must still be
722        // tag-encoded.
723        let inner = if is_array {
724            "Vec::new()".to_owned()
725        } else {
726            owned_zero(base)
727        };
728        return format!("self.{field} == Some({inner})");
729    }
730    if let Some(v) = &f.default {
731        // Compare against the explicit schema default.
732        let cmp_val = scalar_owned_default(base, v);
733        if cmp_val == "None" {
734            return format!("self.{field}.is_none()");
735        }
736        // For Option<T> with a non-null default, compare Some(default) to self.field.
737        if nullable {
738            return format!("self.{field} == Some({cmp_val})");
739        }
740        // For Vec/array fields, check empty.
741        if f.field_type.starts_with("[]") {
742            return format!("self.{field}.is_empty()");
743        }
744        return format!("self.{field} == {cmp_val}");
745    }
746    // No schema default; fall back to Rust's Default.
747    format!("crate::codegen_helpers::is_default(&self.{field})")
748}
749
750// --- primitive encode/decode call generators ------------------------------
751
752/// Encode a field whose Rust type is `Option<T>` but whose wire format is
753/// non-nullable, because `nullable_versions.min > field.versions.min`.
754/// This function treats `None` as the empty or default value for the
755/// underlying type.
756pub(crate) fn encode_call_option_as_non_nullable(schema_type: &str, expr: &str) -> String {
757    if let Some(elem) = schema_type.strip_prefix("[]") {
758        let elem_base = base_type(elem);
759        if is_struct_type(elem_base) {
760            // Option<Vec<Struct>> → encode the inner vec (treating None as empty).
761            return format!(
762                "{{ let v = ({expr}).as_deref().unwrap_or(&[]); \
763                 crate::primitives::array::put_array_len(buf, v.len(), flex)?; \
764                 for it in v {{ it.encode(buf, version)?; }} }}",
765            );
766        }
767        // Option<Vec<Prim>>
768        return format!(
769            "{{ let v = ({expr}).as_deref().unwrap_or(&[]); \
770             crate::primitives::array::put_array_len(buf, v.len(), flex)?; \
771             for it in v {{ {inner}; }} }}",
772            inner = encode_call(elem, "it", false),
773        );
774    }
775    // Option<String> → treat None as ""
776    match schema_type {
777        "string" => format!(
778            "if flex {{ put_compact_string(buf, ({expr}).as_deref().unwrap_or(\"\"))?; }} \
779             else {{ put_string(buf, ({expr}).as_deref().unwrap_or(\"\"))?; }}"
780        ),
781        "uuid" => format!("crate::primitives::uuid::put_uuid(buf, ({expr}).unwrap_or_default())"),
782        // `records` can't go through `unwrap_or_default()` (that would move out of
783        // `&self`), so match by reference and encode an empty payload for None.
784        "records" => format!(
785            "match &{expr} {{ \
786                None => {{ let __rb_buf = bytes::BytesMut::new(); if flex {{ put_compact_bytes(buf, &__rb_buf)?; }} else {{ put_bytes(buf, &__rb_buf)?; }} }}, \
787                Some(__rb) => __rb.encode_length_prefixed(buf, version, flex)?, \
788            }}"
789        ),
790        _ => encode_call(schema_type, &format!("({expr}).unwrap_or_default()"), false),
791    }
792}
793
794/// Compute the `encoded_len` of a field whose Rust type is `Option<T>` but
795/// whose wire format is non-nullable.
796pub(crate) fn encoded_len_expr_option_as_non_nullable(schema_type: &str, expr: &str) -> String {
797    if let Some(elem) = schema_type.strip_prefix("[]") {
798        let elem_base = base_type(elem);
799        if is_struct_type(elem_base) {
800            return format!(
801                "{{ let v = ({expr}).as_deref().unwrap_or(&[]); \
802                 let prefix = crate::primitives::array::array_len_prefix_len(v.len(), flex); \
803                 let body: usize = v.iter().map(|it| it.encoded_len(version)).sum(); \
804                 prefix + body }}",
805            );
806        }
807        return format!(
808            "{{ let v = ({expr}).as_deref().unwrap_or(&[]); \
809             let prefix = crate::primitives::array::array_len_prefix_len(v.len(), flex); \
810             let body: usize = v.iter().map(|it| {inner}).sum(); \
811             prefix + body }}",
812            inner = encoded_len_expr(elem, "*it", false).replace("&*it", "it"),
813        );
814    }
815    match schema_type {
816        "string" => format!(
817            "if flex {{ compact_string_len(({expr}).as_deref().unwrap_or(\"\")) }} \
818             else {{ string_len(({expr}).as_deref().unwrap_or(\"\")) }}"
819        ),
820        "uuid" => "16".into(),
821        "records" => format!(
822            "({expr}).as_ref().map_or_else( \
823                || if flex {{ crate::primitives::string_bytes::compact_bytes_len_from_size(0) }} else {{ 4 }}, \
824                |__rb| {{ let __rb_len = <crate::records::RecordsPayload as crate::Encode>::encoded_len(__rb, version); if flex {{ crate::primitives::string_bytes::compact_bytes_len_from_size(__rb_len) }} else {{ 4 + __rb_len }} }})"
825        ),
826        _ => encoded_len_expr(schema_type, &format!("({expr}).unwrap_or_default()"), false),
827    }
828}
829
830/// Generate an encode call expression with a specific buffer variable name.
831/// The emitter uses this for tagged-field closures, where the buffer is `b`
832/// and not `buf`.
833pub(crate) fn encode_call_with_buf(
834    schema_type: &str,
835    expr: &str,
836    nullable: bool,
837    buf_var: &str,
838) -> String {
839    // Replace all instances of `buf` in the generated expression with `buf_var`.
840    let base = encode_call(schema_type, expr, nullable);
841    // The expressions use `buf` as the buffer name; substitute with the actual var.
842    base.replace("buf", buf_var)
843}
844
845/// Generate a decode call expression with a specific buffer variable name.
846pub(crate) fn decode_call_with_buf(
847    schema_type: &str,
848    nullable: bool,
849    res_map: &HashMap<String, Resolution>,
850    buf_var: &str,
851    lenient_records: bool,
852) -> String {
853    let base = decode_call(schema_type, nullable, res_map, lenient_records);
854    base.replace("buf", buf_var)
855}
856
857/// The statement that encodes one element `it` of a primitive array. `it` is
858/// `&T` from iteration: a Copy primitive is passed as `*it`, and a string or
859/// bytes element derefs to the slice its encoder takes.
860fn array_element_encode(elem: &str) -> String {
861    let call = encode_call(elem, "*it", false).replace("&*it", "it");
862    if call.starts_with("if ") {
863        call
864    } else {
865        format!("{call};")
866    }
867}
868
869// `res_map` is threaded through for array-element recursion even though the
870// primitives branch doesn't use it directly.
871pub(crate) fn encode_call(schema_type: &str, expr: &str, nullable: bool) -> String {
872    if let Some(elem) = schema_type.strip_prefix("[]") {
873        let elem_base = base_type(elem); // same as elem here (already stripped)
874        if is_struct_type(elem_base) {
875            // Array of structs
876            if nullable {
877                return format!(
878                    "{{ let len = ({expr}).as_ref().map(Vec::len); \
879                     crate::primitives::array::put_nullable_array_len(buf, len, flex)?; \
880                     if let Some(v) = &{expr} {{ for it in v {{ it.encode(buf, version)?; }} }} }}",
881                );
882            }
883            return format!(
884                "{{ crate::primitives::array::put_array_len(buf, ({expr}).len(), flex)?; \
885                 for it in &{expr} {{ it.encode(buf, version)?; }} }}",
886            );
887        }
888        if nullable {
889            return format!(
890                "{{ let len = ({expr}).as_ref().map(Vec::len); \
891                 crate::primitives::array::put_nullable_array_len(buf, len, flex)?; \
892                 if let Some(v) = &{expr} {{ for it in v {{ {inner} }} }} }}",
893                inner = array_element_encode(elem),
894            );
895        }
896        return format!(
897            "{{ crate::primitives::array::put_array_len(buf, ({expr}).len(), flex)?; \
898             for it in &{expr} {{ {inner} }} }}",
899            inner = array_element_encode(elem),
900        );
901    }
902
903    if is_struct_type(schema_type) {
904        // Non-array struct. Nullable structs use a 1-byte signed prefix: -1 = null, 1 = non-null.
905        // This matches the Kafka Java generator's nullable-struct wire encoding.
906        if nullable {
907            return format!(
908                "match &{expr} {{ \
909                 None => {{ buf.put_i8(-1); }}, \
910                 Some(v) => {{ buf.put_i8(1); v.encode(buf, version)?; }} \
911                 }}"
912            );
913        }
914        return format!("{expr}.encode(buf, version)?");
915    }
916
917    match (schema_type, nullable) {
918        ("int8", _) => format!("put_i8(buf, {expr})"),
919        ("int16", _) => format!("put_i16(buf, {expr})"),
920        ("uint16", _) => format!("put_u16(buf, {expr})"),
921        ("int32", _) => format!("put_i32(buf, {expr})"),
922        ("int64", _) => format!("put_i64(buf, {expr})"),
923        ("bool", _) => format!("put_bool(buf, {expr})"),
924        ("float64", _) => format!("put_f64(buf, {expr})"),
925        ("uuid", _) => format!("crate::primitives::uuid::put_uuid(buf, {expr})"),
926        ("string", false) => format!(
927            "if flex {{ put_compact_string(buf, &{expr})?; }} else {{ put_string(buf, &{expr})?; }}"
928        ),
929        ("string", true) => format!(
930            "if flex {{ put_compact_nullable_string(buf, {expr}.as_deref())?; }} else {{ put_nullable_string(buf, {expr}.as_deref())?; }}"
931        ),
932        ("bytes", false) => format!(
933            "if flex {{ put_compact_bytes(buf, &{expr})?; }} else {{ put_bytes(buf, &{expr})?; }}"
934        ),
935        ("bytes", true) => format!(
936            "if flex {{ put_compact_nullable_bytes(buf, {expr}.as_deref())?; }} else {{ put_nullable_bytes(buf, {expr}.as_deref())?; }}"
937        ),
938        ("records", false) => format!("{expr}.encode_length_prefixed(buf, version, flex)?"),
939        ("records", true) => format!(
940            "match &{expr} {{ \
941                None => if flex {{ put_compact_nullable_bytes(buf, None)?; }} else {{ put_nullable_bytes(buf, None)?; }}, \
942                Some(__rb) => __rb.encode_length_prefixed(buf, version, flex)?, \
943            }}"
944        ),
945        (t, _) => format!("compile_error!(\"unhandled type in encode_call: {t}\")"),
946    }
947}
948
949pub(crate) fn encoded_len_expr(schema_type: &str, expr: &str, nullable: bool) -> String {
950    if let Some(elem) = schema_type.strip_prefix("[]") {
951        let elem_base = base_type(elem);
952        if is_struct_type(elem_base) {
953            // Array of structs
954            if nullable {
955                return format!(
956                    "{{ let opt: Option<&Vec<_>> = ({expr}).as_ref(); \
957                     let prefix = crate::primitives::array::nullable_array_len_prefix_len(opt.map(std::vec::Vec::len), flex); \
958                     let body: usize = opt.map_or(0, |v| v.iter().map(|it| it.encoded_len(version)).sum()); \
959                     prefix + body }}",
960                );
961            }
962            return format!(
963                "{{ let prefix = crate::primitives::array::array_len_prefix_len(({expr}).len(), flex); \
964                 let body: usize = ({expr}).iter().map(|it| it.encoded_len(version)).sum(); \
965                 prefix + body }}",
966            );
967        }
968        {
969            let inner = encoded_len_expr(elem, "*it", false);
970            // Use `|_|` when the inner expression is constant (doesn't reference `*it`),
971            // to avoid an unused-variable warning.
972            let closure_arg = if inner.contains("*it") { "it" } else { "_" };
973            // A string or bytes element derefs to the slice its length helper takes.
974            let inner = inner.replace("&*it", "it");
975            if nullable {
976                return format!(
977                    "{{ let opt: Option<&Vec<_>> = ({expr}).as_ref(); \
978                     let prefix = crate::primitives::array::nullable_array_len_prefix_len(opt.map(std::vec::Vec::len), flex); \
979                     let body: usize = opt.map_or(0, |v| v.iter().map(|{closure_arg}| {inner}).sum()); \
980                     prefix + body }}",
981                );
982            }
983            return format!(
984                "{{ let prefix = crate::primitives::array::array_len_prefix_len(({expr}).len(), flex); \
985                 let body: usize = ({expr}).iter().map(|{closure_arg}| {inner}).sum(); \
986                 prefix + body }}",
987            );
988        }
989    }
990
991    if is_struct_type(schema_type) {
992        if nullable {
993            // 1 byte for the signed null-marker prefix (–1 or 1) + body when present.
994            return format!("1 + {expr}.as_ref().map_or(0, |v| v.encoded_len(version))");
995        }
996        return format!("{expr}.encoded_len(version)");
997    }
998
999    match (schema_type, nullable) {
1000        ("int8" | "bool", _) => "1".into(),
1001        ("int16" | "uint16", _) => "2".into(),
1002        ("int32", _) => "4".into(),
1003        ("int64" | "float64", _) => "8".into(),
1004        ("uuid", _) => "16".into(),
1005        ("string", false) => {
1006            format!("if flex {{ compact_string_len(&{expr}) }} else {{ string_len(&{expr}) }}")
1007        }
1008        ("string", true) => format!(
1009            "if flex {{ compact_nullable_string_len({expr}.as_deref()) }} else {{ nullable_string_len({expr}.as_deref()) }}"
1010        ),
1011        ("bytes", false) => {
1012            format!("if flex {{ compact_bytes_len(&{expr}) }} else {{ bytes_len(&{expr}) }}")
1013        }
1014        ("bytes", true) => format!(
1015            "if flex {{ compact_nullable_bytes_len({expr}.as_deref()) }} else {{ nullable_bytes_len({expr}.as_deref()) }}"
1016        ),
1017        ("records", false) => format!(
1018            "{{ let __rb_len = <crate::records::RecordsPayload as crate::Encode>::encoded_len(&{expr}, version); \
1019               if flex {{ crate::primitives::string_bytes::compact_bytes_len_from_size(__rb_len) }} \
1020               else {{ 4 + __rb_len }} }}"
1021        ),
1022        ("records", true) => format!(
1023            "({expr}).as_ref().map_or_else( \
1024                || if flex {{ crate::primitives::varint::uvarint_len(0) }} else {{ 4 }}, \
1025                |__rb| {{ let __rb_len = <crate::records::RecordsPayload as crate::Encode>::encoded_len(__rb, version); \
1026                    if flex {{ crate::primitives::string_bytes::compact_bytes_len_from_size(__rb_len) }} \
1027                    else {{ 4 + __rb_len }} }})"
1028        ),
1029        (t, _) => format!("compile_error!(\"unhandled type in encoded_len_expr: {t}\")"),
1030    }
1031}
1032
1033pub(crate) fn decode_call(
1034    schema_type: &str,
1035    nullable: bool,
1036    res_map: &HashMap<String, Resolution>,
1037    lenient_records: bool,
1038) -> String {
1039    if let Some(elem) = schema_type.strip_prefix("[]") {
1040        let elem_base = base_type(elem);
1041        if is_struct_type(elem_base) {
1042            // Array of structs: use the resolved path so common-struct files compile correctly.
1043            let type_path = res_map
1044                .get(elem_base)
1045                .map_or(elem_base, |r| r.rust_path.as_str());
1046            if nullable {
1047                return format!(
1048                    "{{ let opt = crate::primitives::array::get_nullable_array_len(buf, flex)?; \
1049                     match opt {{ None => None, Some(n) => {{ let mut v = Vec::with_capacity(n); \
1050                     for _ in 0..n {{ v.push({type_path}::decode(buf, version)?); }} Some(v) }} }} }}",
1051                );
1052            }
1053            return format!(
1054                "{{ let n = crate::primitives::array::get_array_len(buf, flex)?; \
1055                 let mut v = Vec::with_capacity(n); \
1056                 for _ in 0..n {{ v.push({type_path}::decode(buf, version)?); }} v }}",
1057            );
1058        }
1059        if nullable {
1060            return format!(
1061                "{{ let opt = crate::primitives::array::get_nullable_array_len(buf, flex)?; \
1062                 match opt {{ None => None, Some(n) => {{ let mut v = Vec::with_capacity(n); \
1063                 for _ in 0..n {{ v.push({inner}); }} Some(v) }} }} }}",
1064                inner = decode_call(elem, false, res_map, lenient_records),
1065            );
1066        }
1067        return format!(
1068            "{{ let n = crate::primitives::array::get_array_len(buf, flex)?; \
1069             let mut v = Vec::with_capacity(n); for _ in 0..n {{ v.push({inner}); }} v }}",
1070            inner = decode_call(elem, false, res_map, lenient_records),
1071        );
1072    }
1073
1074    if is_struct_type(schema_type) {
1075        let type_path = res_map
1076            .get(schema_type)
1077            .map_or(schema_type, |r| r.rust_path.as_str());
1078        if nullable {
1079            // Nullable non-array structs use a 1-byte signed prefix: < 0 = null, else non-null.
1080            // Matches the Kafka Java generator's nullable-struct wire encoding.
1081            return format!(
1082                "if get_i8(buf)? < 0 {{ None }} else {{ Some({type_path}::decode(buf, version)?) }}"
1083            );
1084        }
1085        return format!("{type_path}::decode(buf, version)?");
1086    }
1087
1088    match (schema_type, nullable) {
1089        ("int8",   _)     => "get_i8(buf)?".into(),
1090        ("int16",  _)     => "get_i16(buf)?".into(),
1091        ("uint16", _)     => "get_u16(buf)?".into(),
1092        ("int32",  _)     => "get_i32(buf)?".into(),
1093        ("int64",  _)     => "get_i64(buf)?".into(),
1094        ("bool",   _)     => "get_bool(buf)?".into(),
1095        ("float64",_)     => "get_f64(buf)?".into(),
1096        ("uuid",   _)     => "crate::primitives::uuid::get_uuid(buf)?".into(),
1097        ("string", false) => "if flex { get_compact_string_owned(buf)? } else { get_string_owned(buf)? }".into(),
1098        ("string", true)  => "if flex { get_compact_nullable_string_owned(buf)? } else { get_nullable_string_owned(buf)? }".into(),
1099        ("bytes", false) => "if flex { get_compact_bytes_owned(buf)? } else { get_bytes_owned(buf)? }".into(),
1100        ("bytes", true)  => "if flex { get_compact_nullable_bytes_owned(buf)? } else { get_nullable_bytes_owned(buf)? }".into(),
1101        ("records", false) => if lenient_records {
1102            "{ \
1103                let __rb_bytes = if flex { get_compact_bytes_owned(buf)? } else { get_bytes_owned(buf)? }; \
1104                crate::records::RecordsPayload::from_fetch_bytes(__rb_bytes)? \
1105            }".into()
1106        } else {
1107            "{ \
1108                let __rb_bytes = if flex { get_compact_bytes_owned(buf)? } else { get_bytes_owned(buf)? }; \
1109                crate::records::RecordsPayload::from_bytes(__rb_bytes)? \
1110            }".into()
1111        },
1112        ("records", true) => if lenient_records {
1113            "{ \
1114                let __rb_opt = if flex { get_compact_nullable_bytes_owned(buf)? } else { get_nullable_bytes_owned(buf)? }; \
1115                match __rb_opt { \
1116                    None => None, \
1117                    Some(__rb_bytes) => { \
1118                        Some(crate::records::RecordsPayload::from_fetch_bytes(__rb_bytes)?) \
1119                    } \
1120                } \
1121            }".into()
1122        } else {
1123            "{ \
1124                let __rb_opt = if flex { get_compact_nullable_bytes_owned(buf)? } else { get_nullable_bytes_owned(buf)? }; \
1125                match __rb_opt { \
1126                    None => None, \
1127                    Some(__rb_bytes) => { \
1128                        Some(crate::records::RecordsPayload::from_bytes(__rb_bytes)?) \
1129                    } \
1130                } \
1131            }".into()
1132        },
1133        (t, _) => format!("compile_error!(\"unhandled type in decode_call: {t}\")"),
1134    }
1135}
1136
1137// --- helpers --------------------------------------------------------------
1138
1139pub(crate) fn is_tagged(f: &FieldSpec) -> bool {
1140    f.tag.is_some()
1141}
1142pub(crate) fn is_nullable(f: &FieldSpec) -> bool {
1143    f.nullable_versions.is_some()
1144}
1145
1146/// Version condition under which a field uses its NULLABLE codec.
1147///
1148/// A field is nullable only within its `nullableVersions` range. Where that
1149/// range is narrower than the field's own version range (on either end), the
1150/// codec must switch between nullable and non-nullable per version. Returns
1151/// `Some(cond)` for that boundary expression, or `None` when nullability is
1152/// constant across the whole field range. In that case use `is_nullable(f)`
1153/// directly.
1154pub(crate) fn nullable_split_cond(f: &FieldSpec) -> Option<String> {
1155    let r = f.nullable_versions?;
1156    let need_lower = r.min > f.versions.min;
1157    let need_upper = r.max < f.versions.max;
1158    if !need_lower && !need_upper {
1159        return None;
1160    }
1161    let mut parts = Vec::new();
1162    if need_lower {
1163        parts.push(if r.min == i16::MAX {
1164            "version == i16::MAX".to_owned()
1165        } else {
1166            format!("version >= {}", r.min)
1167        });
1168    }
1169    if need_upper {
1170        parts.push(if r.max == i16::MIN {
1171            "version == i16::MIN".to_owned()
1172        } else {
1173            format!("version <= {}", r.max)
1174        });
1175    }
1176    Some(parts.join(" && "))
1177}
1178
1179/// Version predicate that gates a KIP-482 tagged field, or `None` when the
1180/// tag is valid across every flexible version of its struct.
1181///
1182/// A tag belongs to the schema versions that declare it. `FetchRequest` is
1183/// flexible from v12, but its `ReplicaState` tag starts at v15, so v12 to v14
1184/// must not carry tag 1 even when the field holds a non-default value. Kafka's
1185/// own generator wraps the tag in the same version conditional. `flex_minimum`
1186/// is the struct's flexible threshold; the tag can never be written below it,
1187/// so a range that starts at or before it needs no test.
1188pub(crate) fn tagged_version_cond(f: &FieldSpec, flex_minimum: i16) -> Option<String> {
1189    let r = f.tagged_versions.unwrap_or(f.versions);
1190    if r.min <= flex_minimum && r.max == i16::MAX {
1191        return None;
1192    }
1193    Some(version_cond(r, "version"))
1194}
1195
1196pub(crate) fn version_cond(r: VersionRange, version_var: &str) -> String {
1197    if r.min == i16::MIN && r.max == i16::MAX {
1198        "true".to_owned()
1199    } else if r.min == r.max {
1200        format!("{version_var} == {}", r.min)
1201    } else if r.min == i16::MIN {
1202        format!("{version_var} <= {}", r.max)
1203    } else if r.max == i16::MAX {
1204        format!("{version_var} >= {}", r.min)
1205    } else {
1206        format!("({}..={}).contains(&{version_var})", r.min, r.max)
1207    }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use assert2::assert;
1213
1214    use super::*;
1215
1216    /// A nullable, tagged array field with no explicit `"default"` in its
1217    /// schema — the shape of `MetadataRequest.Topics`, but tagged. Kafka never
1218    /// happens to combine `taggedVersions` with a no-default nullable field in
1219    /// its own schemas today, but the generator must still get it right: it is
1220    /// a plain consequence of KIP-482 tagged fields and the ordinary
1221    /// nullable-array default rule, not a special case either omits.
1222    fn tagged_nullable_no_default_array_field() -> FieldSpec {
1223        serde_json::from_value(serde_json::json!({
1224            "name": "Owners", "type": "[]string", "versions": "1+",
1225            "nullableVersions": "1+", "taggedVersions": "1+", "tag": 5
1226        }))
1227        .unwrap()
1228    }
1229
1230    /// The bug `chatgpt-codex-connector` flagged on PR #31: a nullable tagged
1231    /// field with no explicit default gets `Some(Vec::new())` as its actual
1232    /// `Default::default()` value (see `owned_default_expr`), matching
1233    /// Kafka's `FieldSpec.fieldDefault` (empty, not null, absent an explicit
1234    /// `"default": "null"`). `tagged_is_default_cond` must therefore treat
1235    /// `Some(empty)` as the default, not `None` — otherwise a
1236    /// default-constructed message needlessly writes the empty-valued tag
1237    /// (wasting bytes and diverging from Kafka's own generator, which
1238    /// suppresses it), while an explicit `None` (a real wire null, a value
1239    /// the schema's own comment gives separate meaning to) would be wrongly
1240    /// suppressed instead.
1241    #[test]
1242    fn tagged_nullable_no_default_field_defaults_to_some_empty_not_none() {
1243        let field = tagged_nullable_no_default_array_field();
1244        let res_map: HashMap<String, Resolution> = HashMap::new();
1245
1246        assert!(owned_default_expr(&field, &res_map) == "Some(Vec::new())");
1247        assert!(tagged_is_default_cond(&field) == "self.owners == Some(Vec::new())");
1248    }
1249
1250    /// A nullable, tagged, non-array scalar field with no explicit default.
1251    /// This is the `owned_zero(base)` half of the no-default branch that
1252    /// `tagged_nullable_no_default_field_defaults_to_some_empty_not_none`
1253    /// does not reach, since that test's field is an array and takes the
1254    /// `is_array` arm instead.
1255    fn tagged_nullable_no_default_scalar_field() -> FieldSpec {
1256        serde_json::from_value(serde_json::json!({
1257            "name": "Owner", "type": "string", "versions": "1+",
1258            "nullableVersions": "1+", "taggedVersions": "1+", "tag": 6
1259        }))
1260        .unwrap()
1261    }
1262
1263    #[test]
1264    fn tagged_nullable_no_default_scalar_field_defaults_to_some_empty_not_none() {
1265        let field = tagged_nullable_no_default_scalar_field();
1266        let res_map: HashMap<String, Resolution> = HashMap::new();
1267
1268        assert!(owned_default_expr(&field, &res_map) == "Some(String::new())");
1269        assert!(tagged_is_default_cond(&field) == "self.owner == Some(String::new())");
1270    }
1271
1272    /// The bug a review comment on PR #31 flagged: `FieldSpec.fieldDefaultToJava`
1273    /// hardcodes `null` as the default for `records` fields unconditionally
1274    /// (independent of any explicit schema default), unlike every other
1275    /// nullable type. A `records` field with `nullableVersions` (the shape of
1276    /// `FetchResponse.PartitionData.records`) must therefore default to
1277    /// `None`, not `Some(RecordsPayload::default())` — the empty-value rule
1278    /// that applies to every other nullable field with no explicit default.
1279    fn nullable_records_field_with_no_default() -> FieldSpec {
1280        serde_json::from_value(serde_json::json!({
1281            "name": "Records", "type": "records", "versions": "0+",
1282            "nullableVersions": "0+"
1283        }))
1284        .unwrap()
1285    }
1286
1287    #[test]
1288    fn nullable_records_field_always_defaults_to_none() {
1289        let field = nullable_records_field_with_no_default();
1290        let res_map: HashMap<String, Resolution> = HashMap::new();
1291
1292        assert!(owned_default_expr(&field, &res_map) == "None");
1293        assert!(tagged_is_default_cond(&field) == "self.records.is_none()");
1294    }
1295
1296    /// A `records` field with no `nullableVersions` at all (the shape of
1297    /// `FetchSnapshotResponse.UnalignedRecords`) has no `Option` on the Rust
1298    /// side to put `None` into, so it keeps the ordinary empty-value default.
1299    fn non_nullable_records_field() -> FieldSpec {
1300        serde_json::from_value(serde_json::json!({
1301            "name": "UnalignedRecords", "type": "records", "versions": "0+"
1302        }))
1303        .unwrap()
1304    }
1305
1306    #[test]
1307    fn non_nullable_records_field_keeps_empty_default() {
1308        let field = non_nullable_records_field();
1309        let res_map: HashMap<String, Resolution> = HashMap::new();
1310
1311        assert!(
1312            owned_default_expr(&field, &res_map) == "crate::records::RecordsPayload::default()"
1313        );
1314    }
1315}