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