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, CallbackAction, Fixture, FixtureGroup, HttpFixture};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20use super::client;
21
22/// Java e2e code generator.
23pub struct JavaCodegen;
24
25impl E2eCodegen for JavaCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        config: &ResolvedCrateConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let _module_path = overrides
41            .and_then(|o| o.module.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.module.clone());
44        let function_name = overrides
45            .and_then(|o| o.function.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.function.clone());
48        let class_name = overrides
49            .and_then(|o| o.class.as_ref())
50            .cloned()
51            .unwrap_or_else(|| config.name.to_upper_camel_case());
52        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
53        let result_var = &call.result_var;
54
55        // Resolve package config.
56        let java_pkg = e2e_config.resolve_package("java");
57        let pkg_name = java_pkg
58            .as_ref()
59            .and_then(|p| p.name.as_ref())
60            .cloned()
61            .unwrap_or_else(|| config.name.clone());
62
63        // Resolve Java package info for the dependency.
64        let java_group_id = config.java_group_id();
65        let pkg_version = config.resolved_version().unwrap_or_else(|| "0.1.0".to_string());
66
67        // Generate pom.xml.
68        files.push(GeneratedFile {
69            path: output_base.join("pom.xml"),
70            content: render_pom_xml(&pkg_name, &java_group_id, &pkg_version, e2e_config.dep_mode),
71            generated_header: false,
72        });
73
74        // Generate test files per category. Path mirrors the configured Java
75        // package — `dev.myorg` becomes `dev/myorg`, etc. — so the package
76        // declaration in each test file matches its filesystem location.
77        let mut test_base = output_base.join("src").join("test").join("java");
78        for segment in java_group_id.split('.') {
79            test_base = test_base.join(segment);
80        }
81        let test_base = test_base.join("e2e");
82
83        // Resolve options_type from override.
84        let options_type = overrides.and_then(|o| o.options_type.clone());
85
86        // Get Java-specific enum_fields from override (required for correct enum handling).
87        let empty_enum_fields = std::collections::HashMap::new();
88        let java_enum_fields = overrides.as_ref().map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
89
90        // Build effective nested_types by merging defaults with configured overrides.
91        let mut effective_nested_types = default_java_nested_types();
92        if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
93            effective_nested_types.extend(overrides_map.clone());
94        }
95
96        // Resolve nested_types_optional from override (defaults to true for backward compatibility).
97        let nested_types_optional = overrides.map(|o| o.nested_types_optional).unwrap_or(true);
98
99        let field_resolver = FieldResolver::new(
100            &e2e_config.fields,
101            &e2e_config.fields_optional,
102            &e2e_config.result_fields,
103            &e2e_config.fields_array,
104            &std::collections::HashSet::new(),
105        );
106
107        for group in groups {
108            let active: Vec<&Fixture> = group
109                .fixtures
110                .iter()
111                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
112                .collect();
113
114            if active.is_empty() {
115                continue;
116            }
117
118            let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
119            let content = render_test_file(
120                &group.category,
121                &active,
122                &class_name,
123                &function_name,
124                &java_group_id,
125                result_var,
126                &e2e_config.call.args,
127                options_type.as_deref(),
128                &field_resolver,
129                result_is_simple,
130                java_enum_fields,
131                e2e_config,
132                &effective_nested_types,
133                nested_types_optional,
134            );
135            files.push(GeneratedFile {
136                path: test_base.join(class_file_name),
137                content,
138                generated_header: true,
139            });
140        }
141
142        Ok(files)
143    }
144
145    fn language_name(&self) -> &'static str {
146        "java"
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Rendering
152// ---------------------------------------------------------------------------
153
154fn render_pom_xml(
155    pkg_name: &str,
156    java_group_id: &str,
157    pkg_version: &str,
158    dep_mode: crate::config::DependencyMode,
159) -> String {
160    // pkg_name may be in "groupId:artifactId" Maven format; split accordingly.
161    let (dep_group_id, dep_artifact_id) = if let Some((g, a)) = pkg_name.split_once(':') {
162        (g, a)
163    } else {
164        (java_group_id, pkg_name)
165    };
166    let artifact_id = format!("{dep_artifact_id}-e2e-java");
167    let dep_block = match dep_mode {
168        crate::config::DependencyMode::Registry => {
169            format!(
170                r#"        <dependency>
171            <groupId>{dep_group_id}</groupId>
172            <artifactId>{dep_artifact_id}</artifactId>
173            <version>{pkg_version}</version>
174        </dependency>"#
175            )
176        }
177        crate::config::DependencyMode::Local => {
178            format!(
179                r#"        <dependency>
180            <groupId>{dep_group_id}</groupId>
181            <artifactId>{dep_artifact_id}</artifactId>
182            <version>{pkg_version}</version>
183            <scope>system</scope>
184            <systemPath>${{project.basedir}}/../../packages/java/target/{dep_artifact_id}-{pkg_version}.jar</systemPath>
185        </dependency>"#
186            )
187        }
188    };
189    format!(
190        r#"<?xml version="1.0" encoding="UTF-8"?>
191<project xmlns="http://maven.apache.org/POM/4.0.0"
192         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
193         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
194    <modelVersion>4.0.0</modelVersion>
195
196    <groupId>{java_group_id}</groupId>
197    <artifactId>{artifact_id}</artifactId>
198    <version>0.1.0</version>
199
200    <properties>
201        <maven.compiler.source>25</maven.compiler.source>
202        <maven.compiler.target>25</maven.compiler.target>
203        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
204        <junit.version>{junit}</junit.version>
205    </properties>
206
207    <dependencies>
208{dep_block}
209        <dependency>
210            <groupId>com.fasterxml.jackson.core</groupId>
211            <artifactId>jackson-databind</artifactId>
212            <version>{jackson}</version>
213        </dependency>
214        <dependency>
215            <groupId>com.fasterxml.jackson.datatype</groupId>
216            <artifactId>jackson-datatype-jdk8</artifactId>
217            <version>{jackson}</version>
218        </dependency>
219        <dependency>
220            <groupId>org.jetbrains</groupId>
221            <artifactId>annotations</artifactId>
222            <version>24.1.0</version>
223        </dependency>
224        <dependency>
225            <groupId>org.junit.jupiter</groupId>
226            <artifactId>junit-jupiter</artifactId>
227            <version>${{junit.version}}</version>
228            <scope>test</scope>
229        </dependency>
230    </dependencies>
231
232    <build>
233        <plugins>
234            <plugin>
235                <groupId>org.codehaus.mojo</groupId>
236                <artifactId>build-helper-maven-plugin</artifactId>
237                <version>{build_helper}</version>
238                <executions>
239                    <execution>
240                        <id>add-test-source</id>
241                        <phase>generate-test-sources</phase>
242                        <goals>
243                            <goal>add-test-source</goal>
244                        </goals>
245                        <configuration>
246                            <sources>
247                                <source>src/test/java</source>
248                            </sources>
249                        </configuration>
250                    </execution>
251                </executions>
252            </plugin>
253            <plugin>
254                <groupId>org.apache.maven.plugins</groupId>
255                <artifactId>maven-surefire-plugin</artifactId>
256                <version>{maven_surefire}</version>
257                <configuration>
258                    <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=${{project.basedir}}/../../target/release</argLine>
259                    <workingDirectory>${{project.basedir}}/../../test_documents</workingDirectory>
260                </configuration>
261            </plugin>
262        </plugins>
263    </build>
264</project>
265"#,
266        junit = tv::maven::JUNIT,
267        jackson = tv::maven::JACKSON_E2E,
268        build_helper = tv::maven::BUILD_HELPER_MAVEN_PLUGIN,
269        maven_surefire = tv::maven::MAVEN_SUREFIRE_PLUGIN_E2E,
270    )
271}
272
273#[allow(clippy::too_many_arguments)]
274fn render_test_file(
275    category: &str,
276    fixtures: &[&Fixture],
277    class_name: &str,
278    function_name: &str,
279    java_group_id: &str,
280    result_var: &str,
281    args: &[crate::config::ArgMapping],
282    options_type: Option<&str>,
283    field_resolver: &FieldResolver,
284    result_is_simple: bool,
285    enum_fields: &std::collections::HashMap<String, String>,
286    e2e_config: &E2eConfig,
287    nested_types: &std::collections::HashMap<String, String>,
288    nested_types_optional: bool,
289) -> String {
290    let mut out = String::new();
291    out.push_str(&hash::header(CommentStyle::DoubleSlash));
292    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
293
294    // If the class_name is fully qualified (contains '.'), import it and use
295    // only the simple name for method calls.  Otherwise use it as-is.
296    let (import_path, simple_class) = if class_name.contains('.') {
297        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
298        (class_name, simple)
299    } else {
300        ("", class_name)
301    };
302
303    let _ = writeln!(out, "package {java_group_id}.e2e;");
304    let _ = writeln!(out);
305
306    // Check if any fixture (with its resolved call) will emit MAPPER usage.
307    // Note: we no longer use MAPPER for json_object options (using builder pattern instead).
308    // But we still need it for handle args and HTTP fixtures.
309    let lang_for_om = "java";
310    let _needs_object_mapper_for_options = false;
311    // Also need ObjectMapper when a handle arg has a non-null config.
312    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
313        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
314            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
315            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
316        })
317    });
318    // HTTP fixtures always need ObjectMapper for JSON body comparison.
319    let has_http_fixtures = fixtures.iter().any(|f| f.http.is_some());
320    let needs_object_mapper = needs_object_mapper_for_handle || has_http_fixtures;
321
322    // Collect all options_type values used (class-level + per-fixture call overrides).
323    let mut all_options_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
324    if let Some(t) = options_type {
325        all_options_types.insert(t.to_string());
326    }
327    for f in fixtures.iter() {
328        let call_cfg = e2e_config.resolve_call(f.call.as_deref());
329        if let Some(ov) = call_cfg.overrides.get(lang_for_om) {
330            if let Some(t) = &ov.options_type {
331                all_options_types.insert(t.clone());
332            }
333        }
334        // Detect batch item types used in this fixture
335        for arg in &call_cfg.args {
336            if let Some(elem_type) = &arg.element_type {
337                if elem_type == "BatchBytesItem" || elem_type == "BatchFileItem" {
338                    all_options_types.insert(elem_type.clone());
339                }
340            }
341        }
342    }
343
344    let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
345    let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
346    if !import_path.is_empty() {
347        let _ = writeln!(out, "import {import_path};");
348    }
349    if needs_object_mapper {
350        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
351        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
352    }
353    // Collect all enum types used in builder expressions across all fixtures.
354    let mut enum_types_used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
355    // Collect nested config types actually referenced in fixture builder expressions
356    // (rather than importing all defaults unconditionally, which causes javac errors
357    // when a type like ChunkingConfig doesn't exist in the binding's package).
358    let mut nested_types_used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
359    for f in fixtures.iter() {
360        let call_cfg = e2e_config.resolve_call(f.call.as_deref());
361        for arg in &call_cfg.args {
362            if arg.arg_type == "json_object" {
363                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
364                if let Some(val) = f.input.get(field) {
365                    if !val.is_null() && !val.is_array() {
366                        if let Some(obj) = val.as_object() {
367                            collect_enum_and_nested_types(obj, enum_fields, &mut enum_types_used);
368                            collect_nested_type_names(obj, nested_types, &mut nested_types_used);
369                        }
370                    }
371                }
372            }
373        }
374    }
375
376    // Import all options types used across fixtures (for builder expressions and MAPPER).
377    if !all_options_types.is_empty() {
378        let opts_pkg = if !import_path.is_empty() {
379            import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("")
380        } else {
381            ""
382        };
383        for opts_type in &all_options_types {
384            let qualified = if opts_pkg.is_empty() {
385                opts_type.clone()
386            } else {
387                format!("{opts_pkg}.{opts_type}")
388            };
389            let _ = writeln!(out, "import {qualified};");
390        }
391    }
392
393    // Import all enum types used in builder expressions
394    if !enum_types_used.is_empty() && !import_path.is_empty() {
395        let binding_pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
396        for enum_type in &enum_types_used {
397            let _ = writeln!(out, "import {binding_pkg}.{enum_type};");
398        }
399    }
400
401    // Import only the nested options types that are actually referenced in fixture
402    // builder expressions. Using `nested_types_used` (populated above) rather than
403    // all `nested_types.values()` avoids javac `cannot find symbol` errors for types
404    // like ChunkingConfig that exist in other Kreuzberg bindings but not this one.
405    if !nested_types_used.is_empty() && !import_path.is_empty() {
406        let binding_pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
407        for type_name in &nested_types_used {
408            let _ = writeln!(out, "import {binding_pkg}.{type_name};");
409        }
410    }
411
412    // Import CrawlConfig when handle args need JSON deserialization.
413    if needs_object_mapper_for_handle && !import_path.is_empty() {
414        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
415        let _ = writeln!(out, "import {pkg}.CrawlConfig;");
416    }
417    // Import visitor types when any fixture uses visitor callbacks.
418    let has_visitor_fixtures = fixtures.iter().any(|f| f.visitor.is_some());
419    if has_visitor_fixtures && !import_path.is_empty() {
420        let binding_pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
421        if !binding_pkg.is_empty() {
422            let _ = writeln!(out, "import {binding_pkg}.Visitor;");
423            let _ = writeln!(out, "import {binding_pkg}.NodeContext;");
424            let _ = writeln!(out, "import {binding_pkg}.VisitResult;");
425        }
426    }
427    // Import Optional when using builder expressions with optional fields
428    if !all_options_types.is_empty() {
429        let _ = writeln!(out, "import java.util.Optional;");
430    }
431    let _ = writeln!(out);
432
433    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
434    let _ = writeln!(out, "class {test_class_name} {{");
435
436    if needs_object_mapper {
437        let _ = writeln!(out);
438        let _ = writeln!(
439            out,
440            "    private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
441        );
442    }
443
444    for fixture in fixtures {
445        render_test_method(
446            &mut out,
447            fixture,
448            simple_class,
449            function_name,
450            result_var,
451            args,
452            options_type,
453            field_resolver,
454            result_is_simple,
455            enum_fields,
456            e2e_config,
457            nested_types,
458            nested_types_optional,
459        );
460        let _ = writeln!(out);
461    }
462
463    let _ = writeln!(out, "}}");
464    out
465}
466
467// ---------------------------------------------------------------------------
468// HTTP test rendering — shared-driver integration
469// ---------------------------------------------------------------------------
470
471/// Thin renderer that emits JUnit 5 test methods targeting a mock server via
472/// `java.net.http.HttpClient`. Satisfies [`client::TestClientRenderer`] so the
473/// shared [`client::http_call::render_http_test`] driver drives the call sequence.
474struct JavaTestClientRenderer;
475
476impl client::TestClientRenderer for JavaTestClientRenderer {
477    fn language_name(&self) -> &'static str {
478        "java"
479    }
480
481    /// Convert a fixture id to the UpperCamelCase suffix appended to `test`.
482    ///
483    /// The emitted method name is `test{fn_name}`, matching the pre-existing shape.
484    fn sanitize_test_name(&self, id: &str) -> String {
485        id.to_upper_camel_case()
486    }
487
488    /// Emit `@Test void test{fn_name}() throws Exception {`.
489    ///
490    /// When `skip_reason` is `Some`, the body is a single
491    /// `Assumptions.assumeTrue(false, ...)` call and `render_test_close` closes
492    /// the brace symmetrically.
493    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
494        let _ = writeln!(out, "    @Test");
495        if let Some(reason) = skip_reason {
496            let escaped_reason = escape_java(reason);
497            let _ = writeln!(out, "    void test{fn_name}() {{");
498            let _ = writeln!(out, "        // {description}");
499            let _ = writeln!(
500                out,
501                "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped_reason}\");"
502            );
503        } else {
504            let _ = writeln!(out, "    void test{fn_name}() throws Exception {{");
505            let _ = writeln!(out, "        // {description}");
506            // Resolve base URL once at the top of every non-skipped test.
507            let _ = writeln!(out, "        String baseUrl = System.getenv(\"MOCK_SERVER_URL\");");
508            let _ = writeln!(out, "        if (baseUrl == null) baseUrl = \"http://localhost:8080\";");
509        }
510    }
511
512    /// Emit the closing `}` for a test method.
513    fn render_test_close(&self, out: &mut String) {
514        let _ = writeln!(out, "    }}");
515    }
516
517    /// Emit a `java.net.http.HttpClient` request to `baseUrl + path`.
518    ///
519    /// Binds the response to `response` (the `ctx.response_var`). Java's
520    /// `HttpClient` disallows a fixed set of restricted headers; those are
521    /// silently dropped so the test compiles.
522    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
523        // Java's HttpClient throws IllegalArgumentException for these headers.
524        const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
525
526        let method = ctx.method.to_uppercase();
527
528        // Build the path, appending query params when present.
529        let path = if ctx.query_params.is_empty() {
530            ctx.path.to_string()
531        } else {
532            let pairs: Vec<String> = ctx
533                .query_params
534                .iter()
535                .map(|(k, v)| {
536                    let val_str = match v {
537                        serde_json::Value::String(s) => s.clone(),
538                        other => other.to_string(),
539                    };
540                    format!("{}={}", k, escape_java(&val_str))
541                })
542                .collect();
543            format!("{}?{}", ctx.path, pairs.join("&"))
544        };
545        let _ = writeln!(
546            out,
547            "        java.net.URI uri = java.net.URI.create(baseUrl + \"{path}\");"
548        );
549
550        let body_publisher = if let Some(body) = ctx.body {
551            let json = serde_json::to_string(body).unwrap_or_default();
552            let escaped = escape_java(&json);
553            format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
554        } else {
555            "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
556        };
557
558        let _ = writeln!(out, "        var builder = java.net.http.HttpRequest.newBuilder(uri)");
559        let _ = writeln!(out, "            .method(\"{method}\", {body_publisher});");
560
561        // Content-Type header — only when a body is present.
562        if ctx.body.is_some() {
563            let content_type = ctx.content_type.unwrap_or("application/json");
564            // Only emit when not already in ctx.headers (avoid duplicate Content-Type).
565            if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
566                let _ = writeln!(
567                    out,
568                    "        builder = builder.header(\"Content-Type\", \"{content_type}\");"
569                );
570            }
571        }
572
573        // Explicit request headers — skip Java-restricted ones.
574        for (name, value) in ctx.headers {
575            if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
576                continue;
577            }
578            let escaped_name = escape_java(name);
579            let escaped_value = escape_java(value);
580            let _ = writeln!(
581                out,
582                "        builder = builder.header(\"{escaped_name}\", \"{escaped_value}\");"
583            );
584        }
585
586        // Cookies as a single `Cookie` header.
587        if !ctx.cookies.is_empty() {
588            let cookie_str: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
589            let cookie_header = escape_java(&cookie_str.join("; "));
590            let _ = writeln!(
591                out,
592                "        builder = builder.header(\"Cookie\", \"{cookie_header}\");"
593            );
594        }
595
596        let response_var = ctx.response_var;
597        let _ = writeln!(
598            out,
599            "        var {response_var} = java.net.http.HttpClient.newHttpClient()"
600        );
601        let _ = writeln!(
602            out,
603            "            .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString());"
604        );
605    }
606
607    /// Emit `assertEquals(status, response.statusCode(), ...)`.
608    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
609        let _ = writeln!(
610            out,
611            "        assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\");"
612        );
613    }
614
615    /// Emit a header assertion using `response.headers().firstValue(...)`.
616    ///
617    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
618    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
619        let escaped_name = escape_java(name);
620        match expected {
621            "<<present>>" => {
622                let _ = writeln!(
623                    out,
624                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent(), \"header {escaped_name} should be present\");"
625                );
626            }
627            "<<absent>>" => {
628                let _ = writeln!(
629                    out,
630                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isEmpty(), \"header {escaped_name} should be absent\");"
631                );
632            }
633            "<<uuid>>" => {
634                let _ = writeln!(
635                    out,
636                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").matches(\"[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}\"), \"header {escaped_name} should be a UUID\");"
637                );
638            }
639            literal => {
640                let escaped_value = escape_java(literal);
641                let _ = writeln!(
642                    out,
643                    "        assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\");"
644                );
645            }
646        }
647    }
648
649    /// Emit a JSON body equality assertion using Jackson's `MAPPER.readTree`.
650    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
651        match expected {
652            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
653                let json_str = serde_json::to_string(expected).unwrap_or_default();
654                let escaped = escape_java(&json_str);
655                let _ = writeln!(out, "        var bodyJson = MAPPER.readTree({response_var}.body());");
656                let _ = writeln!(out, "        var expectedJson = MAPPER.readTree(\"{escaped}\");");
657                let _ = writeln!(out, "        assertEquals(expectedJson, bodyJson, \"body mismatch\");");
658            }
659            serde_json::Value::String(s) => {
660                let escaped = escape_java(s);
661                let _ = writeln!(
662                    out,
663                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\");"
664                );
665            }
666            other => {
667                let escaped = escape_java(&other.to_string());
668                let _ = writeln!(
669                    out,
670                    "        assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\");"
671                );
672            }
673        }
674    }
675
676    /// Emit partial JSON body assertions: parse once, then assert each expected field.
677    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
678        if let Some(obj) = expected.as_object() {
679            let _ = writeln!(out, "        var partialJson = MAPPER.readTree({response_var}.body());");
680            for (key, val) in obj {
681                let escaped_key = escape_java(key);
682                let json_str = serde_json::to_string(val).unwrap_or_default();
683                let escaped_val = escape_java(&json_str);
684                let _ = writeln!(
685                    out,
686                    "        assertEquals(MAPPER.readTree(\"{escaped_val}\"), partialJson.get(\"{escaped_key}\"), \"body field '{escaped_key}' mismatch\");"
687                );
688            }
689        }
690    }
691
692    /// Emit validation-error assertions: parse the body and check each expected message.
693    fn render_assert_validation_errors(
694        &self,
695        out: &mut String,
696        response_var: &str,
697        errors: &[crate::fixture::ValidationErrorExpectation],
698    ) {
699        let _ = writeln!(out, "        var veBody = {response_var}.body();");
700        for err in errors {
701            let escaped_msg = escape_java(&err.msg);
702            let _ = writeln!(
703                out,
704                "        assertTrue(veBody.contains(\"{escaped_msg}\"), \"expected validation error message: {escaped_msg}\");"
705            );
706        }
707    }
708}
709
710/// Render an HTTP server test method using `java.net.http.HttpClient` against
711/// `MOCK_SERVER_URL`. Delegates to the shared
712/// [`client::http_call::render_http_test`] driver via [`JavaTestClientRenderer`].
713///
714/// The one Java-specific pre-condition — HTTP 101 (WebSocket upgrade) causing an
715/// `EOFException` in `HttpClient` — is handled here before delegating.
716fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
717    // HTTP 101 (WebSocket upgrade) causes Java's HttpClient to throw EOFException.
718    // Emit an assumeTrue(false, ...) stub so the test is skipped rather than failing.
719    if http.expected_response.status_code == 101 {
720        let method_name = fixture.id.to_upper_camel_case();
721        let description = &fixture.description;
722        let _ = writeln!(out, "    @Test");
723        let _ = writeln!(out, "    void test{method_name}() {{");
724        let _ = writeln!(out, "        // {description}");
725        let _ = writeln!(
726            out,
727            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\");"
728        );
729        let _ = writeln!(out, "    }}");
730        return;
731    }
732
733    client::http_call::render_http_test(out, &JavaTestClientRenderer, fixture);
734}
735
736#[allow(clippy::too_many_arguments)]
737fn render_test_method(
738    out: &mut String,
739    fixture: &Fixture,
740    class_name: &str,
741    _function_name: &str,
742    _result_var: &str,
743    _args: &[crate::config::ArgMapping],
744    options_type: Option<&str>,
745    field_resolver: &FieldResolver,
746    result_is_simple: bool,
747    enum_fields: &std::collections::HashMap<String, String>,
748    e2e_config: &E2eConfig,
749    nested_types: &std::collections::HashMap<String, String>,
750    nested_types_optional: bool,
751) {
752    // Delegate HTTP fixtures to the HTTP-specific renderer.
753    if let Some(http) = &fixture.http {
754        render_http_test_method(out, fixture, http);
755        return;
756    }
757
758    // Resolve per-fixture call config (supports named calls via fixture.call field).
759    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
760    let lang = "java";
761    let call_overrides = call_config.overrides.get(lang);
762    let effective_function_name = call_overrides
763        .and_then(|o| o.function.as_ref())
764        .cloned()
765        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
766    let effective_result_var = &call_config.result_var;
767    let effective_args = &call_config.args;
768    let function_name = effective_function_name.as_str();
769    let result_var = effective_result_var.as_str();
770    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
771
772    let method_name = fixture.id.to_upper_camel_case();
773    let description = &fixture.description;
774    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
775
776    // Resolve per-fixture options_type: prefer the java call override, fall back to class-level.
777    let effective_options_type: Option<String> = call_overrides
778        .and_then(|o| o.options_type.clone())
779        .or_else(|| options_type.map(|s| s.to_string()));
780    let effective_options_type = effective_options_type.as_deref();
781
782    // Resolve per-fixture result_is_simple and result_is_bytes from the call override.
783    let effective_result_is_simple =
784        call_overrides.is_some_and(|o| o.result_is_simple) || call_config.result_is_simple || result_is_simple;
785    let effective_result_is_bytes = call_overrides.is_some_and(|o| o.result_is_bytes);
786
787    // Check if this test needs ObjectMapper deserialization for json_object args.
788    // Strip "input." prefix when looking up field in fixture.input.
789    let needs_deser = effective_options_type.is_some()
790        && args.iter().any(|arg| {
791            if arg.arg_type != "json_object" {
792                return false;
793            }
794            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
795            fixture.input.get(field).is_some_and(|v| !v.is_null() && !v.is_array())
796        });
797
798    // Always add throws Exception since the convert method may throw checked exceptions.
799    let throws_clause = " throws Exception";
800
801    let _ = writeln!(out, "    @Test");
802    let _ = writeln!(out, "    void test{method_name}(){throws_clause} {{");
803    let _ = writeln!(out, "        // {description}");
804
805    // Emit builder expressions for json_object args.
806    if let (true, Some(opts_type)) = (needs_deser, effective_options_type) {
807        for arg in args {
808            if arg.arg_type == "json_object" {
809                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
810                if let Some(val) = fixture.input.get(field) {
811                    if !val.is_null() && !val.is_array() {
812                        if let Some(obj) = val.as_object() {
813                            // Generate builder expression: TypeName.builder().withFieldName(value)...build()
814                            let empty_path_fields: Vec<String> = Vec::new();
815                            let path_fields = call_overrides.map(|o| &o.path_fields).unwrap_or(&empty_path_fields);
816                            let builder_expr = java_builder_expression(
817                                obj,
818                                opts_type,
819                                enum_fields,
820                                nested_types,
821                                nested_types_optional,
822                                path_fields,
823                            );
824                            let var_name = &arg.name;
825                            let _ = writeln!(out, "        var {var_name} = {builder_expr};");
826                        }
827                    }
828                }
829            }
830        }
831    }
832
833    let (mut setup_lines, args_str) =
834        build_args_and_setup(&fixture.input, args, class_name, effective_options_type, &fixture.id);
835
836    // Build visitor if present and add to setup
837    let mut visitor_var = String::new();
838    let mut has_visitor_fixture = false;
839    if let Some(visitor_spec) = &fixture.visitor {
840        visitor_var = build_java_visitor(&mut setup_lines, visitor_spec, class_name);
841        has_visitor_fixture = true;
842    }
843
844    for line in &setup_lines {
845        let _ = writeln!(out, "        {line}");
846    }
847
848    // When visitor is present, attach it to the options parameter
849    let final_args = if has_visitor_fixture {
850        if args_str.is_empty() {
851            // No arguments: just create ConversionOptions with visitor
852            format!("new ConversionOptions().withVisitor({})", visitor_var)
853        } else if args_str.contains("new ConversionOptions")
854            || args_str.contains("ConversionOptionsBuilder")
855            || args_str.contains(".builder()")
856        {
857            // Options are being built (either new ConversionOptions(), builder pattern, or .builder().build())
858            // append .withVisitor() call before .build() if present
859            if args_str.contains(".build()") {
860                // Insert .withVisitor() before the final .build()
861                let idx = args_str.rfind(".build()").unwrap();
862                format!("{}.withVisitor({}){}", &args_str[..idx], visitor_var, &args_str[idx..])
863            } else {
864                // Already a chain, just append
865                format!("{}.withVisitor({})", args_str, visitor_var)
866            }
867        } else if args_str.ends_with(", null") {
868            // Replace trailing null options with ConversionOptions containing visitor
869            let base = &args_str[..args_str.len() - 6];
870            format!("{}, new ConversionOptions().withVisitor({})", base, visitor_var)
871        } else {
872            // args_str is just the html argument(s) — append new ConversionOptions with visitor
873            format!("{}, new ConversionOptions().withVisitor({})", args_str, visitor_var)
874        }
875    } else {
876        args_str
877    };
878
879    if expects_error {
880        let _ = writeln!(
881            out,
882            "        assertThrows(Exception.class, () -> {class_name}.{function_name}({final_args}));"
883        );
884        let _ = writeln!(out, "    }}");
885        return;
886    }
887
888    if call_config.returns_void {
889        let _ = writeln!(out, "        {class_name}.{function_name}({final_args});");
890        let _ = writeln!(out, "    }}");
891        return;
892    }
893
894    let _ = writeln!(
895        out,
896        "        var {result_var} = {class_name}.{function_name}({final_args});"
897    );
898
899    // Emit a `source` variable for run_query assertions that need the raw bytes.
900    let needs_source_var = fixture
901        .assertions
902        .iter()
903        .any(|a| a.assertion_type == "method_result" && a.method.as_deref() == Some("run_query"));
904    if needs_source_var {
905        // Find the source_code arg to emit a `source` binding.
906        if let Some(source_arg) = args.iter().find(|a| a.field == "source_code") {
907            let field = source_arg.field.strip_prefix("input.").unwrap_or(&source_arg.field);
908            if let Some(val) = fixture.input.get(field) {
909                let java_val = json_to_java(val);
910                let _ = writeln!(out, "        var source = {java_val}.getBytes();");
911            }
912        }
913    }
914
915    for assertion in &fixture.assertions {
916        render_assertion(
917            out,
918            assertion,
919            result_var,
920            class_name,
921            field_resolver,
922            effective_result_is_simple,
923            effective_result_is_bytes,
924            enum_fields,
925        );
926    }
927
928    let _ = writeln!(out, "    }}");
929}
930
931/// Build setup lines (e.g. handle creation) and the argument list for the function call.
932///
933/// Returns `(setup_lines, args_string)`.
934fn build_args_and_setup(
935    input: &serde_json::Value,
936    args: &[crate::config::ArgMapping],
937    class_name: &str,
938    options_type: Option<&str>,
939    fixture_id: &str,
940) -> (Vec<String>, String) {
941    if args.is_empty() {
942        return (Vec::new(), String::new());
943    }
944
945    let mut setup_lines: Vec<String> = Vec::new();
946    let mut parts: Vec<String> = Vec::new();
947
948    for arg in args {
949        if arg.arg_type == "mock_url" {
950            setup_lines.push(format!(
951                "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
952                arg.name,
953            ));
954            parts.push(arg.name.clone());
955            continue;
956        }
957
958        if arg.arg_type == "handle" {
959            // Generate a createEngine (or equivalent) call and pass the variable.
960            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
961            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
962            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
963            if config_value.is_null()
964                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
965            {
966                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
967            } else {
968                let json_str = serde_json::to_string(config_value).unwrap_or_default();
969                let name = &arg.name;
970                setup_lines.push(format!(
971                    "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
972                    escape_java(&json_str),
973                ));
974                setup_lines.push(format!(
975                    "var {} = {class_name}.{constructor_name}({name}Config);",
976                    arg.name,
977                    name = name,
978                ));
979            }
980            parts.push(arg.name.clone());
981            continue;
982        }
983
984        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
985        let val = input.get(field);
986        match val {
987            None | Some(serde_json::Value::Null) if arg.optional => {
988                // Optional arg with no fixture value: emit positional null/default so the call
989                // has the right arity. For json_object optional args, build an empty default object
990                // so we get the right type rather than a raw null.
991                if arg.arg_type == "json_object" {
992                    if let Some(opts_type) = options_type {
993                        parts.push(format!("{opts_type}.builder().build()"));
994                    } else {
995                        parts.push("null".to_string());
996                    }
997                } else {
998                    parts.push("null".to_string());
999                }
1000            }
1001            None | Some(serde_json::Value::Null) => {
1002                // Required arg with no fixture value: pass a language-appropriate default.
1003                let default_val = match arg.arg_type.as_str() {
1004                    "string" | "file_path" => "\"\"".to_string(),
1005                    "int" | "integer" => "0".to_string(),
1006                    "float" | "number" => "0.0d".to_string(),
1007                    "bool" | "boolean" => "false".to_string(),
1008                    _ => "null".to_string(),
1009                };
1010                parts.push(default_val);
1011            }
1012            Some(v) => {
1013                if arg.arg_type == "json_object" {
1014                    // Array json_object args: emit inline Java list expression.
1015                    // Check for batch item arrays first (element_type = BatchBytesItem/BatchFileItem).
1016                    if v.is_array() {
1017                        if let Some(elem_type) = &arg.element_type {
1018                            if elem_type == "BatchBytesItem" || elem_type == "BatchFileItem" {
1019                                parts.push(emit_java_batch_item_array(v, elem_type));
1020                                continue;
1021                            }
1022                        }
1023                        // Otherwise use element_type to emit the correct numeric literal suffix (f vs d).
1024                        let elem_type = arg.element_type.as_deref();
1025                        parts.push(json_to_java_typed(v, elem_type));
1026                        continue;
1027                    }
1028                    // Object json_object args with options_type: use pre-deserialized variable.
1029                    if options_type.is_some() {
1030                        parts.push(arg.name.clone());
1031                        continue;
1032                    }
1033                    parts.push(json_to_java(v));
1034                    continue;
1035                }
1036                // bytes args must be passed as byte[], not String.
1037                if arg.arg_type == "bytes" {
1038                    let val = json_to_java(v);
1039                    parts.push(format!("{val}.getBytes()"));
1040                    continue;
1041                }
1042                // file_path args must be wrapped in java.nio.file.Path.of().
1043                if arg.arg_type == "file_path" {
1044                    let val = json_to_java(v);
1045                    parts.push(format!("java.nio.file.Path.of({val})"));
1046                    continue;
1047                }
1048                parts.push(json_to_java(v));
1049            }
1050        }
1051    }
1052
1053    (setup_lines, parts.join(", "))
1054}
1055
1056#[allow(clippy::too_many_arguments)]
1057fn render_assertion(
1058    out: &mut String,
1059    assertion: &Assertion,
1060    result_var: &str,
1061    class_name: &str,
1062    field_resolver: &FieldResolver,
1063    result_is_simple: bool,
1064    result_is_bytes: bool,
1065    enum_fields: &std::collections::HashMap<String, String>,
1066) {
1067    // Handle synthetic/virtual fields that are computed rather than direct record accessors.
1068    if let Some(f) = &assertion.field {
1069        match f.as_str() {
1070            // ---- ExtractionResult chunk-level computed predicates ----
1071            "chunks_have_content" => {
1072                let pred = format!(
1073                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.content() != null && !c.content().isBlank())"
1074                );
1075                match assertion.assertion_type.as_str() {
1076                    "is_true" => {
1077                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
1078                    }
1079                    "is_false" => {
1080                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
1081                    }
1082                    _ => {
1083                        let _ = writeln!(
1084                            out,
1085                            "        // skipped: unsupported assertion on synthetic field '{f}'"
1086                        );
1087                    }
1088                }
1089                return;
1090            }
1091            "chunks_have_heading_context" => {
1092                let pred = format!(
1093                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.metadata().headingContext().isPresent())"
1094                );
1095                match assertion.assertion_type.as_str() {
1096                    "is_true" => {
1097                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
1098                    }
1099                    "is_false" => {
1100                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
1101                    }
1102                    _ => {
1103                        let _ = writeln!(
1104                            out,
1105                            "        // skipped: unsupported assertion on synthetic field '{f}'"
1106                        );
1107                    }
1108                }
1109                return;
1110            }
1111            "chunks_have_embeddings" => {
1112                let pred = format!(
1113                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.embedding() != null && !c.embedding().isEmpty())"
1114                );
1115                match assertion.assertion_type.as_str() {
1116                    "is_true" => {
1117                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
1118                    }
1119                    "is_false" => {
1120                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
1121                    }
1122                    _ => {
1123                        let _ = writeln!(
1124                            out,
1125                            "        // skipped: unsupported assertion on synthetic field '{f}'"
1126                        );
1127                    }
1128                }
1129                return;
1130            }
1131            "first_chunk_starts_with_heading" => {
1132                let pred = format!(
1133                    "{result_var}.chunks().orElse(java.util.List.of()).stream().findFirst().map(c -> c.metadata().headingContext().isPresent()).orElse(false)"
1134                );
1135                match assertion.assertion_type.as_str() {
1136                    "is_true" => {
1137                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
1138                    }
1139                    "is_false" => {
1140                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
1141                    }
1142                    _ => {
1143                        let _ = writeln!(
1144                            out,
1145                            "        // skipped: unsupported assertion on synthetic field '{f}'"
1146                        );
1147                    }
1148                }
1149                return;
1150            }
1151            // ---- EmbedResponse virtual fields ----
1152            // When result_is_simple=true the result IS List<List<Float>> (the raw embeddings list).
1153            // When result_is_simple=false the result has an .embeddings() accessor.
1154            "embedding_dimensions" => {
1155                // Dimension = size of the first embedding vector in the list.
1156                let embed_list = if result_is_simple {
1157                    result_var.to_string()
1158                } else {
1159                    format!("{result_var}.embeddings()")
1160                };
1161                let expr = format!("({embed_list}.isEmpty() ? 0 : {embed_list}.get(0).size())");
1162                match assertion.assertion_type.as_str() {
1163                    "equals" => {
1164                        if let Some(val) = &assertion.value {
1165                            let java_val = json_to_java(val);
1166                            let _ = writeln!(out, "        assertEquals({java_val}, {expr});");
1167                        }
1168                    }
1169                    "greater_than" => {
1170                        if let Some(val) = &assertion.value {
1171                            let java_val = json_to_java(val);
1172                            let _ = writeln!(
1173                                out,
1174                                "        assertTrue({expr} > {java_val}, \"expected > {java_val}\");"
1175                            );
1176                        }
1177                    }
1178                    _ => {
1179                        let _ = writeln!(out, "        // skipped: unsupported assertion on '{f}'");
1180                    }
1181                }
1182                return;
1183            }
1184            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1185                // These are validation predicates that require iterating the embedding matrix.
1186                let embed_list = if result_is_simple {
1187                    result_var.to_string()
1188                } else {
1189                    format!("{result_var}.embeddings()")
1190                };
1191                let pred = match f.as_str() {
1192                    "embeddings_valid" => {
1193                        format!("{embed_list}.stream().allMatch(e -> e != null && !e.isEmpty())")
1194                    }
1195                    "embeddings_finite" => {
1196                        format!("{embed_list}.stream().flatMap(java.util.Collection::stream).allMatch(Float::isFinite)")
1197                    }
1198                    "embeddings_non_zero" => {
1199                        format!("{embed_list}.stream().allMatch(e -> e.stream().anyMatch(v -> v != 0.0f))")
1200                    }
1201                    "embeddings_normalized" => format!(
1202                        "{embed_list}.stream().allMatch(e -> {{ double n = e.stream().mapToDouble(v -> v * v).sum(); return Math.abs(n - 1.0) < 1e-3; }})"
1203                    ),
1204                    _ => unreachable!(),
1205                };
1206                match assertion.assertion_type.as_str() {
1207                    "is_true" => {
1208                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
1209                    }
1210                    "is_false" => {
1211                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
1212                    }
1213                    _ => {
1214                        let _ = writeln!(out, "        // skipped: unsupported assertion on '{f}'");
1215                    }
1216                }
1217                return;
1218            }
1219            // ---- Fields not present on the Java ExtractionResult ----
1220            "keywords" | "keywords_count" => {
1221                let _ = writeln!(
1222                    out,
1223                    "        // skipped: field '{f}' not available on Java ExtractionResult"
1224                );
1225                return;
1226            }
1227            // ---- metadata not_empty / is_empty: Metadata is a required record, not Optional ----
1228            // Metadata has no .isEmpty() method; check that at least one optional field is present.
1229            "metadata" => {
1230                match assertion.assertion_type.as_str() {
1231                    "not_empty" => {
1232                        let _ = writeln!(
1233                            out,
1234                            "        assertTrue({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected non-empty value\");"
1235                        );
1236                        return;
1237                    }
1238                    "is_empty" => {
1239                        let _ = writeln!(
1240                            out,
1241                            "        assertFalse({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected empty value\");"
1242                        );
1243                        return;
1244                    }
1245                    _ => {} // fall through to normal handling
1246                }
1247            }
1248            _ => {}
1249        }
1250    }
1251
1252    // Skip assertions on fields that don't exist on the result type.
1253    if let Some(f) = &assertion.field {
1254        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1255            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1256            return;
1257        }
1258    }
1259
1260    // Determine if this field is an enum type (no `.contains()` on enums in Java).
1261    // Check both the raw fixture field path and the resolved (aliased) path so that
1262    // `fields_enum` entries can use either form (e.g., `"assets[].category"` or the
1263    // resolved `"assets[].asset_category"`).
1264    let field_is_enum = assertion
1265        .field
1266        .as_deref()
1267        .is_some_and(|f| enum_fields.contains_key(f) || enum_fields.contains_key(field_resolver.resolve(f)));
1268
1269    // Determine if this field is an array (List<T>) — needed to choose .toString() for
1270    // contains assertions, since List.contains(Object) uses equals() which won't match
1271    // strings against complex record types like StructureItem.
1272    let field_is_array = assertion
1273        .field
1274        .as_deref()
1275        .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1276
1277    let field_expr = if result_is_simple {
1278        result_var.to_string()
1279    } else {
1280        match &assertion.field {
1281            Some(f) if !f.is_empty() => {
1282                let accessor = field_resolver.accessor(f, "java", result_var);
1283                let resolved = field_resolver.resolve(f);
1284                // Unwrap Optional fields with a type-appropriate fallback.
1285                // Map.get() returns nullable, not Optional, so skip .orElse() for map access.
1286                // NOTE: is_optional() means the field is in optional_fields, but that doesn't
1287                // guarantee it returns Optional<T> in Java — nested fields like metadata.twitterCard
1288                // return @Nullable String, not Optional<String>. We detect this by checking
1289                // if the field path contains a dot (nested access).
1290                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
1291                    // All nullable fields in the Java binding return @Nullable types, not Optional<T>.
1292                    // Wrap them in Optional.ofNullable() so e2e tests can use .orElse() fallbacks.
1293                    let optional_expr = format!("java.util.Optional.ofNullable({accessor})");
1294                    match assertion.assertion_type.as_str() {
1295                        // For not_empty / is_empty on Optional fields, return the raw Optional
1296                        // so the assertion arms can call isPresent()/isEmpty().
1297                        "not_empty" | "is_empty" => optional_expr,
1298                        // For size/count assertions on Optional<List<T>> fields, use List.of() fallback.
1299                        "count_min" | "count_equals" => {
1300                            format!("{optional_expr}.orElse(java.util.List.of())")
1301                        }
1302                        // For numeric comparisons on Optional<Long/Integer> fields, use 0L.
1303                        "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
1304                            if field_resolver.is_array(resolved) {
1305                                format!("{optional_expr}.orElse(java.util.List.of())")
1306                            } else {
1307                                format!("{optional_expr}.orElse(0L)")
1308                            }
1309                        }
1310                        // For equals on Optional fields, determine fallback based on whether value is numeric.
1311                        // If the fixture value is a number, use 0L; otherwise use "".
1312                        "equals" => {
1313                            if let Some(expected) = &assertion.value {
1314                                if expected.is_number() {
1315                                    format!("{optional_expr}.orElse(0L)")
1316                                } else {
1317                                    format!("{optional_expr}.orElse(\"\")")
1318                                }
1319                            } else {
1320                                format!("{optional_expr}.orElse(\"\")")
1321                            }
1322                        }
1323                        _ if field_resolver.is_array(resolved) => {
1324                            format!("{optional_expr}.orElse(java.util.List.of())")
1325                        }
1326                        _ => format!("{optional_expr}.orElse(\"\")"),
1327                    }
1328                } else {
1329                    accessor
1330                }
1331            }
1332            _ => result_var.to_string(),
1333        }
1334    };
1335
1336    // For enum fields, string-based assertions need .getValue() to convert the enum to
1337    // its serde-serialized lowercase string value (e.g., AssetCategory.Image -> "image").
1338    // All alef-generated Java enums expose a getValue() method annotated with @JsonValue.
1339    let string_expr = if field_is_enum {
1340        format!("{field_expr}.getValue()")
1341    } else {
1342        field_expr.clone()
1343    };
1344
1345    match assertion.assertion_type.as_str() {
1346        "equals" => {
1347            if let Some(expected) = &assertion.value {
1348                let java_val = json_to_java(expected);
1349                if expected.is_string() {
1350                    let _ = writeln!(out, "        assertEquals({java_val}, {string_expr}.trim());");
1351                } else if expected.is_number() && field_expr.contains(".orElse(\"\")") {
1352                    // For numeric "equals" on Optional fields with string fallback,
1353                    // the field must be Optional<Long/Integer>, not Optional<String>.
1354                    // Replace the string fallback with a numeric one.
1355                    let fixed_expr = field_expr.replace(".orElse(\"\")", ".orElse(0L)");
1356                    let _ = writeln!(out, "        assertEquals({java_val}, {fixed_expr});");
1357                } else {
1358                    let _ = writeln!(out, "        assertEquals({java_val}, {field_expr});");
1359                }
1360            }
1361        }
1362        "contains" => {
1363            if let Some(expected) = &assertion.value {
1364                let java_val = json_to_java(expected);
1365                // For array fields of complex objects (e.g. List<StructureItem>), use .toString()
1366                // because List.contains(Object) uses equals(), which won't match a String against
1367                // a record type. Java records produce toString() like "StructureItem[kind=Function, ...]".
1368                let check_expr = if field_is_array {
1369                    format!("{string_expr}.toString()")
1370                } else {
1371                    string_expr.clone()
1372                };
1373                let _ = writeln!(
1374                    out,
1375                    "        assertTrue({check_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1376                );
1377            }
1378        }
1379        "contains_all" => {
1380            if let Some(values) = &assertion.values {
1381                for val in values {
1382                    let java_val = json_to_java(val);
1383                    let check_expr = if field_is_array {
1384                        format!("{string_expr}.toString()")
1385                    } else {
1386                        string_expr.clone()
1387                    };
1388                    let _ = writeln!(
1389                        out,
1390                        "        assertTrue({check_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1391                    );
1392                }
1393            }
1394        }
1395        "not_contains" => {
1396            if let Some(expected) = &assertion.value {
1397                let java_val = json_to_java(expected);
1398                let check_expr = if field_is_array {
1399                    format!("{string_expr}.toString()")
1400                } else {
1401                    string_expr.clone()
1402                };
1403                let _ = writeln!(
1404                    out,
1405                    "        assertFalse({check_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
1406                );
1407            }
1408        }
1409        "not_empty" => {
1410            let _ = writeln!(
1411                out,
1412                "        assertFalse({field_expr} == null || {field_expr}.isEmpty(), \"expected non-empty value\");"
1413            );
1414        }
1415        "is_empty" => {
1416            let _ = writeln!(
1417                out,
1418                "        assertTrue({field_expr} == null || {field_expr}.isEmpty(), \"expected empty value\");"
1419            );
1420        }
1421        "contains_any" => {
1422            if let Some(values) = &assertion.values {
1423                let checks: Vec<String> = values
1424                    .iter()
1425                    .map(|v| {
1426                        let java_val = json_to_java(v);
1427                        format!("{string_expr}.contains({java_val})")
1428                    })
1429                    .collect();
1430                let joined = checks.join(" || ");
1431                let _ = writeln!(
1432                    out,
1433                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\");"
1434                );
1435            }
1436        }
1437        "greater_than" => {
1438            if let Some(val) = &assertion.value {
1439                let java_val = json_to_java(val);
1440                let _ = writeln!(
1441                    out,
1442                    "        assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
1443                );
1444            }
1445        }
1446        "less_than" => {
1447            if let Some(val) = &assertion.value {
1448                let java_val = json_to_java(val);
1449                let _ = writeln!(
1450                    out,
1451                    "        assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
1452                );
1453            }
1454        }
1455        "greater_than_or_equal" => {
1456            if let Some(val) = &assertion.value {
1457                let java_val = json_to_java(val);
1458                let _ = writeln!(
1459                    out,
1460                    "        assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
1461                );
1462            }
1463        }
1464        "less_than_or_equal" => {
1465            if let Some(val) = &assertion.value {
1466                let java_val = json_to_java(val);
1467                let _ = writeln!(
1468                    out,
1469                    "        assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
1470                );
1471            }
1472        }
1473        "starts_with" => {
1474            if let Some(expected) = &assertion.value {
1475                let java_val = json_to_java(expected);
1476                let _ = writeln!(
1477                    out,
1478                    "        assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
1479                );
1480            }
1481        }
1482        "ends_with" => {
1483            if let Some(expected) = &assertion.value {
1484                let java_val = json_to_java(expected);
1485                let _ = writeln!(
1486                    out,
1487                    "        assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
1488                );
1489            }
1490        }
1491        "min_length" => {
1492            if let Some(val) = &assertion.value {
1493                if let Some(n) = val.as_u64() {
1494                    // byte[] uses `.length` (array field), String uses `.length()` (method).
1495                    let len_expr = if result_is_bytes {
1496                        format!("{field_expr}.length")
1497                    } else {
1498                        format!("{field_expr}.length()")
1499                    };
1500                    let _ = writeln!(
1501                        out,
1502                        "        assertTrue({len_expr} >= {n}, \"expected length >= {n}\");"
1503                    );
1504                }
1505            }
1506        }
1507        "max_length" => {
1508            if let Some(val) = &assertion.value {
1509                if let Some(n) = val.as_u64() {
1510                    let len_expr = if result_is_bytes {
1511                        format!("{field_expr}.length")
1512                    } else {
1513                        format!("{field_expr}.length()")
1514                    };
1515                    let _ = writeln!(
1516                        out,
1517                        "        assertTrue({len_expr} <= {n}, \"expected length <= {n}\");"
1518                    );
1519                }
1520            }
1521        }
1522        "count_min" => {
1523            if let Some(val) = &assertion.value {
1524                if let Some(n) = val.as_u64() {
1525                    let _ = writeln!(
1526                        out,
1527                        "        assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
1528                    );
1529                }
1530            }
1531        }
1532        "count_equals" => {
1533            if let Some(val) = &assertion.value {
1534                if let Some(n) = val.as_u64() {
1535                    let _ = writeln!(
1536                        out,
1537                        "        assertEquals({n}, {field_expr}.size(), \"expected exactly {n} elements\");"
1538                    );
1539                }
1540            }
1541        }
1542        "is_true" => {
1543            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\");");
1544        }
1545        "is_false" => {
1546            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\");");
1547        }
1548        "method_result" => {
1549            if let Some(method_name) = &assertion.method {
1550                let call_expr = build_java_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1551                let check = assertion.check.as_deref().unwrap_or("is_true");
1552                // Methods that return a collection (List) rather than a scalar.
1553                let method_returns_collection =
1554                    matches!(method_name.as_str(), "find_nodes_by_type" | "findNodesByType");
1555                match check {
1556                    "equals" => {
1557                        if let Some(val) = &assertion.value {
1558                            if val.is_boolean() {
1559                                if val.as_bool() == Some(true) {
1560                                    let _ = writeln!(out, "        assertTrue({call_expr});");
1561                                } else {
1562                                    let _ = writeln!(out, "        assertFalse({call_expr});");
1563                                }
1564                            } else if method_returns_collection {
1565                                let java_val = json_to_java(val);
1566                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr}.size());");
1567                            } else {
1568                                let java_val = json_to_java(val);
1569                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr});");
1570                            }
1571                        }
1572                    }
1573                    "is_true" => {
1574                        let _ = writeln!(out, "        assertTrue({call_expr});");
1575                    }
1576                    "is_false" => {
1577                        let _ = writeln!(out, "        assertFalse({call_expr});");
1578                    }
1579                    "greater_than_or_equal" => {
1580                        if let Some(val) = &assertion.value {
1581                            let n = val.as_u64().unwrap_or(0);
1582                            let _ = writeln!(out, "        assertTrue({call_expr} >= {n}, \"expected >= {n}\");");
1583                        }
1584                    }
1585                    "count_min" => {
1586                        if let Some(val) = &assertion.value {
1587                            let n = val.as_u64().unwrap_or(0);
1588                            let _ = writeln!(
1589                                out,
1590                                "        assertTrue({call_expr}.size() >= {n}, \"expected at least {n} elements\");"
1591                            );
1592                        }
1593                    }
1594                    "is_error" => {
1595                        let _ = writeln!(out, "        assertThrows(Exception.class, () -> {{ {call_expr}; }});");
1596                    }
1597                    "contains" => {
1598                        if let Some(val) = &assertion.value {
1599                            let java_val = json_to_java(val);
1600                            let _ = writeln!(
1601                                out,
1602                                "        assertTrue({call_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1603                            );
1604                        }
1605                    }
1606                    other_check => {
1607                        panic!("Java e2e generator: unsupported method_result check type: {other_check}");
1608                    }
1609                }
1610            } else {
1611                panic!("Java e2e generator: method_result assertion missing 'method' field");
1612            }
1613        }
1614        "matches_regex" => {
1615            if let Some(expected) = &assertion.value {
1616                let java_val = json_to_java(expected);
1617                let _ = writeln!(
1618                    out,
1619                    "        assertTrue({string_expr}.matches({java_val}), \"expected value to match regex: \" + {java_val});"
1620                );
1621            }
1622        }
1623        "not_error" => {
1624            // Already handled by the call succeeding without exception.
1625        }
1626        "error" => {
1627            // Handled at the test method level.
1628        }
1629        other => {
1630            panic!("Java e2e generator: unsupported assertion type: {other}");
1631        }
1632    }
1633}
1634
1635/// Build a Java call expression for a `method_result` assertion on a tree-sitter Tree.
1636///
1637/// Maps method names to the appropriate Java static/instance method calls.
1638fn build_java_method_call(
1639    result_var: &str,
1640    method_name: &str,
1641    args: Option<&serde_json::Value>,
1642    class_name: &str,
1643) -> String {
1644    match method_name {
1645        "root_child_count" => format!("{result_var}.rootNode().childCount()"),
1646        "root_node_type" => format!("{result_var}.rootNode().kind()"),
1647        "named_children_count" => format!("{result_var}.rootNode().namedChildCount()"),
1648        "has_error_nodes" => format!("{class_name}.treeHasErrorNodes({result_var})"),
1649        "error_count" | "tree_error_count" => format!("{class_name}.treeErrorCount({result_var})"),
1650        "tree_to_sexp" => format!("{class_name}.treeToSexp({result_var})"),
1651        "contains_node_type" => {
1652            let node_type = args
1653                .and_then(|a| a.get("node_type"))
1654                .and_then(|v| v.as_str())
1655                .unwrap_or("");
1656            format!("{class_name}.treeContainsNodeType({result_var}, \"{node_type}\")")
1657        }
1658        "find_nodes_by_type" => {
1659            let node_type = args
1660                .and_then(|a| a.get("node_type"))
1661                .and_then(|v| v.as_str())
1662                .unwrap_or("");
1663            format!("{class_name}.findNodesByType({result_var}, \"{node_type}\")")
1664        }
1665        "run_query" => {
1666            let query_source = args
1667                .and_then(|a| a.get("query_source"))
1668                .and_then(|v| v.as_str())
1669                .unwrap_or("");
1670            let language = args
1671                .and_then(|a| a.get("language"))
1672                .and_then(|v| v.as_str())
1673                .unwrap_or("");
1674            let escaped_query = escape_java(query_source);
1675            format!("{class_name}.runQuery({result_var}, \"{language}\", \"{escaped_query}\", source)")
1676        }
1677        _ => {
1678            format!("{result_var}.{}()", method_name.to_lower_camel_case())
1679        }
1680    }
1681}
1682
1683/// Convert a `serde_json::Value` to a Java literal string.
1684fn json_to_java(value: &serde_json::Value) -> String {
1685    json_to_java_typed(value, None)
1686}
1687
1688/// Convert a JSON value to a Java literal, optionally overriding number type for array elements.
1689/// `element_type` controls how numeric array elements are emitted: "f32" → `1.0f`, otherwise `1.0d`.
1690/// Emit Java batch item constructors for BatchBytesItem or BatchFileItem arrays.
1691fn emit_java_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1692    if let Some(items) = arr.as_array() {
1693        let item_strs: Vec<String> = items
1694            .iter()
1695            .filter_map(|item| {
1696                if let Some(obj) = item.as_object() {
1697                    match elem_type {
1698                        "BatchBytesItem" => {
1699                            let content = obj.get("content").and_then(|v| v.as_array());
1700                            let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1701                            let content_code = if let Some(arr) = content {
1702                                let bytes: Vec<String> = arr
1703                                    .iter()
1704                                    .filter_map(|v| v.as_u64().map(|n| format!("(byte) {}", n)))
1705                                    .collect();
1706                                format!("new byte[] {{{}}}", bytes.join(", "))
1707                            } else {
1708                                "new byte[] {}".to_string()
1709                            };
1710                            Some(format!("new {}({}, \"{}\", null)", elem_type, content_code, mime_type))
1711                        }
1712                        "BatchFileItem" => {
1713                            let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1714                            Some(format!(
1715                                "new {}(java.nio.file.Paths.get(\"{}\"), null)",
1716                                elem_type, path
1717                            ))
1718                        }
1719                        _ => None,
1720                    }
1721                } else {
1722                    None
1723                }
1724            })
1725            .collect();
1726        format!("java.util.Arrays.asList({})", item_strs.join(", "))
1727    } else {
1728        "java.util.List.of()".to_string()
1729    }
1730}
1731
1732fn json_to_java_typed(value: &serde_json::Value, element_type: Option<&str>) -> String {
1733    match value {
1734        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
1735        serde_json::Value::Bool(b) => b.to_string(),
1736        serde_json::Value::Number(n) => {
1737            if n.is_f64() {
1738                match element_type {
1739                    Some("f32" | "float" | "Float") => format!("{}f", n),
1740                    _ => format!("{}d", n),
1741                }
1742            } else {
1743                n.to_string()
1744            }
1745        }
1746        serde_json::Value::Null => "null".to_string(),
1747        serde_json::Value::Array(arr) => {
1748            let items: Vec<String> = arr.iter().map(|v| json_to_java_typed(v, element_type)).collect();
1749            format!("java.util.List.of({})", items.join(", "))
1750        }
1751        serde_json::Value::Object(_) => {
1752            let json_str = serde_json::to_string(value).unwrap_or_default();
1753            format!("\"{}\"", escape_java(&json_str))
1754        }
1755    }
1756}
1757
1758/// Generate a Java builder expression for a JSON object.
1759/// E.g., `obj = {"language": "abl", "chunk_max_size": 50}`
1760/// becomes: `TypeName.builder().withLanguage("abl").withChunkMaxSize(50L).build()`
1761///
1762/// For enums: emit `EnumType.VariantName` (detected via camelCase lookup in enum_fields)
1763/// For strings and bools: use the value directly
1764/// For plain numbers: emit the literal with type suffix (long uses L, double uses d)
1765/// For nested objects: recurse with Options suffix
1766/// When `nested_types_optional` is false, nested builders are passed directly without
1767/// Optional.of() wrapping, allowing non-optional nested config types.
1768fn java_builder_expression(
1769    obj: &serde_json::Map<String, serde_json::Value>,
1770    type_name: &str,
1771    enum_fields: &std::collections::HashMap<String, String>,
1772    nested_types: &std::collections::HashMap<String, String>,
1773    nested_types_optional: bool,
1774    path_fields: &[String],
1775) -> String {
1776    let mut expr = format!("{}.builder()", type_name);
1777    for (key, val) in obj {
1778        // Convert snake_case key to camelCase for method name
1779        let camel_key = key.to_lower_camel_case();
1780        let method_name = format!("with{}", camel_key.to_upper_camel_case());
1781
1782        let java_val = match val {
1783            serde_json::Value::String(s) => {
1784                // Check if this field is an enum type by looking up in enum_fields.
1785                // enum_fields is keyed by camelCase names (e.g., "codeBlockStyle"), not snake_case.
1786                if let Some(enum_type_name) = enum_fields.get(&camel_key) {
1787                    // Enum field: use the mapped enum type name from the config
1788                    let variant_name = s.to_upper_camel_case();
1789                    format!("{}.{}", enum_type_name, variant_name)
1790                } else if camel_key == "preset" && type_name == "PreprocessingOptions" {
1791                    // Special case: preset field in PreprocessingOptions maps to PreprocessingPreset
1792                    let variant_name = s.to_upper_camel_case();
1793                    format!("PreprocessingPreset.{}", variant_name)
1794                } else if path_fields.contains(key) {
1795                    // Path field: wrap in Optional.of(java.nio.file.Path.of(...))
1796                    format!("Optional.of(java.nio.file.Path.of(\"{}\"))", escape_java(s))
1797                } else {
1798                    // String field: emit as a quoted literal
1799                    format!("\"{}\"", escape_java(s))
1800                }
1801            }
1802            serde_json::Value::Bool(b) => b.to_string(),
1803            serde_json::Value::Null => "null".to_string(),
1804            serde_json::Value::Number(n) => {
1805                // Number field: emit literal with type suffix.
1806                // Java records/classes use either `long` (primitive, not nullable) or
1807                // `Optional<Long>` (nullable). The codegen wraps in `Optional.of(...)`
1808                // by default since most options builder fields are Optional, but several
1809                // record types (e.g. SecurityLimits) use primitive `long` throughout.
1810                // Skip the wrap for: (a) known-primitive top-level fields and (b) any
1811                // method on a record type whose builder methods take primitives only.
1812                let camel_key = key.to_lower_camel_case();
1813                let is_plain_field = matches!(camel_key.as_str(), "listIndentWidth" | "wrapWidth");
1814                // Builders for typed-record nested config classes use primitives
1815                // throughout — they're not the optional-options pattern.
1816                let is_primitive_builder = matches!(type_name, "SecurityLimits" | "SecurityLimitsBuilder");
1817
1818                if is_plain_field || is_primitive_builder {
1819                    // Plain numeric field: no Optional wrapper
1820                    if n.is_f64() {
1821                        format!("{}d", n)
1822                    } else {
1823                        format!("{}L", n)
1824                    }
1825                } else {
1826                    // Optional numeric field: wrap in Optional.of()
1827                    if n.is_f64() {
1828                        format!("Optional.of({}d)", n)
1829                    } else {
1830                        format!("Optional.of({}L)", n)
1831                    }
1832                }
1833            }
1834            serde_json::Value::Array(arr) => {
1835                let items: Vec<String> = arr.iter().map(|v| json_to_java_typed(v, None)).collect();
1836                format!("java.util.List.of({})", items.join(", "))
1837            }
1838            serde_json::Value::Object(nested) => {
1839                // Recurse with the type from nested_types mapping, or default to snake_case → PascalCase + "Options".
1840                let nested_type = nested_types
1841                    .get(key.as_str())
1842                    .cloned()
1843                    .unwrap_or_else(|| format!("{}Options", key.to_upper_camel_case()));
1844                let inner = java_builder_expression(
1845                    nested,
1846                    &nested_type,
1847                    enum_fields,
1848                    nested_types,
1849                    nested_types_optional,
1850                    &[],
1851                );
1852                // Top-level config builders (e.g. ExtractionConfigBuilder) declare nested
1853                // record fields as `Optional<T>` (since they are nullable). Primitive-fields
1854                // builders (SecurityLimitsBuilder etc.) take the bare type directly.
1855                let is_primitive_builder = matches!(type_name, "SecurityLimits" | "SecurityLimitsBuilder");
1856                if is_primitive_builder || !nested_types_optional {
1857                    inner
1858                } else {
1859                    format!("Optional.of({inner})")
1860                }
1861            }
1862        };
1863        expr.push_str(&format!(".{}({})", method_name, java_val));
1864    }
1865    expr.push_str(".build()");
1866    expr
1867}
1868
1869/// Build default nested type mappings for Java extraction config types.
1870///
1871/// Maps known Kreuzberg/Kreuzcrawl config field names (in snake_case) to their
1872/// Java record type names (in PascalCase). These defaults allow e2e codegen to
1873/// automatically deserialize nested config objects without requiring explicit
1874/// configuration in alef.toml. User-provided overrides take precedence.
1875fn default_java_nested_types() -> std::collections::HashMap<String, String> {
1876    [
1877        ("chunking", "ChunkingConfig"),
1878        ("ocr", "OcrConfig"),
1879        ("images", "ImageExtractionConfig"),
1880        ("html_output", "HtmlOutputConfig"),
1881        ("language_detection", "LanguageDetectionConfig"),
1882        ("postprocessor", "PostProcessorConfig"),
1883        ("acceleration", "AccelerationConfig"),
1884        ("email", "EmailConfig"),
1885        ("pages", "PageConfig"),
1886        ("pdf_options", "PdfConfig"),
1887        ("layout", "LayoutDetectionConfig"),
1888        ("tree_sitter", "TreeSitterConfig"),
1889        ("structured_extraction", "StructuredExtractionConfig"),
1890        ("content_filter", "ContentFilterConfig"),
1891        ("token_reduction", "TokenReductionOptions"),
1892        ("security_limits", "SecurityLimits"),
1893    ]
1894    .iter()
1895    .map(|(k, v)| (k.to_string(), v.to_string()))
1896    .collect()
1897}
1898
1899// ---------------------------------------------------------------------------
1900// Import collection helpers
1901// ---------------------------------------------------------------------------
1902
1903/// Recursively collect enum types and nested option types used in a builder expression.
1904/// Enums are keyed in the enum_fields map by camelCase names (e.g., "codeBlockStyle" → "CodeBlockStyle").
1905fn collect_enum_and_nested_types(
1906    obj: &serde_json::Map<String, serde_json::Value>,
1907    enum_fields: &std::collections::HashMap<String, String>,
1908    types_out: &mut std::collections::BTreeSet<String>,
1909) {
1910    for (key, val) in obj {
1911        // enum_fields is keyed by camelCase, not snake_case.
1912        let camel_key = key.to_lower_camel_case();
1913        if let Some(enum_type) = enum_fields.get(&camel_key) {
1914            // Add the enum type from the mapping (e.g., "CodeBlockStyle").
1915            types_out.insert(enum_type.clone());
1916        } else if camel_key == "preset" {
1917            // Special case: preset field uses PreprocessingPreset enum.
1918            types_out.insert("PreprocessingPreset".to_string());
1919        }
1920        // Recurse into nested objects to find their nested enum types.
1921        if let Some(nested) = val.as_object() {
1922            collect_enum_and_nested_types(nested, enum_fields, types_out);
1923        }
1924    }
1925}
1926
1927fn collect_nested_type_names(
1928    obj: &serde_json::Map<String, serde_json::Value>,
1929    nested_types: &std::collections::HashMap<String, String>,
1930    types_out: &mut std::collections::BTreeSet<String>,
1931) {
1932    for (key, val) in obj {
1933        if let Some(type_name) = nested_types.get(key.as_str()) {
1934            types_out.insert(type_name.clone());
1935        }
1936        if let Some(nested) = val.as_object() {
1937            collect_nested_type_names(nested, nested_types, types_out);
1938        }
1939    }
1940}
1941
1942// ---------------------------------------------------------------------------
1943// Visitor generation
1944// ---------------------------------------------------------------------------
1945
1946/// Build a Java visitor class and add setup lines. Returns the visitor variable name.
1947fn build_java_visitor(
1948    setup_lines: &mut Vec<String>,
1949    visitor_spec: &crate::fixture::VisitorSpec,
1950    class_name: &str,
1951) -> String {
1952    setup_lines.push("class _TestVisitor implements Visitor {".to_string());
1953    for (method_name, action) in &visitor_spec.callbacks {
1954        emit_java_visitor_method(setup_lines, method_name, action, class_name);
1955    }
1956    setup_lines.push("}".to_string());
1957    setup_lines.push("var visitor = new _TestVisitor();".to_string());
1958    "visitor".to_string()
1959}
1960
1961/// Emit a Java visitor method for a callback action.
1962fn emit_java_visitor_method(
1963    setup_lines: &mut Vec<String>,
1964    method_name: &str,
1965    action: &CallbackAction,
1966    _class_name: &str,
1967) {
1968    let camel_method = method_to_camel(method_name);
1969    let params = match method_name {
1970        "visit_link" => "NodeContext ctx, String href, String text, String title",
1971        "visit_image" => "NodeContext ctx, String src, String alt, String title",
1972        "visit_heading" => "NodeContext ctx, int level, String text, String id",
1973        "visit_code_block" => "NodeContext ctx, String lang, String code",
1974        "visit_code_inline"
1975        | "visit_strong"
1976        | "visit_emphasis"
1977        | "visit_strikethrough"
1978        | "visit_underline"
1979        | "visit_subscript"
1980        | "visit_superscript"
1981        | "visit_mark"
1982        | "visit_button"
1983        | "visit_summary"
1984        | "visit_figcaption"
1985        | "visit_definition_term"
1986        | "visit_definition_description" => "NodeContext ctx, String text",
1987        "visit_text" => "NodeContext ctx, String text",
1988        "visit_list_item" => "NodeContext ctx, boolean ordered, String marker, String text",
1989        "visit_blockquote" => "NodeContext ctx, String content, long depth",
1990        "visit_table_row" => "NodeContext ctx, java.util.List<String> cells, boolean isHeader",
1991        "visit_custom_element" => "NodeContext ctx, String tagName, String html",
1992        "visit_form" => "NodeContext ctx, String actionUrl, String method",
1993        "visit_input" => "NodeContext ctx, String inputType, String name, String value",
1994        "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, String src",
1995        "visit_details" => "NodeContext ctx, boolean isOpen",
1996        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1997            "NodeContext ctx, String output"
1998        }
1999        "visit_list_start" => "NodeContext ctx, boolean ordered",
2000        "visit_list_end" => "NodeContext ctx, boolean ordered, String output",
2001        _ => "NodeContext ctx",
2002    };
2003
2004    setup_lines.push(format!("    @Override public VisitResult {camel_method}({params}) {{"));
2005    match action {
2006        CallbackAction::Skip => {
2007            setup_lines.push("        return VisitResult.skip();".to_string());
2008        }
2009        CallbackAction::Continue => {
2010            setup_lines.push("        return VisitResult.continue_();".to_string());
2011        }
2012        CallbackAction::PreserveHtml => {
2013            setup_lines.push("        return VisitResult.preserveHtml();".to_string());
2014        }
2015        CallbackAction::Custom { output } => {
2016            let escaped = escape_java(output);
2017            setup_lines.push(format!("        return VisitResult.custom(\"{escaped}\");"));
2018        }
2019        CallbackAction::CustomTemplate { template } => {
2020            // Extract {placeholder} names from the template (in order of appearance).
2021            // Convert each snake_case placeholder to the camelCase Java variable name,
2022            // then replace each {placeholder} with %s for String.format.
2023            let mut format_str = String::with_capacity(template.len());
2024            let mut format_args: Vec<String> = Vec::new();
2025            let mut chars = template.chars().peekable();
2026            while let Some(ch) = chars.next() {
2027                if ch == '{' {
2028                    // Collect identifier chars until '}'.
2029                    let mut name = String::new();
2030                    let mut closed = false;
2031                    for inner in chars.by_ref() {
2032                        if inner == '}' {
2033                            closed = true;
2034                            break;
2035                        }
2036                        name.push(inner);
2037                    }
2038                    if closed && !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
2039                        let camel_name = name.as_str().to_lower_camel_case();
2040                        format_args.push(camel_name);
2041                        format_str.push_str("%s");
2042                    } else {
2043                        // Not a simple placeholder — emit literally.
2044                        format_str.push('{');
2045                        format_str.push_str(&name);
2046                        if closed {
2047                            format_str.push('}');
2048                        }
2049                    }
2050                } else {
2051                    format_str.push(ch);
2052                }
2053            }
2054            let escaped = escape_java(&format_str);
2055            if format_args.is_empty() {
2056                setup_lines.push(format!("        return VisitResult.custom(\"{escaped}\");"));
2057            } else {
2058                let args_str = format_args.join(", ");
2059                setup_lines.push(format!(
2060                    "        return VisitResult.custom(String.format(\"{escaped}\", {args_str}));"
2061                ));
2062            }
2063        }
2064    }
2065    setup_lines.push("    }".to_string());
2066}
2067
2068/// Convert snake_case method names to Java camelCase.
2069fn method_to_camel(snake: &str) -> String {
2070    snake.to_lower_camel_case()
2071}