Skip to main content

krabka_protocol_codegen/emit/
default_json.rs

1//! Emit `pub fn default_json(version: i16) -> serde_json::Value` per message.
2//!
3//! The emitted function produces a `serde_json::Value` whose shape mirrors
4//! what Kafka's `*DataJsonConverter.read(json, version)` expects for the
5//! default state of a message. That is the JSON the JVM oracle should accept
6//! to yield the same bytes as `MessageName::default()` after encoding.
7//!
8//! The function is version-aware. It includes only fields that are valid for
9//! the requested version, and it emits null only for versions where the field
10//! is actually nullable. The JVM converter therefore never rejects the
11//! input.
12
13use proc_macro2::TokenStream;
14use quote::quote;
15
16use crate::{
17    emit::{
18        EmitError,
19        common::{format_int_literal, parse_tokens, uses_ident, validate},
20    },
21    ir::{FieldSpec, MessageSpec, VersionRange},
22};
23
24/// Emit the `default_json(version: i16)` function body for the given message.
25/// The output is plain Rust source, and the caller appends it to the
26/// per-message owned module body.
27///
28/// # Errors
29/// Returns an error when the emitted function is not valid Rust, which is an
30/// emitter bug.
31pub fn emit_default_json(spec: &MessageSpec) -> Result<String, EmitError> {
32    let field_stmts = fields_stmts_tokens(&spec.fields)?;
33    // A message whose fields hold at every version never reads `version`, and
34    // one with no fields never inserts into `obj`.
35    let version = if uses_ident(&field_stmts, "version") {
36        quote!(version)
37    } else {
38        quote!(_version)
39    };
40    let mut_kw = if field_stmts.is_empty() {
41        quote!()
42    } else {
43        quote!(mut)
44    };
45
46    let tokens = quote! {
47        #[doc = " Default JSON payload matching `Self::default()` for JVM oracle differential testing."]
48        #[doc = " Only includes fields valid for the given version."]
49        #[must_use]
50        #[allow(unused_comparisons)]
51        pub fn default_json(#version: i16) -> ::serde_json::Value {
52            let #mut_kw obj = ::serde_json::Map::new();
53            #field_stmts
54            ::serde_json::Value::Object(obj)
55        }
56    };
57
58    // Validate at generation time — a parse failure is a generator bug.
59    validate::<syn::Item>(&tokens, "default_json fn")?;
60
61    Ok(tokens.to_string())
62}
63
64/// Build a `TokenStream` of `obj.insert(...)` statements for each field, with
65/// version range checks as guards.
66fn fields_stmts_tokens(fields: &[FieldSpec]) -> Result<TokenStream, EmitError> {
67    let stmts = fields
68        .iter()
69        .map(|f| {
70            let key = json_field_name(&f.name);
71            let val_tokens = parse_tokens(&json_value_expr_versioned(f))?;
72            let insert = quote!(obj.insert(#key.to_owned(), #val_tokens););
73            // A field valid at every version needs no guard.
74            let Some(cond) = version_cond(f.versions) else {
75                return Ok(insert);
76            };
77            let cond_tokens = parse_tokens(&cond)?;
78            Ok(quote! {
79                if #cond_tokens {
80                    #insert
81                }
82            })
83        })
84        .collect::<Result<Vec<_>, EmitError>>()?;
85
86    Ok(quote! { #(#stmts)* })
87}
88
89/// Return the condition string for a version range, or None if always valid.
90fn version_cond(vr: VersionRange) -> Option<String> {
91    if vr.min == 0 && vr.max == i16::MAX {
92        None // always valid
93    } else if vr.max == i16::MAX {
94        Some(format!("version >= {}", vr.min))
95    } else if vr.min == 0 {
96        Some(format!("version <= {}", vr.max))
97    } else if vr.min == vr.max {
98        Some(format!("version == {}", vr.min))
99    } else {
100        Some(format!("({}..={}).contains(&version)", vr.min, vr.max))
101    }
102}
103
104/// Generate a version-aware Rust expression for the default `serde_json::Value`.
105///
106/// The goal is to match what `MessageName::default()` encodes so that the
107/// oracle produces the same bytes as the Rust implementation.
108///
109/// Key rules, matching Kafka's Java message generator
110/// (`FieldSpec.fieldDefaultToJava`):
111/// - Fields with an explicit `"default": "null"`: Rust `Default` produces
112///   `None`, so emit `null`, staying version-aware for split nullability
113///   ranges (null only where the field is actually nullable, the type's zero
114///   value otherwise).
115/// - Nullable fields with NO explicit default (array, string, or struct):
116///   Rust `Default` now produces the *empty* value (`Some(<empty>)`), not
117///   `None`, so emit the type's zero value for every version, not just the
118///   non-nullable ones.
119/// - Non-nullable array fields: always `[]`.
120pub(crate) fn json_value_expr_versioned(f: &FieldSpec) -> String {
121    let is_array = f.field_type.starts_with("[]");
122    let default_is_null = matches!(&f.default, Some(serde_json::Value::Null))
123        || matches!(&f.default, Some(serde_json::Value::String(s)) if s == "null")
124        // Kafka's `FieldSpec.fieldDefaultToJava` hardcodes `null` as the
125        // default for `records` fields unconditionally, regardless of any
126        // explicit schema default. This is a permanent special case, not
127        // subject to the general nullable-default-value rule below. It only
128        // applies when the field is nullable in some version: the JVM's
129        // JSON-to-Data converter rejects a JSON `null` for a `records` field
130        // that has no `nullableVersions` at all (e.g.
131        // `FetchSnapshotResponse.UnalignedRecords`), which keeps the
132        // type-zero (empty base64) fallback below.
133        || (base_type(&f.field_type) == "records" && f.nullable_versions.is_some());
134
135    if default_is_null {
136        if let Some(nv) = f.nullable_versions {
137            // Check if nullable for ALL valid versions (trivial case: no branching needed).
138            let always_nullable =
139                nv.min <= f.versions.min && (nv.max == i16::MAX || nv.max >= f.versions.max);
140            if always_nullable {
141                return "::serde_json::Value::Null".into();
142            }
143            // Split: nullable in some versions, not in others.
144            // Emit: if <nullable_cond> { Null } else { type_zero }
145            let zero = if is_array {
146                "::serde_json::Value::Array(vec![])".to_owned()
147            } else {
148                type_zero_expr(&f.field_type, f)
149            };
150            if let Some(cond) = version_cond(nv) {
151                return format!("if {cond} {{ ::serde_json::Value::Null }} else {{ {zero} }}");
152            }
153            return "::serde_json::Value::Null".into();
154        }
155        // No nullable_versions but default_is_null — emit type zero.
156        if is_array {
157            return "::serde_json::Value::Array(vec![])".into();
158        }
159        return type_zero_expr(&f.field_type, f);
160    }
161
162    if is_array {
163        return "::serde_json::Value::Array(vec![])".into();
164    }
165
166    f.default.as_ref().map_or_else(
167        || type_zero_expr(&f.field_type, f),
168        |v| scalar_value_expr(&f.field_type, v, f),
169    )
170}
171
172/// Convert a schema `default` annotation to a Rust expression that produces a
173/// `serde_json::Value`.
174fn scalar_value_expr(field_type: &str, val: &serde_json::Value, f: &FieldSpec) -> String {
175    let base = base_type(field_type);
176
177    match val {
178        serde_json::Value::String(s) if s == "null" => "::serde_json::Value::Null".into(),
179
180        serde_json::Value::String(s) if is_numeric_type(base) => {
181            // Convert hex string defaults (e.g. "0x7fffffff") to decimal.
182            let trimmed = s.trim();
183            let decimal = trimmed
184                .strip_prefix("0x")
185                .or_else(|| trimmed.strip_prefix("0X"))
186                .and_then(|hex_str| i64::from_str_radix(hex_str, 16).ok())
187                .map_or_else(|| trimmed.to_owned(), |n| n.to_string());
188            // Produce a json! Number value.
189            let literal = if base == "float64" {
190                decimal
191            } else {
192                format_int_literal(&decimal, "")
193            };
194            format!("::serde_json::json!({literal})")
195        }
196
197        serde_json::Value::String(s) if base == "bool" => {
198            if s == "true" {
199                "::serde_json::Value::Bool(true)".into()
200            } else {
201                "::serde_json::Value::Bool(false)".into()
202            }
203        }
204
205        serde_json::Value::String(s) if base == "string" => {
206            // Escape the string for embedding in Rust source.
207            if s.is_empty() {
208                return "::serde_json::Value::String(String::new())".into();
209            }
210            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
211            format!("::serde_json::Value::String(\"{escaped}\".to_owned())")
212        }
213
214        serde_json::Value::Number(n) => {
215            let literal = if base == "float64" {
216                n.to_string()
217            } else {
218                format_int_literal(&n.to_string(), "")
219            };
220            format!("::serde_json::json!({literal})")
221        }
222
223        serde_json::Value::Bool(b) => {
224            format!("::serde_json::Value::Bool({b})")
225        }
226
227        _ => type_zero_expr(field_type, f),
228    }
229}
230
231/// The "zero" `serde_json::Value` expression for a type, used when the schema
232/// specifies no default.
233fn type_zero_expr(field_type: &str, f: &FieldSpec) -> String {
234    let base = base_type(field_type);
235    match base {
236        "bool" => "::serde_json::Value::Bool(false)".into(),
237        "int8" | "int16" | "int32" | "int64" | "uint16" | "uint32" => {
238            "::serde_json::json!(0)".into()
239        }
240        "float64" => "::serde_json::json!(0.0)".into(),
241        "string" | "bytes" | "records" => "::serde_json::Value::String(String::new())".into(),
242        "uuid" => {
243            // Kafka's *DataJsonConverter encodes Uuid as base64 (22 chars),
244            // not the standard hyphen UUID format. The zero UUID in base64 is
245            // "AAAAAAAAAAAAAAAAAAAAAA" (16 zero bytes, no padding needed).
246            "::serde_json::Value::String(\"AAAAAAAAAAAAAAAAAAAAAA\".to_owned())".into()
247        }
248        _ => {
249            if f.fields.is_empty() {
250                "::serde_json::Value::Object(::serde_json::Map::new())".into()
251            } else {
252                emit_nested_struct_expr(&f.fields)
253            }
254        }
255    }
256}
257
258/// Emit a Rust expression that builds a `serde_json::Value::Object` for a
259/// nested struct.
260///
261/// Note that this function does NOT apply sub-field version guards, because
262/// nested struct defaults are version-independent relative to their parent
263/// field's guard. If this causes problems for specific complex schemas, fix it
264/// at the per-field level.
265fn emit_nested_struct_expr(fields: &[FieldSpec]) -> String {
266    let inserts = fields
267        .iter()
268        .map(|f| {
269            let key = json_field_name(&f.name);
270            let val = json_value_expr_versioned(f);
271            format!("m.insert(\"{key}\".to_owned(), {val}); ")
272        })
273        .collect::<Vec<_>>()
274        .concat();
275    format!("{{ let mut m = ::serde_json::Map::new(); {inserts}::serde_json::Value::Object(m) }}")
276}
277
278/// Convert a Kafka schema field name in `PascalCase`, such as
279/// `TransactionalId`, to the JSON key the JVM's `*DataJsonConverter` uses.
280///
281/// That key is `camelCase`, such as `transactionalId`, with the first
282/// character in lower case.
283#[must_use]
284pub fn json_field_name(name: &str) -> String {
285    let mut chars = name.chars();
286    chars.next().map_or_else(String::new, |c| {
287        c.to_lowercase().collect::<String>() + chars.as_str()
288    })
289}
290
291fn base_type(t: &str) -> &str {
292    t.strip_prefix("[]").unwrap_or(t)
293}
294
295fn is_numeric_type(t: &str) -> bool {
296    matches!(
297        t,
298        "int8" | "int16" | "int32" | "int64" | "uint16" | "uint32" | "float64"
299    )
300}
301
302#[cfg(test)]
303mod tests {
304    use assert2::assert;
305
306    use super::*;
307
308    /// The JSON-oracle half of the `records`-null-default bug a review
309    /// comment on PR #31 flagged: Kafka's `FieldSpec.fieldDefaultToJava`
310    /// hardcodes `null` as the default for `records` fields unconditionally.
311    /// A nullable `records` field (the shape of
312    /// `FetchResponse.PartitionData.records`) must emit `Value::Null`, not
313    /// the empty-string placeholder the general nullable-default-value rule
314    /// would otherwise produce.
315    #[test]
316    fn nullable_records_field_emits_json_null() {
317        let field: FieldSpec = serde_json::from_value(serde_json::json!({
318            "name": "Records", "type": "records", "versions": "0+",
319            "nullableVersions": "0+"
320        }))
321        .unwrap();
322
323        assert!(json_value_expr_versioned(&field) == "::serde_json::Value::Null");
324    }
325
326    /// A `records` field with no `nullableVersions` at all (the shape of
327    /// `FetchSnapshotResponse.UnalignedRecords`) keeps the empty-string
328    /// placeholder: the JVM's JSON-to-Data converter rejects a JSON `null`
329    /// for a `records` field it does not consider nullable.
330    #[test]
331    fn non_nullable_records_field_keeps_empty_string_placeholder() {
332        let field: FieldSpec = serde_json::from_value(serde_json::json!({
333            "name": "UnalignedRecords", "type": "records", "versions": "0+"
334        }))
335        .unwrap();
336
337        assert!(json_value_expr_versioned(&field) == "::serde_json::Value::String(String::new())");
338    }
339}