Skip to main content

alef_e2e/codegen/
java.rs

1//! Java e2e test generator using JUnit 5.
2//!
3//! Generates `e2e/java/pom.xml` and `src/test/java/dev/kreuzberg/e2e/{Category}Test.java`
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_java, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::ToUpperCamelCase;
14use std::collections::HashSet;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// Java e2e code generator.
21pub struct JavaCodegen;
22
23impl E2eCodegen for JavaCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let _module_path = overrides
39            .and_then(|o| o.module.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.module.clone());
42        let function_name = overrides
43            .and_then(|o| o.function.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.function.clone());
46        let class_name = overrides
47            .and_then(|o| o.class.as_ref())
48            .cloned()
49            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
50        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
51        let result_var = &call.result_var;
52
53        // Resolve package config.
54        let java_pkg = e2e_config.resolve_package("java");
55        let pkg_name = java_pkg
56            .as_ref()
57            .and_then(|p| p.name.as_ref())
58            .cloned()
59            .unwrap_or_else(|| alef_config.crate_config.name.clone());
60
61        // Resolve Java package info for the dependency.
62        let java_group_id = alef_config.java_group_id();
63        let pkg_version = alef_config.resolved_version().unwrap_or_else(|| "0.1.0".to_string());
64
65        // Generate pom.xml.
66        files.push(GeneratedFile {
67            path: output_base.join("pom.xml"),
68            content: render_pom_xml(&pkg_name, &java_group_id, &pkg_version, e2e_config.dep_mode),
69            generated_header: false,
70        });
71
72        // Generate test files per category.
73        let test_base = output_base
74            .join("src")
75            .join("test")
76            .join("java")
77            .join("dev")
78            .join("kreuzberg")
79            .join("e2e");
80
81        // Resolve options_type from override.
82        let options_type = overrides.and_then(|o| o.options_type.clone());
83        let field_resolver = FieldResolver::new(
84            &e2e_config.fields,
85            &e2e_config.fields_optional,
86            &e2e_config.result_fields,
87            &e2e_config.fields_array,
88        );
89
90        for group in groups {
91            let active: Vec<&Fixture> = group
92                .fixtures
93                .iter()
94                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
95                .collect();
96
97            if active.is_empty() {
98                continue;
99            }
100
101            let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
102            let content = render_test_file(
103                &group.category,
104                &active,
105                &class_name,
106                &function_name,
107                result_var,
108                &e2e_config.call.args,
109                options_type.as_deref(),
110                &field_resolver,
111                result_is_simple,
112                &e2e_config.fields_enum,
113            );
114            files.push(GeneratedFile {
115                path: test_base.join(class_file_name),
116                content,
117                generated_header: true,
118            });
119        }
120
121        Ok(files)
122    }
123
124    fn language_name(&self) -> &'static str {
125        "java"
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Rendering
131// ---------------------------------------------------------------------------
132
133fn render_pom_xml(
134    pkg_name: &str,
135    java_group_id: &str,
136    pkg_version: &str,
137    dep_mode: crate::config::DependencyMode,
138) -> String {
139    // pkg_name may be in "groupId:artifactId" Maven format; split accordingly.
140    let (dep_group_id, dep_artifact_id) = if let Some((g, a)) = pkg_name.split_once(':') {
141        (g, a)
142    } else {
143        (java_group_id, pkg_name)
144    };
145    let artifact_id = format!("{dep_artifact_id}-e2e-java");
146    let dep_block = match dep_mode {
147        crate::config::DependencyMode::Registry => {
148            format!(
149                r#"        <dependency>
150            <groupId>{dep_group_id}</groupId>
151            <artifactId>{dep_artifact_id}</artifactId>
152            <version>{pkg_version}</version>
153        </dependency>"#
154            )
155        }
156        crate::config::DependencyMode::Local => {
157            format!(
158                r#"        <dependency>
159            <groupId>{dep_group_id}</groupId>
160            <artifactId>{dep_artifact_id}</artifactId>
161            <version>{pkg_version}</version>
162            <scope>system</scope>
163            <systemPath>${{project.basedir}}/../../packages/java/target/{dep_artifact_id}-{pkg_version}.jar</systemPath>
164        </dependency>"#
165            )
166        }
167    };
168    format!(
169        r#"<?xml version="1.0" encoding="UTF-8"?>
170<project xmlns="http://maven.apache.org/POM/4.0.0"
171         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
172         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
173    <modelVersion>4.0.0</modelVersion>
174
175    <groupId>dev.kreuzberg</groupId>
176    <artifactId>{artifact_id}</artifactId>
177    <version>0.1.0</version>
178
179    <properties>
180        <maven.compiler.source>25</maven.compiler.source>
181        <maven.compiler.target>25</maven.compiler.target>
182        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
183        <junit.version>5.11.4</junit.version>
184    </properties>
185
186    <dependencies>
187{dep_block}
188        <dependency>
189            <groupId>com.fasterxml.jackson.core</groupId>
190            <artifactId>jackson-databind</artifactId>
191            <version>2.18.2</version>
192        </dependency>
193        <dependency>
194            <groupId>com.fasterxml.jackson.datatype</groupId>
195            <artifactId>jackson-datatype-jdk8</artifactId>
196            <version>2.18.2</version>
197        </dependency>
198        <dependency>
199            <groupId>org.junit.jupiter</groupId>
200            <artifactId>junit-jupiter</artifactId>
201            <version>${{junit.version}}</version>
202            <scope>test</scope>
203        </dependency>
204    </dependencies>
205
206    <build>
207        <plugins>
208            <plugin>
209                <groupId>org.codehaus.mojo</groupId>
210                <artifactId>build-helper-maven-plugin</artifactId>
211                <version>3.6.0</version>
212                <executions>
213                    <execution>
214                        <id>add-test-source</id>
215                        <phase>generate-test-sources</phase>
216                        <goals>
217                            <goal>add-test-source</goal>
218                        </goals>
219                        <configuration>
220                            <sources>
221                                <source>src/test/java</source>
222                            </sources>
223                        </configuration>
224                    </execution>
225                </executions>
226            </plugin>
227            <plugin>
228                <groupId>org.apache.maven.plugins</groupId>
229                <artifactId>maven-surefire-plugin</artifactId>
230                <version>3.5.2</version>
231                <configuration>
232                    <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=../../target/release</argLine>
233                </configuration>
234            </plugin>
235        </plugins>
236    </build>
237</project>
238"#
239    )
240}
241
242#[allow(clippy::too_many_arguments)]
243fn render_test_file(
244    category: &str,
245    fixtures: &[&Fixture],
246    class_name: &str,
247    function_name: &str,
248    result_var: &str,
249    args: &[crate::config::ArgMapping],
250    options_type: Option<&str>,
251    field_resolver: &FieldResolver,
252    result_is_simple: bool,
253    enum_fields: &HashSet<String>,
254) -> String {
255    let mut out = String::new();
256    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
257    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
258
259    // If the class_name is fully qualified (contains '.'), import it and use
260    // only the simple name for method calls.  Otherwise use it as-is.
261    let (import_path, simple_class) = if class_name.contains('.') {
262        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
263        (class_name, simple)
264    } else {
265        ("", class_name)
266    };
267
268    let _ = writeln!(out, "package dev.kreuzberg.e2e;");
269    let _ = writeln!(out);
270
271    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
272    let needs_object_mapper_for_options = options_type.is_some()
273        && fixtures.iter().any(|f| {
274            args.iter()
275                .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
276        });
277    // Also need ObjectMapper when a handle arg has a non-null config.
278    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
279        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
280            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
281            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
282        })
283    });
284    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
285
286    let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
287    let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
288    if !import_path.is_empty() {
289        let _ = writeln!(out, "import {import_path};");
290    }
291    if needs_object_mapper {
292        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
293        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
294    }
295    // Import the options type if tests use it (it's in the same package as the main class).
296    if let Some(opts_type) = options_type {
297        if needs_object_mapper {
298            // Derive the fully-qualified name from the main class import path.
299            let opts_package = if !import_path.is_empty() {
300                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
301                format!("{pkg}.{opts_type}")
302            } else {
303                opts_type.to_string()
304            };
305            let _ = writeln!(out, "import {opts_package};");
306        }
307    }
308    // Import CrawlConfig when handle args need JSON deserialization.
309    if needs_object_mapper_for_handle && !import_path.is_empty() {
310        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
311        let _ = writeln!(out, "import {pkg}.CrawlConfig;");
312    }
313    let _ = writeln!(out);
314
315    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
316    let _ = writeln!(out, "class {test_class_name} {{");
317
318    if needs_object_mapper {
319        let _ = writeln!(out);
320        let _ = writeln!(
321            out,
322            "    private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
323        );
324    }
325
326    for fixture in fixtures {
327        render_test_method(
328            &mut out,
329            fixture,
330            simple_class,
331            function_name,
332            result_var,
333            args,
334            options_type,
335            field_resolver,
336            result_is_simple,
337            enum_fields,
338        );
339        let _ = writeln!(out);
340    }
341
342    let _ = writeln!(out, "}}");
343    out
344}
345
346#[allow(clippy::too_many_arguments)]
347fn render_test_method(
348    out: &mut String,
349    fixture: &Fixture,
350    class_name: &str,
351    function_name: &str,
352    result_var: &str,
353    args: &[crate::config::ArgMapping],
354    options_type: Option<&str>,
355    field_resolver: &FieldResolver,
356    result_is_simple: bool,
357    enum_fields: &HashSet<String>,
358) {
359    let method_name = fixture.id.to_upper_camel_case();
360    let description = &fixture.description;
361    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
362
363    // Check if this test needs ObjectMapper deserialization for json_object args.
364    let needs_deser = options_type.is_some()
365        && args
366            .iter()
367            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
368
369    // Always add throws Exception since the convert method may throw checked exceptions.
370    let throws_clause = " throws Exception";
371
372    let _ = writeln!(out, "    @Test");
373    let _ = writeln!(out, "    void test{method_name}(){throws_clause} {{");
374    let _ = writeln!(out, "        // {description}");
375
376    // Emit ObjectMapper deserialization bindings for json_object args.
377    if let (true, Some(opts_type)) = (needs_deser, options_type) {
378        for arg in args {
379            if arg.arg_type == "json_object" {
380                if let Some(val) = fixture.input.get(&arg.field) {
381                    if !val.is_null() {
382                        // Fixture keys are camelCase; the Java ConversionOptions record uses
383                        // @JsonProperty("snake_case") annotations. Normalize keys so Jackson
384                        // can deserialize them correctly.
385                        let normalized = super::normalize_json_keys_to_snake_case(val);
386                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
387                        let var_name = &arg.name;
388                        let _ = writeln!(
389                            out,
390                            "        var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
391                            escape_java(&json_str)
392                        );
393                    }
394                }
395            }
396        }
397    }
398
399    let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
400
401    for line in &setup_lines {
402        let _ = writeln!(out, "        {line}");
403    }
404
405    if expects_error {
406        let _ = writeln!(
407            out,
408            "        assertThrows(Exception.class, () -> {class_name}.{function_name}({args_str}));"
409        );
410        let _ = writeln!(out, "    }}");
411        return;
412    }
413
414    let _ = writeln!(
415        out,
416        "        var {result_var} = {class_name}.{function_name}({args_str});"
417    );
418
419    for assertion in &fixture.assertions {
420        render_assertion(
421            out,
422            assertion,
423            result_var,
424            field_resolver,
425            result_is_simple,
426            enum_fields,
427        );
428    }
429
430    let _ = writeln!(out, "    }}");
431}
432
433/// Build setup lines (e.g. handle creation) and the argument list for the function call.
434///
435/// Returns `(setup_lines, args_string)`.
436fn build_args_and_setup(
437    input: &serde_json::Value,
438    args: &[crate::config::ArgMapping],
439    class_name: &str,
440    options_type: Option<&str>,
441    fixture_id: &str,
442) -> (Vec<String>, String) {
443    if args.is_empty() {
444        return (Vec::new(), json_to_java(input));
445    }
446
447    let mut setup_lines: Vec<String> = Vec::new();
448    let mut parts: Vec<String> = Vec::new();
449
450    for arg in args {
451        if arg.arg_type == "mock_url" {
452            setup_lines.push(format!(
453                "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
454                arg.name,
455            ));
456            parts.push(arg.name.clone());
457            continue;
458        }
459
460        if arg.arg_type == "handle" {
461            // Generate a createEngine (or equivalent) call and pass the variable.
462            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
463            let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
464            if config_value.is_null()
465                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
466            {
467                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
468            } else {
469                let json_str = serde_json::to_string(config_value).unwrap_or_default();
470                let name = &arg.name;
471                setup_lines.push(format!(
472                    "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
473                    escape_java(&json_str),
474                ));
475                setup_lines.push(format!(
476                    "var {} = {class_name}.{constructor_name}({name}Config);",
477                    arg.name,
478                    name = name,
479                ));
480            }
481            parts.push(arg.name.clone());
482            continue;
483        }
484
485        let val = input.get(&arg.field);
486        match val {
487            None | Some(serde_json::Value::Null) if arg.optional => {
488                // Optional arg with no fixture value: skip entirely.
489                continue;
490            }
491            None | Some(serde_json::Value::Null) => {
492                // Required arg with no fixture value: pass a language-appropriate default.
493                let default_val = match arg.arg_type.as_str() {
494                    "string" => "\"\"".to_string(),
495                    "int" | "integer" => "0".to_string(),
496                    "float" | "number" => "0.0d".to_string(),
497                    "bool" | "boolean" => "false".to_string(),
498                    _ => "null".to_string(),
499                };
500                parts.push(default_val);
501            }
502            Some(v) => {
503                // For json_object args with options_type, use the pre-deserialized variable.
504                if arg.arg_type == "json_object" && options_type.is_some() {
505                    parts.push(arg.name.clone());
506                    continue;
507                }
508                parts.push(json_to_java(v));
509            }
510        }
511    }
512
513    (setup_lines, parts.join(", "))
514}
515
516fn render_assertion(
517    out: &mut String,
518    assertion: &Assertion,
519    result_var: &str,
520    field_resolver: &FieldResolver,
521    result_is_simple: bool,
522    enum_fields: &HashSet<String>,
523) {
524    // Skip assertions on fields that don't exist on the result type.
525    if let Some(f) = &assertion.field {
526        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
527            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
528            return;
529        }
530    }
531
532    // Determine if this field is an enum type (no `.contains()` on enums in Java).
533    // Check both the raw fixture field path and the resolved (aliased) path so that
534    // `fields_enum` entries can use either form (e.g., `"assets[].category"` or the
535    // resolved `"assets[].asset_category"`).
536    let field_is_enum = assertion
537        .field
538        .as_deref()
539        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
540
541    let field_expr = if result_is_simple {
542        result_var.to_string()
543    } else {
544        match &assertion.field {
545            Some(f) if !f.is_empty() => {
546                let accessor = field_resolver.accessor(f, "java", result_var);
547                let resolved = field_resolver.resolve(f);
548                // Unwrap Optional fields with .orElse("") for string comparisons.
549                // Map.get() returns nullable, not Optional, so skip .orElse() for map access.
550                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
551                    format!("{accessor}.orElse(\"\")")
552                } else {
553                    accessor
554                }
555            }
556            _ => result_var.to_string(),
557        }
558    };
559
560    // For enum fields, string-based assertions need .getValue() to convert the enum to
561    // its serde-serialized lowercase string value (e.g., AssetCategory.Image -> "image").
562    // All alef-generated Java enums expose a getValue() method annotated with @JsonValue.
563    let string_expr = if field_is_enum {
564        format!("{field_expr}.getValue()")
565    } else {
566        field_expr.clone()
567    };
568
569    match assertion.assertion_type.as_str() {
570        "equals" => {
571            if let Some(expected) = &assertion.value {
572                let java_val = json_to_java(expected);
573                if expected.is_string() {
574                    let _ = writeln!(out, "        assertEquals({java_val}, {string_expr}.trim());");
575                } else {
576                    let _ = writeln!(out, "        assertEquals({java_val}, {field_expr});");
577                }
578            }
579        }
580        "contains" => {
581            if let Some(expected) = &assertion.value {
582                let java_val = json_to_java(expected);
583                let _ = writeln!(
584                    out,
585                    "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
586                );
587            }
588        }
589        "contains_all" => {
590            if let Some(values) = &assertion.values {
591                for val in values {
592                    let java_val = json_to_java(val);
593                    let _ = writeln!(
594                        out,
595                        "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
596                    );
597                }
598            }
599        }
600        "not_contains" => {
601            if let Some(expected) = &assertion.value {
602                let java_val = json_to_java(expected);
603                let _ = writeln!(
604                    out,
605                    "        assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
606                );
607            }
608        }
609        "not_empty" => {
610            let _ = writeln!(
611                out,
612                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
613            );
614        }
615        "is_empty" => {
616            let _ = writeln!(
617                out,
618                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
619            );
620        }
621        "contains_any" => {
622            if let Some(values) = &assertion.values {
623                let checks: Vec<String> = values
624                    .iter()
625                    .map(|v| {
626                        let java_val = json_to_java(v);
627                        format!("{string_expr}.contains({java_val})")
628                    })
629                    .collect();
630                let joined = checks.join(" || ");
631                let _ = writeln!(
632                    out,
633                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\");"
634                );
635            }
636        }
637        "greater_than" => {
638            if let Some(val) = &assertion.value {
639                let java_val = json_to_java(val);
640                let _ = writeln!(
641                    out,
642                    "        assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
643                );
644            }
645        }
646        "less_than" => {
647            if let Some(val) = &assertion.value {
648                let java_val = json_to_java(val);
649                let _ = writeln!(
650                    out,
651                    "        assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
652                );
653            }
654        }
655        "greater_than_or_equal" => {
656            if let Some(val) = &assertion.value {
657                let java_val = json_to_java(val);
658                let _ = writeln!(
659                    out,
660                    "        assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
661                );
662            }
663        }
664        "less_than_or_equal" => {
665            if let Some(val) = &assertion.value {
666                let java_val = json_to_java(val);
667                let _ = writeln!(
668                    out,
669                    "        assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
670                );
671            }
672        }
673        "starts_with" => {
674            if let Some(expected) = &assertion.value {
675                let java_val = json_to_java(expected);
676                let _ = writeln!(
677                    out,
678                    "        assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
679                );
680            }
681        }
682        "ends_with" => {
683            if let Some(expected) = &assertion.value {
684                let java_val = json_to_java(expected);
685                let _ = writeln!(
686                    out,
687                    "        assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
688                );
689            }
690        }
691        "min_length" => {
692            if let Some(val) = &assertion.value {
693                if let Some(n) = val.as_u64() {
694                    let _ = writeln!(
695                        out,
696                        "        assertTrue({field_expr}.length() >= {n}, \"expected length >= {n}\");"
697                    );
698                }
699            }
700        }
701        "max_length" => {
702            if let Some(val) = &assertion.value {
703                if let Some(n) = val.as_u64() {
704                    let _ = writeln!(
705                        out,
706                        "        assertTrue({field_expr}.length() <= {n}, \"expected length <= {n}\");"
707                    );
708                }
709            }
710        }
711        "count_min" => {
712            if let Some(val) = &assertion.value {
713                if let Some(n) = val.as_u64() {
714                    let _ = writeln!(
715                        out,
716                        "        assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
717                    );
718                }
719            }
720        }
721        "not_error" => {
722            // Already handled by the call succeeding without exception.
723        }
724        "error" => {
725            // Handled at the test method level.
726        }
727        other => {
728            let _ = writeln!(out, "        // TODO: unsupported assertion type: {other}");
729        }
730    }
731}
732
733/// Convert a `serde_json::Value` to a Java literal string.
734fn json_to_java(value: &serde_json::Value) -> String {
735    match value {
736        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
737        serde_json::Value::Bool(b) => b.to_string(),
738        serde_json::Value::Number(n) => {
739            if n.is_f64() {
740                format!("{}d", n)
741            } else {
742                n.to_string()
743            }
744        }
745        serde_json::Value::Null => "null".to_string(),
746        serde_json::Value::Array(arr) => {
747            let items: Vec<String> = arr.iter().map(json_to_java).collect();
748            format!("java.util.List.of({})", items.join(", "))
749        }
750        serde_json::Value::Object(_) => {
751            let json_str = serde_json::to_string(value).unwrap_or_default();
752            format!("\"{}\"", escape_java(&json_str))
753        }
754    }
755}